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:
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+2
-3
@@ -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)} />;
|
||||
}
|
||||
-9
@@ -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" });
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
+44
-30
@@ -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" });
|
||||
Reference in New Issue
Block a user