Make Cobble a local install: setup flow, bots-first admin, settings

Local, password-only auth:
- No public pages; everything outside /admin and /auth redirects in
- First run (no users) sends every page to /auth/signup, which creates
  the super admin (user #1) once and then locks
- Login is email/username + password; removed the emailed-code flow,
  /auth/sso and email password reset
- Google login kept for existing accounts only, never creates them
- seed-super-admin now requires SEED_SUPER_ADMIN_PASSWORD

Bots-first admin:
- /admin and closed sections (people, logs, backups) redirect to
  /admin/bots; only bots and settings are live (AdminSections)
- Removed the old admin layout; every /admin page uses the bots rail
- Coordinator bot created at signup/seed and by /admin/bots
  (ensureCoordinatorBot), which redirects to its chat
- bots.system_prompt (markdown) with a default coordinator prompt
- seed-bots reuses the existing coordinator

Settings:
- Sub pages general (default), profile, change-password, listed in the
  rail with a back link; unknown settings paths redirect to general
- Redesigned settings UI: pane header, grouped rows, sticky save bar
- General: System/Light/Dark theme, saved per device

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 17:49:25 +01:00
co-authored by Claude Opus 5.5
parent 3286b80029
commit 694c83346f
159 changed files with 1891 additions and 2964 deletions
@@ -1,4 +1,5 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio";
import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-password-hash";
@@ -26,7 +27,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
if (!user_res.singleRes) {
return {
redirect: {
destination: "/admin",
destination: AdminHomePath,
},
};
}
@@ -1,13 +0,0 @@
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import Button from "@moduletrace/twui/layout/Button";
import useFormInit from "@/src/hooks/use-form-init";
export default function AccountFormAction(
_params: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>,
) {
return (
<>
<Button title="Save Changes">Save Changes</Button>
</>
);
}
@@ -1,53 +0,0 @@
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import useFormInit from "@/src/hooks/use-form-init";
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import submitAccountForm from "../(functions)/submit-account-form";
import AccountFormAction from "./account-form-action";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import UserFormName from "@/src/pages/admin/people/create/(partials)/user-form/user-form-name";
import UserFormEmail from "@/src/pages/admin/people/create/(partials)/user-form/user-form-email";
import UserFormUsername from "@/src/pages/admin/people/create/(partials)/user-form/user-form-username";
import UserFormBio from "@/src/pages/admin/people/create/(partials)/user-form/user-form-bio";
import UserFormImage from "@/src/pages/admin/people/create/(partials)/user-form/user-form-image";
import type { UserFormObject } from "@/src/types";
type Props = {
existing_user?: UserFormObject;
existing_user_full?: BUN_SQLITE_COBBLE_USERS;
};
export default function AccountForm({ existing_user, existing_user_full }: Props) {
const init = useFormInit<UserFormObject>({
existing: existing_user,
existing_full: existing_user_full,
});
const { loading, status } = init;
return (
<>
<Form
className="w-full gap-6"
onSubmit={() => {
submitAccountForm(init);
}}
>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{loading && <LoadingOverlay />}
<UserFormName {...init} />
<Row className="w-full grid xl:grid-cols-2">
<UserFormEmail {...init} />
<UserFormUsername {...init} />
</Row>
<Row className="w-full grid xl:grid-cols-2 items-stretch">
<UserFormBio {...init} />
<UserFormImage {...init} />
</Row>
<AccountFormAction {...init} />
</Form>
</>
);
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
description?: ReactNode;
children: ReactNode;
};
/**
* One block of settings: what it is on the left, the controls on the right.
* Stacks below 768px. Blocks are separated by hairlines, never cards.
*/
export default function SettingsGroup({ title, description, children }: Props) {
return (
<Stack className="w-full gap-4! md:grid md:grid-cols-[200px_1fr] md:gap-10! py-8 border-0 border-b border-solid border-hairline last:border-b-0">
<Stack className="gap-1! min-w-0">
<H2 className="text-sm! leading-5! tracking-normal! font-bold text-ink mb-0!">
{title}
</H2>
{description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{description}
</Span>
) : null}
</Stack>
<Stack className="gap-4! min-w-0 w-full items-stretch">
{children}
</Stack>
</Stack>
);
}
@@ -0,0 +1,28 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
description: string;
};
/**
* Same 64px header as a conversation, so moving between bots and settings
* doesn't shift the page.
*/
export default function SettingsPaneHeader({ title, description }: Props) {
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{title}
</H1>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate">
{description}
</Span>
</Stack>
</Row>
);
}
@@ -0,0 +1,61 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import type { ComponentProps } from "react";
import SettingsStatus from "./settings-status";
type Props = {
dirty: boolean;
loading?: boolean;
status?: ComponentProps<typeof SettingsStatus>["status"];
save_label?: string;
onDiscard?: () => void;
};
/**
* Sticks to the bottom of the pane. Save is only enabled once something
* changed, and says so; Discard puts the form back.
*/
export default function SettingsSaveBar({
dirty,
loading,
status,
save_label = "Save changes",
onDiscard,
}: Props) {
return (
<Row className="sticky bottom-0 z-10 w-full min-h-16 gap-3! flex-nowrap items-center justify-between py-3 bg-paper border-0 border-t border-solid border-hairline">
{status?.msg ? (
<SettingsStatus status={status} />
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{dirty ? "You have unsaved changes." : "No changes yet."}
</Span>
)}
<Row className="gap-2! flex-nowrap shrink-0">
{dirty && onDiscard ? (
<Button
title="Discard changes"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={loading}
onClick={onDiscard}
>
Discard
</Button>
) : null}
<Button
title={save_label}
type="submit"
size="small"
disabled={!dirty || loading}
loading={loading}
>
{save_label}
</Button>
</Row>
</Row>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import SettingsPaneHeader from "./settings-pane-header";
type Props = {
title: string;
description: string;
children: ReactNode;
};
/**
* The settings pane beside the rail: a 64px header like the conversation
* header, then one readable column that scrolls on its own.
*/
export default function SettingsShell({ title, description, children }: Props) {
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<SettingsPaneHeader title={title} description={description} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
<Stack className="w-full max-w-[760px] mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16">
{children}
</Stack>
</Stack>
</Stack>
);
}
@@ -0,0 +1,36 @@
import Span from "@moduletrace/twui/layout/Span";
import { CircleAlert, Check } from "lucide-react";
import { twMerge } from "tailwind-merge";
type Props = {
status?: {
error?: boolean;
success?: boolean;
msg?: string;
};
};
/**
* One quiet line for the result of a save. State is said in words with an
* icon, never with color alone.
*/
export default function SettingsStatus({ status }: Props) {
if (!status?.msg || (!status.error && !status.success)) {
return null;
}
const Icon = status.error ? CircleAlert : Check;
return (
<Span
role={status.error ? "alert" : "status"}
className={twMerge(
"flex items-center gap-1.5 text-[13px] leading-[18px]",
status.error ? "text-ink" : "text-ink-secondary",
)}
>
<Icon size={14} aria-hidden className="shrink-0" />
{status.msg}
</Span>
);
}
@@ -1,35 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import AccountForm from "../(partials)/account-form";
import _ from "lodash";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
export default function AccountFormSection() {
const { pageProps } = useContext(AppContext);
const person = pageProps?.person;
if (!person) return null;
return (
<Section>
<AccountForm
existing_user={
_.pick<
BUN_SQLITE_COBBLE_USERS_JOIN,
keyof BUN_SQLITE_COBBLE_USERS_JOIN
>(person, [
"first_name",
"last_name",
"email",
"username",
"bio",
]) as UserFormObject
}
existing_user_full={person}
/>
</Section>
);
}
@@ -1,43 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
type Props = {
section_title: string;
};
export default function Hero({ section_title }: Props) {
const { pageProps } = useContext(AppContext);
const person = pageProps?.person;
if (!person) return null;
return (
<AdminHero
title={section_title}
description={
<Stack className="gap-4">
{person.user_types?.[0] ? (
<Row className="flex-nowrap">
{person.user_types?.split(",").map((ut, i) => (
<Tag key={i} color="gray" variant="outlined">
{ut}
</Tag>
))}
</Row>
) : null}
{person.bio ? (
<div
dangerouslySetInnerHTML={{ __html: person.bio }}
className="bio-subtitle"
/>
) : null}
</Stack>
}
/>
);
}
@@ -6,7 +6,7 @@ import validatePassword from "@/src/utils/validate-password";
type Params = ReturnType<typeof useFormInit<PasswordFormObject>> & {
has_password: boolean;
onDone: (params: { has_password: boolean }) => void;
onDone: (params: { has_password: boolean; msg: string }) => void;
};
export default async function submitPasswordForm({
@@ -58,6 +58,5 @@ export default async function submitPasswordForm({
return;
}
setStatus({ success: true, msg: res.msg || "Password saved." });
onDone({ has_password: true });
onDone({ has_password: true, msg: res.msg || "Password saved." });
}
@@ -0,0 +1,105 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import useFormInit from "@/src/hooks/use-form-init";
import type { PasswordFormObject } from "@/src/types";
import { AppData } from "@/src/data/app-data";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsSaveBar from "../../../(partials)/settings-save-bar";
import PasswordFormField from "./password-form-field";
import submitPasswordForm from "../../(functions)/submit-password-form";
type Props = {
has_password: boolean;
};
export default function PasswordForm({ has_password: initial }: Props) {
const init = useFormInit<PasswordFormObject>();
const { form, loading, status, setStatus } = init;
const [has_password, setHasPassword] = useState(initial);
/**
* Inputs are uncontrolled; bumping this clears them.
*/
const [form_key, setFormKey] = useState(0);
/**
* Kept here, not in `status`: `useFormInit` clears `status` whenever
* the form changes, and a successful save clears the form.
*/
const [notice, setNotice] = useState<string>();
const dirty = Boolean(
form.current_password || form.new_password || form.confirm_password,
);
const clear = () => {
init.setForm({});
setFormKey((prev) => prev + 1);
};
const onDone = ({ has_password, msg }: { has_password: boolean; msg: string }) => {
setHasPassword(has_password);
setNotice(msg);
clear();
};
const save_label = has_password ? "Change password" : "Set password";
return (
<Form
key={form_key}
className="w-full gap-0! items-stretch"
onSubmit={() => {
if (!dirty || loading) return;
submitPasswordForm({ ...init, has_password, onDone });
}}
>
{has_password ? (
<SettingsGroup
title="Current password"
description="So we know it's you."
>
<PasswordFormField
{...init}
field="current_password"
label="Current password"
autoComplete="current-password"
/>
</SettingsGroup>
) : null}
<SettingsGroup
title={has_password ? "New password" : "Password"}
description={`At least ${AppData["PasswordMinLength"]} characters. You'll stay logged in here.`}
>
<PasswordFormField
{...init}
field="new_password"
label={has_password ? "New password" : "Password"}
autoComplete="new-password"
/>
<PasswordFormField
{...init}
field="confirm_password"
label="Type it again"
autoComplete="new-password"
/>
</SettingsGroup>
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={
status?.msg
? status
: !dirty && notice
? { success: true, msg: notice }
: undefined
}
save_label={save_label}
onDiscard={() => {
setStatus({});
setNotice(undefined);
clear();
}}
/>
</Form>
);
}
@@ -0,0 +1,11 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import PasswordForm from "../(partials)/password-form";
export default function PasswordFormSection() {
const { pageProps } = useContext(AppContext);
if (!pageProps?.person) return null;
return <PasswordForm has_password={Boolean(pageProps.has_password)} />;
}
@@ -1,16 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
if (grabLoginMethod() != "password") {
return {
redirect: {
destination: "/admin/settings",
},
};
}
return await grabSettingsServerProps(params);
};
@@ -0,0 +1,16 @@
import PasswordFormSection from "./(sections)/password-form-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsChangePasswordPage() {
return (
<SettingsShell
title="Change password"
description="You log in with your email or username and this password."
>
<PasswordFormSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Password" });
@@ -0,0 +1,59 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check } from "lucide-react";
import { twMerge } from "tailwind-merge";
import type { ThemePreferences } from "@/src/dict/theme-preferences-dict";
type Props = (typeof ThemePreferences)[number] & {
selected: boolean;
onSelect: () => void;
};
/**
* One radio tile. Selected is a strong border plus a check, so it never
* depends on color alone.
*/
export default function ThemeOption({
title,
description,
Icon,
selected,
onSelect,
}: Props) {
return (
<Button
title={title}
type="button"
role="radio"
aria-checked={selected}
variant="ghost"
color="gray"
onClick={onSelect}
className={twMerge(
"relative w-full min-w-0 h-auto p-4! pr-10! justify-start! rounded-default text-left",
"bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink",
"border border-solid border-hairline transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "border-ink dark:border-ink bg-ink/5 dark:bg-ink/5",
)}
>
<Stack className="w-full min-w-0 gap-3! items-start">
<Icon size={20} aria-hidden />
<Stack className="w-full min-w-0 gap-0! items-start">
<Span className="text-sm font-bold leading-5 text-ink">
{title}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{description}
</Span>
</Stack>
</Stack>
{selected ? (
<Span className="absolute top-3 right-3 size-5 rounded-full bg-ink text-on-ink flex items-center justify-center">
<Check size={12} strokeWidth={3} aria-hidden />
</Span>
) : null}
</Button>
);
}
@@ -0,0 +1,28 @@
import Row from "@moduletrace/twui/layout/Row";
import { ThemePreferences } from "@/src/dict/theme-preferences-dict";
import useThemePreference from "@/src/hooks/use-theme-preference";
import ThemeOption from "./theme-option";
/**
* Applies on click; there is nothing to save.
*/
export default function ThemePicker() {
const { preference, setPreference } = useThemePreference();
return (
<Row
role="radiogroup"
aria-label="Theme"
className="w-full grid grid-cols-1 sm:grid-cols-3 gap-3!"
>
{ThemePreferences.map((option) => (
<ThemeOption
key={option.value}
{...option}
selected={preference == option.value}
onSelect={() => setPreference(option.value)}
/>
))}
</Row>
);
}
@@ -0,0 +1,13 @@
import SettingsGroup from "../../(partials)/settings-group";
import ThemePicker from "../(partials)/theme-picker";
export default function AppearanceSection() {
return (
<SettingsGroup
title="Theme"
description="Saved on this device only, so each computer can have its own."
>
<ThemePicker />
</SettingsGroup>
);
}
@@ -0,0 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabSettingsServerProps(params);
};
@@ -0,0 +1,16 @@
import AppearanceSection from "./(sections)/appearance-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsGeneralPage() {
return (
<SettingsShell
title="General"
description="How the app looks and behaves."
>
<AppearanceSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "General" });
+8 -4
View File
@@ -1,7 +1,11 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSettingsServerProps from "./(functions)/grab-settings-server-props";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabSettingsServerProps(params);
export const server: BunextPageServerFn = async () => {
return {
redirect: {
destination: SettingsDefaultPath,
permanent: false,
},
};
};
+11 -10
View File
@@ -1,14 +1,15 @@
import Hero from "./(sections)/hero";
import AccountFormSection from "./(sections)/account-form-section";
import settingsPageMeta from "./(functions)/settings-page-meta";
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
/**
* Never rendered: `index.server.ts` always redirects to
* `SettingsDefaultPath`.
*/
export default function AdminSettingsPage() {
return (
<>
<Hero section_title="Profile" />
<AccountFormSection />
</>
);
return null;
}
export const meta = settingsPageMeta({ label: "Profile" });
export const meta: BunextPageModuleMeta = {
title: `Settings | ${SiteData["SiteName"]}`,
description: "Redirecting to your profile settings.",
};
@@ -1,66 +0,0 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import useFormInit from "@/src/hooks/use-form-init";
import type { PasswordFormObject } from "@/src/types";
import PasswordFormField from "./password-form-field";
import PasswordFormActions from "./password-form-actions";
import submitPasswordForm from "../../(functions)/submit-password-form";
type Props = {
has_password: boolean;
};
export default function PasswordForm({ has_password: initial }: Props) {
const init = useFormInit<PasswordFormObject>();
const { loading, status } = init;
const [has_password, setHasPassword] = useState(initial);
const [form_key, setFormKey] = useState(0);
const onDone = ({ has_password }: { has_password: boolean }) => {
setHasPassword(has_password);
init.setForm({});
setFormKey((prev) => prev + 1);
};
return (
<Form
key={form_key}
className="w-full gap-6"
onSubmit={() => {
submitPasswordForm({ ...init, has_password, onDone });
}}
>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{status?.success && <Tag color="success">{status.msg}</Tag>}
{loading && <LoadingOverlay />}
{has_password ? (
<PasswordFormField
{...init}
field="current_password"
label="Current password"
autoComplete="current-password"
/>
) : null}
<Row className="w-full grid xl:grid-cols-2">
<PasswordFormField
{...init}
field="new_password"
label={has_password ? "New password" : "Password"}
autoComplete="new-password"
/>
<PasswordFormField
{...init}
field="confirm_password"
label="Confirm password"
autoComplete="new-password"
/>
</Row>
<PasswordFormActions has_password={has_password} />
</Form>
);
}
@@ -1,15 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
type Props = {
has_password: boolean;
};
export default function PasswordFormActions({ has_password }: Props) {
const label = has_password ? "Change password" : "Set password";
return (
<Button title={label} type="submit">
{label}
</Button>
);
}
@@ -1,29 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Stack from "@moduletrace/twui/layout/Stack";
import H3 from "@moduletrace/twui/layout/H3";
import Span from "@moduletrace/twui/layout/Span";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import useLoginMethod from "@/src/hooks/use-login-method";
import PasswordForm from "../(partials)/password-form";
export default function PasswordFormSection() {
const { pageProps } = useContext(AppContext);
const method = useLoginMethod();
if (!pageProps?.person || method != "password") return null;
return (
<Section>
<Stack className="w-full gap-6">
<Stack className="gap-1">
<H3 className="mb-0!">Change Password</H3>
<Span variant="faded">
You log in with your password, then a code we email you.
</Span>
</Stack>
<PasswordForm has_password={Boolean(pageProps.has_password)} />
</Stack>
</Section>
);
}
@@ -1,14 +0,0 @@
import Hero from "../(sections)/hero";
import PasswordFormSection from "./(sections)/password-form-section";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsPasswordPage() {
return (
<>
<Hero section_title="Password" />
<PasswordFormSection />
</>
);
}
export const meta = settingsPageMeta({ label: "Password" });
@@ -0,0 +1,12 @@
import type { UserFormObject } from "@/src/types";
/**
* The `users` columns the profile page edits.
*/
export const ProfileFields: (keyof UserFormObject)[] = [
"first_name",
"last_name",
"email",
"username",
"bio",
];
@@ -4,60 +4,74 @@ import clientDeleteMediaHandler from "@/src/functions/frontend/client-delete-med
import clientUploadMediaHandler from "@/src/functions/frontend/client-upload-media-handler";
import type useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import _ from "lodash";
import { ProfileFields } from "../(data)/profile-fields";
export default async function submitAccountForm({
type Params = ReturnType<typeof useFormInit<UserFormObject>> & {
onSaved: (params: {
saved: UserFormObject;
photo_src?: string;
msg: string;
}) => void;
};
/**
* Saves the text fields, then the photo if a new one was picked. No reload:
* `onSaved` hands back what is now stored. The success message goes through
* `onSaved` too, since `useFormInit` clears `status` whenever the form
* changes.
*/
export default async function submitProfileForm({
setLoading,
setStatus,
form,
existing_full,
image,
}: ReturnType<typeof useFormInit<UserFormObject>>) {
onSaved,
}: Params) {
const user_id = existing_full?.id;
if (!user_id) {
return;
}
try {
const user_id = existing_full?.id;
if (!user_id) {
return;
}
if (!window.confirm("Update your account details?")) {
return;
}
const new_user_data: BUN_SQLITE_COBBLE_USERS = _.pick<
UserFormObject,
keyof UserFormObject
>(form, ["first_name", "last_name", "email", "username", "bio"]);
setLoading(true);
setStatus({});
const saved = Object.fromEntries(
ProfileFields.map((field) => [
field,
String(form[field] ?? "").trim(),
]),
) as BUN_SQLITE_COBBLE_USERS;
const update_user_res = await adminCrudHandler({
action: "update",
table: "users",
update_data: new_user_data,
update_data: saved,
id: user_id,
});
if (!update_user_res.success) {
throw new Error(update_user_res.msg || "Account Update Failed");
throw new Error(
update_user_res.msg || "Couldn't save your profile.",
);
}
if (image?.imageBase64Full) {
const delete_media_res = await clientDeleteMediaHandler({
user_id: user_id,
user_id,
media_paradigm: "user-profile-image",
});
if (!delete_media_res.success) {
throw new Error(
delete_media_res.msg ||
delete_media_res.error ||
`Couldn't delete old media`,
delete_media_res.msg || "Couldn't replace your photo.",
);
}
const upload_image_res = await clientUploadMediaHandler({
user_id: user_id,
user_id,
media_base_64_data_url: image.imageBase64Full,
media_paradigms: ["user-profile-image"],
media_type: "image",
@@ -68,18 +82,18 @@ export default async function submitAccountForm({
if (!upload_image_res.success) {
throw new Error(
upload_image_res.msg || "Couldn't upload Image",
upload_image_res.msg || "Couldn't upload your photo.",
);
}
}
window.location.reload();
onSaved({ saved, photo_src: image?.imageBase64Full, msg: "Saved." });
} catch (error: any) {
setStatus({
error: true,
msg: error.message || "Account Form Error",
msg: error.message || "Couldn't save your profile.",
});
} finally {
setLoading(false);
}
}
}
@@ -0,0 +1,32 @@
import Img from "@moduletrace/twui/layout/Img";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
src?: string;
initials: string;
};
/**
* 64px round photo, or the initials on sunken paper when there is none.
* Same treatment as the account row in the rail, just larger.
*/
export default function ProfileAvatar({ src, initials }: Props) {
if (src) {
return (
<Img
src={src}
alt="Your photo"
className="size-16 shrink-0 rounded-full object-cover bg-sunken"
/>
);
}
return (
<Span
className="size-16 shrink-0 rounded-full bg-sunken flex items-center justify-center text-lg font-bold leading-none text-ink"
aria-hidden
>
{initials || "?"}
</Span>
);
}
@@ -0,0 +1,165 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Row from "@moduletrace/twui/layout/Row";
import _ from "lodash";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsSaveBar from "../../(partials)/settings-save-bar";
import { ProfileFields } from "../(data)/profile-fields";
import isProfileDirty from "../(utils)/is-profile-dirty";
import submitProfileForm from "../(functions)/submit-profile-form";
import ProfilePhotoField from "./profile-photo-field";
import ProfileTextField from "./profile-text-field";
import ProfileRoleRow from "./profile-role-row";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
};
export default function ProfileForm({ person }: Props) {
const [saved, setSaved] = useState(
() => _.pick(person, ProfileFields) as UserFormObject,
);
const [saved_photo_src, setSavedPhotoSrc] = useState(() =>
person.profile_media_id
? grabMediaURL({ media: { id: person.profile_media_id } })
.media_url
: undefined,
);
/**
* Inputs are uncontrolled; bumping this remounts them on Discard.
*/
const [form_key, setFormKey] = useState(0);
const [notice, setNotice] = useState<string>();
const init = useFormInit<UserFormObject>({
existing: saved,
existing_full: { id: person.id },
});
const { form, setForm, image, setImage, loading, status, setStatus } =
init;
const dirty = isProfileDirty({ form, existing: saved, image });
const initials = [saved.first_name, saved.last_name]
.filter(Boolean)
.map((part) => String(part).charAt(0).toUpperCase())
.join("");
const discard = () => {
setForm(saved);
setImage(undefined);
setStatus({});
setNotice(undefined);
setFormKey((prev) => prev + 1);
};
return (
<Form
key={form_key}
className="w-full gap-0! items-stretch"
onSubmit={() => {
if (!dirty || loading) return;
submitProfileForm({
...init,
onSaved: ({ saved, photo_src, msg }) => {
setSaved(saved);
setImage(undefined);
if (photo_src) setSavedPhotoSrc(photo_src);
setNotice(msg);
},
});
}}
>
<SettingsGroup title="Photo">
<ProfilePhotoField
saved_src={saved_photo_src}
initials={initials}
image={image}
setImage={(next) => {
setStatus({});
setImage(next);
}}
disabled={loading}
/>
</SettingsGroup>
<SettingsGroup title="Name" description="How you appear in the app.">
<Row className="w-full grid sm:grid-cols-2 gap-4!">
<ProfileTextField
form={form}
setForm={setForm}
field="first_name"
label="First name"
autoComplete="given-name"
required
/>
<ProfileTextField
form={form}
setForm={setForm}
field="last_name"
label="Last name"
autoComplete="family-name"
/>
</Row>
</SettingsGroup>
<SettingsGroup
title="Login"
description="You can log in with either of these."
>
<ProfileTextField
form={form}
setForm={setForm}
field="email"
label="Email"
type="email"
autoComplete="email"
required
/>
<ProfileTextField
form={form}
setForm={setForm}
field="username"
label="Username"
autoComplete="username"
info="3 to 32 characters: lowercase letters, numbers, dots, dashes or underscores."
required
/>
</SettingsGroup>
<SettingsGroup
title="About you"
description="A few lines about you and what you do."
>
<ProfileTextField
form={form}
setForm={setForm}
field="bio"
label="About you"
placeholder="I run a small studio and handle the books myself."
multiline
/>
</SettingsGroup>
<SettingsGroup title="Role" description="Set when your account was made.">
<ProfileRoleRow user_types={person.user_types} />
</SettingsGroup>
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={
status?.msg
? status
: !dirty && notice
? { success: true, msg: notice }
: undefined
}
onDiscard={discard}
/>
</Form>
);
}
@@ -0,0 +1,94 @@
import { useRef } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import imageInputToBase64, {
type ImageInputToBase64FunctionReturn,
} from "@moduletrace/twui/utils/form/imageInputToBase64";
import ProfileAvatar from "./profile-avatar";
type Props = {
saved_src?: string;
initials: string;
image?: ImageInputToBase64FunctionReturn;
setImage: (image?: ImageInputToBase64FunctionReturn) => void;
disabled?: boolean;
};
/**
* Photo preview with a single upload button. A picked photo previews right
* away and is saved with the rest of the form.
*/
export default function ProfilePhotoField({
saved_src,
initials,
image,
setImage,
disabled,
}: Props) {
const input_ref = useRef<HTMLInputElement>(null);
const preview_src = image?.imageBase64Full || saved_src;
return (
<Row className="w-full gap-4! flex-nowrap items-center">
<ProfileAvatar src={preview_src} initials={initials} />
<Stack className="gap-2! min-w-0">
<Row className="gap-2!">
<Button
title={preview_src ? "Change photo" : "Upload photo"}
type="button"
variant="outlined"
color="gray"
size="small"
disabled={disabled}
onClick={() => input_ref.current?.click()}
>
{preview_src ? "Change photo" : "Upload photo"}
</Button>
{image ? (
<Button
title="Keep the current photo"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={disabled}
onClick={() => {
setImage(undefined);
if (input_ref.current) {
input_ref.current.value = "";
}
}}
>
Cancel
</Button>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
PNG or JPG. A square photo looks best.
</Span>
</Stack>
{/*
* Raw on purpose: twui `Input` writes `value` on change, which
* browsers forbid on file inputs, and `ImageUpload` /
* `FileUpload` are full card widgets. This is only the hidden
* picker behind the button above.
*/}
<input
ref={input_ref}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={(e) => {
if (!e.target.files?.[0]) return;
imageInputToBase64({ imageInput: e.target }).then((res) =>
setImage(res),
);
}}
/>
</Row>
);
}
@@ -0,0 +1,28 @@
import Row from "@moduletrace/twui/layout/Row";
import Tag from "@moduletrace/twui/elements/Tag";
import formatUserType from "../(utils)/format-user-type";
type Props = {
user_types?: string;
};
export default function ProfileRoleRow({ user_types }: Props) {
const types = (user_types || "")
.split(",")
.map((type) => type.trim())
.filter(Boolean);
if (!types[0]) {
return null;
}
return (
<Row className="gap-2!">
{types.map((user_type) => (
<Tag key={user_type} color="gray" variant="outlined">
{formatUserType({ user_type })}
</Tag>
))}
</Row>
);
}
@@ -0,0 +1,60 @@
import type {
ChangeEvent,
Dispatch,
ReactNode,
SetStateAction,
} from "react";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import type { AutocompleteOptions } from "@moduletrace/twui/types";
import type { UserFormObject } from "@/src/types";
type Props = {
form: UserFormObject;
setForm: Dispatch<SetStateAction<UserFormObject>>;
field: "first_name" | "last_name" | "email" | "username" | "bio";
label: string;
type?: "text" | "email";
autoComplete?: (typeof AutocompleteOptions)[number];
placeholder?: string;
info?: ReactNode;
required?: boolean;
multiline?: boolean;
};
/**
* A labelled profile input bound to one `users` column.
*/
export default function ProfileTextField({
form,
setForm,
field,
label,
type = "text",
autoComplete,
placeholder,
info,
required,
multiline,
}: Props) {
const shared = {
id: `profile-${field}`,
label,
title: label,
placeholder: placeholder || label,
defaultValue: String(form[field] ?? ""),
info,
required,
showLabel: true,
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
},
};
if (multiline) {
return <Textarea {...shared} rows={4} />;
}
return <Input {...shared} type={type} autoComplete={autoComplete} />;
}
@@ -0,0 +1,12 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import ProfileForm from "../(partials)/profile-form";
export default function ProfileFormSection() {
const { pageProps } = useContext(AppContext);
const person = pageProps?.person;
if (!person) return null;
return <ProfileForm person={person} />;
}
@@ -0,0 +1,10 @@
import { describe, expect, test } from "bun:test";
import formatUserType from "./format-user-type";
describe("formatUserType", () => {
test("turns a user type value into a sentence-case label", () => {
expect(formatUserType({ user_type: "super_admin" })).toBe("Super admin");
expect(formatUserType({ user_type: "read_only" })).toBe("Read only");
expect(formatUserType({ user_type: "admin" })).toBe("Admin");
});
});
@@ -0,0 +1,12 @@
type Params = {
user_type: string;
};
/**
* `super_admin` -> `Super admin`.
*/
export default function formatUserType({ user_type }: Params) {
const words = user_type.trim().replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import isProfileDirty from "./is-profile-dirty";
const existing = {
first_name: "Ben",
last_name: "",
email: "[email protected]",
username: "ben",
bio: "",
};
describe("isProfileDirty", () => {
test("unchanged, or only whitespace changed, is clean", () => {
expect(isProfileDirty({ form: { ...existing }, existing })).toBe(false);
expect(
isProfileDirty({ form: { ...existing, first_name: " Ben " }, existing }),
).toBe(false);
});
test("a changed field or a new photo is dirty", () => {
expect(
isProfileDirty({ form: { ...existing, username: "benji" }, existing }),
).toBe(true);
expect(
isProfileDirty({
form: { ...existing },
existing,
image: { imageBase64Full: "data:image/png;base64,AAAA" },
}),
).toBe(true);
});
});
@@ -0,0 +1,25 @@
import type { UserFormObject } from "@/src/types";
import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/form/imageInputToBase64";
import { ProfileFields } from "../(data)/profile-fields";
type Params = {
form: UserFormObject;
existing: UserFormObject;
image?: ImageInputToBase64FunctionReturn;
};
/**
* Whether the profile form differs from what is saved: a new photo, or any
* text field changed (ignoring surrounding spaces).
*/
export default function isProfileDirty({ form, existing, image }: Params) {
if (image?.imageBase64Full) {
return true;
}
return ProfileFields.some(
(field) =>
String(form[field] ?? "").trim() !=
String(existing[field] ?? "").trim(),
);
}
@@ -0,0 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabSettingsServerProps(params);
};
@@ -0,0 +1,13 @@
import ProfileFormSection from "./(sections)/profile-form-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsProfilePage() {
return (
<SettingsShell title="Profile" description="Your details on this install.">
<ProfileFormSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Profile" });