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
+48 -18
View File
@@ -1,20 +1,49 @@
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import type { PagePropsType } from "../types";
import userAuth from "../functions/backend/auth/user-auth";
import grabLoginMethod from "../functions/backend/auth/grab-login-method";
import grabHasUsers from "../functions/backend/auth/grab-has-users";
import isAdminSectionPath from "../utils/is-admin-section-path";
import { AdminHomePath } from "../dict/admin-sections-dict";
import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
/**
* Routing for a local install. There are no public pages:
* - no account yet: everything goes to `/auth/signup` (super admin setup)
* - signup after setup: back to `/auth/login` (or the bots if logged in)
* - anything outside the live admin sections and `/auth`: `/admin/bots`
* - an unknown `/admin/settings/*` page: `/admin/settings/general`
* - `/admin/*` logged out: `/auth/login`
* - `/auth/*` logged in (except logout): `/admin/bots`
*/
const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
const { user, user_types } = await userAuth({ req });
const is_admin_page =
url.pathname.startsWith("/admin") || url.pathname.startsWith("/auth");
const is_signup_page = url.pathname.startsWith("/auth/signup");
const has_users = await grabHasUsers();
if (!has_users && !is_signup_page) {
return redirect({ destination: "/auth/signup" });
}
if (has_users && is_signup_page) {
return redirect({
destination: user?.logged_in_status ? AdminHomePath : "/auth/login",
});
}
if (url.pathname.startsWith("/admin") && !user?.logged_in_status) {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
return redirect({ destination: "/auth/login" });
}
if (
!isAdminSectionPath({ pathname: url.pathname }) &&
!url.pathname.startsWith("/auth")
) {
return redirect({ destination: AdminHomePath });
}
if (isUnknownSettingsPath({ pathname: url.pathname })) {
return redirect({ destination: SettingsDefaultPath });
}
if (
@@ -22,19 +51,13 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
!url.pathname.match(/logout/) &&
user?.logged_in_status
) {
return {
redirect: {
destination: "/admin",
permanent: false,
},
};
return redirect({ destination: AdminHomePath });
}
return {
props: {
environment: process.env.NODE_ENV,
envs: {
AUTH_LOGIN_METHOD: grabLoginMethod(),
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || "",
R2_PUBLIC_DOMAIN: process.env.R2_PUBLIC_DOMAIN || "",
PAYSTACK_PUBLIC_KEY: process.env.PAYSTACK_PUBLIC_KEY || "",
@@ -42,9 +65,16 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
user,
user_types,
},
cache_page: is_admin_page ? false : true,
cache_expiry: 360,
};
};
function redirect({ destination }: { destination: string }) {
return {
redirect: {
destination,
permanent: false,
},
};
}
export default server;
+1 -10
View File
@@ -10,7 +10,6 @@ import type {
import type { AppContextObject, AppStateObject } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import Layout from "../layouts/main";
import AdminLayout from "../layouts/admin";
import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots";
import ThemeInitScript from "../components/general/theme-init-script";
@@ -47,18 +46,10 @@ export default function App({
query,
};
if (url?.pathname.startsWith("/admin/bots")) {
return (
<AppContext.Provider value={appContextObject}>
<BotsLayout>{children}</BotsLayout>
</AppContext.Provider>
);
}
if (url?.pathname.startsWith("/admin")) {
return (
<AppContext.Provider value={appContextObject}>
<AdminLayout>{children}</AdminLayout>
<BotsLayout>{children}</BotsLayout>
</AppContext.Provider>
);
}
@@ -1,23 +0,0 @@
import { Bot, Users, type LucideIcon } from "lucide-react";
export type DashboardShortcut = {
title: string;
description: string;
url: string;
Icon: LucideIcon;
};
export const DashboardShortcuts: DashboardShortcut[] = [
{
title: "Bots",
description: "Talk to your crew, see what they are working on and answer what needs you.",
url: "/admin/bots",
Icon: Bot,
},
{
title: "People",
description: "Manage admin users, roles and access.",
url: "/admin/people",
Icon: Users,
},
];
@@ -1,37 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import H3 from "@moduletrace/twui/layout/H3";
import { ArrowUpRight } from "lucide-react";
import type { DashboardShortcut } from "../(data)/dashboard-shortcuts";
type Props = DashboardShortcut;
export default function DashboardShortcutCard({
title,
description,
url,
Icon,
}: Props) {
return (
<a href={url} className="group block h-full">
<Stack className="h-full w-full gap-10! rounded-default-lg border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-5 transition-all hover:border-slate-300 dark:hover:border-white/20 hover:shadow-md hover:shadow-slate-900/5 dark:hover:shadow-none">
<Row className="w-full justify-between">
<Row className="size-9 justify-center rounded-default bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-slate-300">
<Icon size={17} strokeWidth={1.75} />
</Row>
<ArrowUpRight
size={16}
className="text-slate-300 dark:text-slate-600 transition-colors group-hover:text-accent"
/>
</Row>
<Stack className="gap-1.5!">
<H3>{title}</H3>
<Span className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{description}
</Span>
</Stack>
</Stack>
</a>
);
}
@@ -1,16 +0,0 @@
import { useContext } from "react";
import AdminHero from "@/src/components/general/admin-hero";
import { AppContext } from "@/src/pages/__root";
import type { User } from "@/src/types";
export default function DashboardHero() {
const { pageProps } = useContext(AppContext);
const user = pageProps?.user as User | undefined;
return (
<AdminHero
title={user?.first_name ? `Welcome, ${user.first_name}` : "Dashboard"}
description="An overview of your workspace. Pick up where you left off."
/>
);
}
@@ -1,23 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { DashboardShortcuts } from "../(data)/dashboard-shortcuts";
import DashboardShortcutCard from "../(partials)/dashboard-shortcut-card";
export default function DashboardShortcutsSection() {
return (
<Section>
<Stack className="w-full items-stretch gap-5!">
<Span className="htag">Jump back in</Span>
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{DashboardShortcuts.map((shortcut) => (
<DashboardShortcutCard
key={shortcut.url}
{...shortcut}
/>
))}
</div>
</Stack>
</Section>
);
}
@@ -1,18 +0,0 @@
import Center from "@moduletrace/twui/layout/Center";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
export default function EmptyCrew() {
return (
<Center className="grow h-full">
<Stack className="items-center gap-3!">
<BotStone face="asleep" size={64} />
<Span className="text-sm font-bold text-ink">No bots yet</Span>
<Span className="text-sm text-ink-secondary">
Add a bot and give it a job.
</Span>
</Stack>
</Center>
);
}
+6 -15
View File
@@ -1,11 +1,11 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the coordinator's conversation. With no crew yet, renders the empty
* shell.
* Opens the coordinator's conversation, creating the coordinator first if
* this workspace doesn't have one yet.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
@@ -18,20 +18,11 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
};
}
const bots = await grabBots({ user_id: user.id });
const first_bot = bots[0];
if (first_bot?.id) {
return {
redirect: {
destination: `/admin/bots/${first_bot.id}`,
},
};
}
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
return {
props: {
bots,
redirect: {
destination: `/admin/bots/${coordinator_id}`,
},
};
};
+4 -2
View File
@@ -1,9 +1,11 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import EmptyCrew from "./(sections)/empty-crew";
/**
* Never rendered: `index.server.ts` always redirects to the coordinator.
*/
export default function BotsPage() {
return <EmptyCrew />;
return null;
}
export const meta: BunextPageModuleMeta = {
+11
View File
@@ -0,0 +1,11 @@
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
export const server: BunextPageServerFn = async () => {
return {
redirect: {
destination: AdminHomePath,
permanent: false,
},
};
};
+7 -11
View File
@@ -1,18 +1,14 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import DashboardHero from "./(sections)/dashboard-hero";
import DashboardShortcutsSection from "./(sections)/dashboard-shortcuts-section";
export default function AdminDashboardPage() {
return (
<>
<DashboardHero />
<DashboardShortcutsSection />
</>
);
/**
* Never rendered: `index.server.ts` always redirects to `AdminHomePath`.
*/
export default function AdminPage() {
return null;
}
export const meta: BunextPageModuleMeta = {
title: `Admin Dashboard | ${SiteData["SiteName"]}`,
description: `Admin dashboard for managing the ${SiteData["SiteName"]} website.`,
title: `${SiteData["SiteName"]}`,
description: "Redirecting to your bots.",
};
@@ -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" });
@@ -2,7 +2,6 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-password-hash";
import verifyUserPassword from "@/src/functions/backend/auth/verify-user-password";
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import type { ApiReqParams } from "@/src/types";
@@ -14,8 +13,7 @@ import type {
} from "@moduletrace/bunext/types";
/**
* Sets or changes the logged-in user's own password (password login mode
* only). Acts only on the session user; changing an existing password
* Sets or changes the logged-in user's own password. Acts only on the session user; changing an existing password
* needs the current one.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
@@ -37,10 +35,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
};
}
if (grabLoginMethod() != "password") {
throw new Error(`Password login is turned off.`);
}
const user_id = user.id;
const { password_update } = body as ApiReqParams;
-100
View File
@@ -1,100 +0,0 @@
import issueSSOLoginCode from "@/src/functions/backend/auth/issue-sso-login-code";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import rateLimit from "@/src/utils/rate-limit";
import findUserByIdentifier from "@/src/functions/backend/auth/find-user-by-identifier";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import { LoginIdentifierRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import AppError from "@/src/utils/app-error";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (grabLoginMethod() != "code") {
throw new AppError(`Please log in with your password.`);
}
const { login } = parseWithSchema({
schema: LoginIdentifierRequestSchema,
data: body,
});
const window_ms = AppData["RateLimitWindowMinutes"] * 60 * 1000;
const ip_limit = rateLimit({
key: `sso-code-request:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["SSOCodeRequestLimit"] * 4,
window_ms,
});
const identifier_limit = rateLimit({
key: `sso-code-request:id:${String(login.email_or_username).toLowerCase()}`,
limit: AppData["SSOCodeRequestLimit"],
window_ms,
});
if (!ip_limit.allowed || !identifier_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
const target_user = await findUserByIdentifier({
identifier: login.email_or_username,
});
/**
* Same response whether or not the account exists, so this
* endpoint can't be used to enumerate users.
*/
if (!target_user?.id || !target_user.email) {
await logEvent({
title: "SSO login code requested for unknown user",
category: "auth",
plain_text: `SSO login code requested for unknown identifier.`,
ip_address: grabRequestIP(req),
});
return {
success: true,
};
}
await logEvent({
title: "SSO login code requested",
category: "auth",
user_id: target_user.id,
plain_text: `User #${target_user.id} [${target_user.first_name} ${target_user.last_name}] requested an SSO login code.`,
metadata: {
user_id: target_user.id,
},
ip_address: grabRequestIP(req),
});
return issueSSOLoginCode({
user_id: target_user.id,
email: target_user.email,
method: "code",
});
} catch (error: unknown) {
await logEvent({
title: "SSO login code request failed",
category: "auth",
plain_text: `SSO login code request failed: ${
error instanceof Error ? error.message : String(error)
}`,
ip_address: grabRequestIP(req),
});
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
+7 -37
View File
@@ -1,8 +1,6 @@
import loginUser from "@/src/functions/backend/auth/login-user";
import verifyGoogleIdToken from "@/src/functions/backend/auth/verify-google-id-token";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import createSignupUser from "@/src/functions/backend/auth/create-signup-user";
import generateUniqueUsername from "@/src/functions/backend/auth/generate-unique-username";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { GoogleLoginRequestSchema } from "@/src/schemas/auth-schema";
@@ -14,12 +12,16 @@ import type {
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Logs an existing user in with Google. Never creates accounts: people are
* added by an admin, and the super admin is created at first-run signup.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
const { google_token, google_signup } = parseWithSchema({
const { google_token } = parseWithSchema({
schema: GoogleLoginRequestSchema,
data: body,
});
@@ -42,43 +44,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
exact_match: true,
});
let target_user = target_user_res.singleRes;
if (!target_user?.id && google_signup) {
const new_user_id = await createSignupUser({
first_name:
googleUser.given_name ||
googleUser.name?.split(" ")[0] ||
googleUser.email.split("@")[0] ||
"",
last_name: googleUser.family_name,
email: googleUser.email.toLowerCase(),
username: await generateUniqueUsername({
email: googleUser.email,
}),
});
await logEvent({
title: "Google signup",
category: "auth",
user_id: new_user_id,
plain_text: `User #${new_user_id} signed up with Google.`,
metadata: {
user_id: new_user_id,
},
ip_address: grabRequestIP(req),
});
target_user = (
await grabUsers({
query: { user_id: new_user_id },
})
).singleRes;
}
const target_user = target_user_res.singleRes;
if (!target_user?.id) {
throw new AppError(
`No account exists for ${googleUser.email}. Please sign up first.`,
`No account exists for ${googleUser.email}. Ask your admin to add you.`,
);
}
-52
View File
@@ -1,52 +0,0 @@
import loginUser from "@/src/functions/backend/auth/login-user";
import verifySSOCode from "@/src/functions/backend/auth/verify-sso-code";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { LoginCodeRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
const { sso_code } = parseWithSchema({
schema: LoginCodeRequestSchema,
data: body,
});
/**
* Only codes issued by the site-wide login method complete a login.
* In password mode that means the password step has passed.
*/
const user_id = await verifySSOCode({
req,
sso_code,
method: grabLoginMethod(),
});
return await loginUser({
user_id,
req,
});
} catch (error: unknown) {
await logEvent({
title: "SSO login failed",
category: "auth",
plain_text: `SSO login failed: ${
error instanceof Error ? error.message : String(error)
}`,
ip_address: grabRequestIP(req),
});
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
+5 -23
View File
@@ -1,7 +1,6 @@
import issueSSOLoginCode from "@/src/functions/backend/auth/issue-sso-login-code";
import findUserByIdentifier from "@/src/functions/backend/auth/find-user-by-identifier";
import verifyUserPassword from "@/src/functions/backend/auth/verify-user-password";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import loginUser from "@/src/functions/backend/auth/login-user";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import rateLimit from "@/src/utils/rate-limit";
@@ -15,18 +14,13 @@ import type {
} from "@moduletrace/bunext/types";
/**
* First factor of password login. A correct password issues an emailed
* login code (second factor), completed through `api/auth/login`.
* Logs a user in with their email or username and password.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (grabLoginMethod() != "password") {
throw new AppError(`Password login is turned off.`);
}
const { login } = parseWithSchema({
schema: PasswordLoginRequestSchema,
data: body,
@@ -60,7 +54,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
password,
});
if (!is_valid || !target_user?.id || !target_user.email) {
if (!is_valid || !target_user?.id) {
await logEvent({
title: "Password login failed",
category: "auth",
@@ -77,21 +71,9 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
};
}
await logEvent({
title: "Password verified",
category: "auth",
return await loginUser({
user_id: target_user.id,
plain_text: `User #${target_user.id} [${target_user.first_name} ${target_user.last_name}] entered a correct password. Login code sent.`,
metadata: {
user_id: target_user.id,
},
ip_address: grabRequestIP(req),
});
return issueSSOLoginCode({
user_id: target_user.id,
email: target_user.email,
method: "password",
req,
});
} catch (error: unknown) {
return {
@@ -1,95 +0,0 @@
import issueSSOLoginCode from "@/src/functions/backend/auth/issue-sso-login-code";
import findUserByIdentifier from "@/src/functions/backend/auth/find-user-by-identifier";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import rateLimit from "@/src/utils/rate-limit";
import { LoginIdentifierRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import AppError from "@/src/utils/app-error";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Emails a password reset code (password login mode only). Also how
* accounts without a password (e.g. created before password mode, or via
* Google) set their first one.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (grabLoginMethod() != "password") {
throw new AppError(`Password login is turned off.`);
}
const { login } = parseWithSchema({
schema: LoginIdentifierRequestSchema,
data: body,
});
const identifier = login.email_or_username;
const window_ms = AppData["RateLimitWindowMinutes"] * 60 * 1000;
const ip_limit = rateLimit({
key: `password-reset:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["SSOCodeRequestLimit"] * 4,
window_ms,
});
const identifier_limit = rateLimit({
key: `password-reset:id:${identifier.toLowerCase()}`,
limit: AppData["SSOCodeRequestLimit"],
window_ms,
});
if (!ip_limit.allowed || !identifier_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
const target_user = await findUserByIdentifier({ identifier });
/**
* Same response whether or not the account exists.
*/
if (!target_user?.id || !target_user.email) {
await logEvent({
title: "Password reset requested for unknown user",
category: "auth",
plain_text: `Password reset requested for unknown identifier.`,
ip_address: grabRequestIP(req),
});
return {
success: true,
};
}
await logEvent({
title: "Password reset requested",
category: "auth",
user_id: target_user.id,
plain_text: `User #${target_user.id} requested a password reset code.`,
metadata: {
user_id: target_user.id,
},
ip_address: grabRequestIP(req),
});
return issueSSOLoginCode({
user_id: target_user.id,
email: target_user.email,
method: "reset",
});
} catch (error: unknown) {
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
-107
View File
@@ -1,107 +0,0 @@
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
import issueSSOLoginCode from "@/src/functions/backend/auth/issue-sso-login-code";
import { grabRequestIP } from "@/src/functions/backend/logger";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import { AppData } from "@/src/data/app-data";
import type { SSOAuth } from "@/src/types";
import { getCookie } from "@/src/utils/cookies";
import { decrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import rateLimit from "@/src/utils/rate-limit";
import BunSQLite from "@moduletrace/bun-sqlite";
import safeErrorMessage from "@/src/utils/safe-error-message";
import AppError from "@/src/utils/app-error";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Re-sends the login code for the pending login held in the SSO cookie.
* The cookie is only issued once the first step has passed, and only
* codes for the current login method (or a password reset) are resent.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
}) => {
try {
const window_ms = AppData["RateLimitWindowMinutes"] * 60 * 1000;
const ip_limit = rateLimit({
key: `sso-resend:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["SSOCodeRequestLimit"] * 4,
window_ms,
});
if (!ip_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
const sso_cookie = getCookie(req, AppData["SSOAuthCookieName"]);
/**
* No pending login (e.g. the identifier didn't match an account).
* Answer like a real resend so this can't be used to probe.
*/
if (!sso_cookie) {
return {
success: true,
};
}
const sso_auth = EJSON.parse(await decrypt(sso_cookie)) as SSOAuth;
const max_age_ms = AppData["SSOPendingLoginMaxMinutes"] * 60 * 1000;
const login_method = grabLoginMethod();
const is_current_method =
sso_auth?.method == login_method ||
(login_method == "password" && sso_auth?.method == "reset");
if (
!sso_auth?.user_id ||
!sso_auth.method ||
!is_current_method ||
!sso_auth.first_step_at ||
Date.now() - sso_auth.first_step_at > max_age_ms
) {
throw new AppError(`Your login session expired. Please start again.`);
}
const user_limit = rateLimit({
key: `sso-resend:user:${sso_auth.user_id}`,
limit: AppData["SSOCodeRequestLimit"],
window_ms,
});
if (!user_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
const target_user = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
targetId: sso_auth.user_id,
})
).singleRes;
if (!target_user?.id || !target_user.email) {
throw new AppError(`Your login session expired. Please start again.`);
}
return issueSSOLoginCode({
user_id: target_user.id,
email: target_user.email,
method: sso_auth.method,
first_step_at: sso_auth.first_step_at,
});
} catch (error: unknown) {
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
-79
View File
@@ -1,79 +0,0 @@
import loginUser from "@/src/functions/backend/auth/login-user";
import verifySSOCode from "@/src/functions/backend/auth/verify-sso-code";
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import validatePassword from "@/src/utils/validate-password";
import { ResetPasswordRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import AppError from "@/src/utils/app-error";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Completes a password reset: checks the emailed reset code, saves the new
* password, then logs the user in. The emailed code is the proof of
* identity here, as with any email-based reset.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (grabLoginMethod() != "password") {
throw new AppError(`Password login is turned off.`);
}
const { sso_code, new_password } = parseWithSchema({
schema: ResetPasswordRequestSchema,
data: body,
});
const password_error = validatePassword({ password: new_password });
if (password_error) {
throw new AppError(password_error);
}
const user_id = await verifySSOCode({
req,
sso_code,
method: "reset",
});
await setUserPassword({ user_id, password: new_password });
await logEvent({
title: "Password reset",
category: "auth",
user_id,
plain_text: `User #${user_id} reset their password.`,
metadata: {
user_id,
},
ip_address: grabRequestIP(req),
});
return await loginUser({
user_id,
req,
});
} catch (error: unknown) {
await logEvent({
title: "Password reset failed",
category: "auth",
plain_text: `Password reset failed: ${
error instanceof Error ? error.message : String(error)
}`,
ip_address: grabRequestIP(req),
});
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
+57 -99
View File
@@ -1,13 +1,12 @@
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
import issueSSOLoginCode from "@/src/functions/backend/auth/issue-sso-login-code";
import createSignupUser from "@/src/functions/backend/auth/create-signup-user";
import createSuperAdminUser from "@/src/functions/backend/auth/create-super-admin-user";
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import grabHasUsers from "@/src/functions/backend/auth/grab-has-users";
import loginUser from "@/src/functions/backend/auth/login-user";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import validatePassword from "@/src/utils/validate-password";
import { grabRequestIP, logError } from "@/src/functions/backend/logger";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import rateLimit from "@/src/utils/rate-limit";
import BunSQLite from "@moduletrace/bun-sqlite";
import { SignupRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -17,11 +16,30 @@ import type {
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* First-run setup: creates the super admin and their coordinator bot, then
* logs them in. Refused once
* any account exists, so signup can only ever happen once per install.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
const ip_limit = rateLimit({
key: `signup:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["PasswordAttemptLimit"],
window_ms: AppData["RateLimitWindowMinutes"] * 60 * 1000,
});
if (!ip_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
if (await grabHasUsers()) {
throw new AppError(`This install has already been set up.`);
}
const { signup } = parseWithSchema({
schema: SignupRequestSchema,
data: body,
@@ -30,117 +48,57 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
const email = signup.email.trim().toLowerCase();
const username = signup.username.trim().toLowerCase();
const window_ms = AppData["RateLimitWindowMinutes"] * 60 * 1000;
const ip_limit = rateLimit({
key: `signup:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["SSOCodeRequestLimit"] * 4,
window_ms,
});
const email_limit = rateLimit({
key: `signup:email:${email}`,
limit: AppData["SSOCodeRequestLimit"],
window_ms,
});
if (!ip_limit.allowed || !email_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
if (!AppData["UsernameRegex"].test(username)) {
throw new AppError(
`Usernames must be 3-32 characters: lowercase letters, numbers, dots, dashes or underscores`,
);
}
const login_method = grabLoginMethod();
const password_error =
login_method == "password"
? validatePassword({ password: signup.password })
: undefined;
const password_error = validatePassword({ password: signup.password });
if (password_error) {
throw new AppError(password_error);
}
const existing_email_user = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
email: {
value: email,
},
},
},
})
)?.singleRes;
/**
* Respond exactly as a fresh signup would, so this endpoint can't
* be used to discover which emails have accounts. In code mode the
* owner gets a login code. In password mode nothing is sent, since
* a code alone mustn't skip their password.
*/
if (existing_email_user?.id) {
if (login_method == "password") {
return {
success: true,
};
}
return issueSSOLoginCode({
user_id: existing_email_user.id,
email,
method: "code",
});
}
const existing_username_user = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
username: {
value: username,
},
},
},
})
)?.singleRes;
if (existing_username_user?.id) {
throw new AppError(
`That username is taken. Please choose another.`,
);
}
const new_user_id = await createSignupUser({
const new_user_id = await createSuperAdminUser({
first_name: signup.first_name.trim(),
last_name: signup.last_name?.trim(),
email,
username,
});
if (login_method == "password" && signup.password) {
await setUserPassword({
user_id: new_user_id,
password: signup.password,
});
}
return issueSSOLoginCode({
await setUserPassword({
user_id: new_user_id,
email,
method: login_method,
password: signup.password,
});
/**
* The account exists now, so a failure here mustn't fail signup
* (which is one-shot). `/admin/bots` creates it on the next visit.
*/
await ensureCoordinatorBot({ user_id: new_user_id }).catch((error) =>
logError({
title: "Coordinator bot not created at signup",
error,
user_id: new_user_id,
ip_address: grabRequestIP(req),
}).catch(() => {}),
);
await logEvent({
title: "Super admin created",
category: "auth",
user_id: new_user_id,
plain_text: `User #${new_user_id} created the super admin account.`,
metadata: {
user_id: new_user_id,
},
ip_address: grabRequestIP(req),
});
return await loginUser({
user_id: new_user_id,
req,
});
} catch (error: unknown) {
await logError({
@@ -1,41 +1,33 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams, LoginFormObject, LoginMethod } from "@/src/types";
import type { ApiReqParams, LoginFormObject } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import saveSSOPendingLogin from "@/src/functions/frontend/save-sso-pending-login";
type Params = ReturnType<typeof useFormInit<LoginFormObject>> & {
method: LoginMethod;
};
type Params = ReturnType<typeof useFormInit<LoginFormObject>>;
export default async function submitLoginForm({
setLoading,
setStatus,
form,
method,
}: Params) {
setLoading(true);
const identifier = form.username_or_email?.trim() || "";
const res = await fetchApi<ApiReqParams, APIResponseObject>(
method == "password"
? `/api/auth/password-login`
: `/api/auth/get-login-code`,
`/api/auth/password-login`,
{
method: "POST",
body: {
login: {
email_or_username: identifier,
password: method == "password" ? form.password : undefined,
email_or_username: form.username_or_email?.trim() || "",
password: form.password,
},
},
},
);
if (res.success) {
saveSSOPendingLogin({ identifier, method });
window.location.pathname = `/auth/sso`;
window.location.pathname = AdminHomePath;
} else {
setStatus({
error: true,
@@ -1,10 +1,8 @@
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import useFormInit from "@/src/hooks/use-form-init";
import useLoginMethod from "@/src/hooks/use-login-method";
import type { LoginFormObject } from "@/src/types";
import LoginShell from "@/src/components/general/login-shell";
import AuthFooterLink from "@/src/components/general/auth-footer-link";
import GoogleAuthSection from "@/src/components/general/google-auth-section";
import LoginFormEmailUsername from "./login-form-email-username";
import LoginFormPassword from "./login-form-password";
@@ -14,36 +12,21 @@ import submitLoginForm from "../../(functions)/submit-login-form";
export default function LoginForm() {
const init = useFormInit<LoginFormObject>();
const { loading, status } = init;
const method = useLoginMethod();
return (
<LoginShell status={status}>
<GoogleAuthSection {...init} mode="login" />
<GoogleAuthSection {...init} />
<Form
className="w-full flex items-center justify-center relative gap-4"
onSubmit={() => {
submitLoginForm({ ...init, method });
submitLoginForm(init);
}}
>
{loading && <LoadingOverlay />}
<LoginFormEmailUsername {...init} />
{method == "password" ? <LoginFormPassword {...init} /> : null}
<LoginFormAction method={method} />
<LoginFormPassword {...init} />
<LoginFormAction />
</Form>
{method == "password" ? (
<AuthFooterLink
prompt="Forgot your password?"
title="Reset it"
url="/auth/reset-password"
className="w-full text-sm"
/>
) : null}
<AuthFooterLink
prompt="Already have a code?"
title="Enter it"
url="/auth/sso"
className="w-full text-sm"
/>
</LoginShell>
);
}
@@ -1,16 +1,9 @@
import Button from "@moduletrace/twui/layout/Button";
import type { LoginMethod } from "@/src/types";
type Props = {
method: LoginMethod;
};
export default function LoginFormAction({ method }: Props) {
const label = method == "password" ? "Log in" : "Email me a login code";
export default function LoginFormAction() {
return (
<Button title={label} type="submit" className="w-full">
{label}
<Button title="Log in" type="submit" className="w-full">
Log in
</Button>
);
}
@@ -2,13 +2,9 @@ import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import AuthHero from "@/src/components/general/auth-hero";
import AuthFooterLink from "@/src/components/general/auth-footer-link";
import LoginForm from "../(partials)/login-form";
import useLoginMethod from "@/src/hooks/use-login-method";
export default function LoginFormSection() {
const method = useLoginMethod();
return (
<Section>
<Container>
@@ -16,18 +12,9 @@ export default function LoginFormSection() {
<AuthHero
eyebrow="Welcome back"
title="Log in to your account"
description={
method == "password"
? "Log in with your password, then confirm with a code we email you."
: "Enter your email or username and we'll email you a one-time login code."
}
description="Log in with your email or username and password."
/>
<LoginForm />
<AuthFooterLink
prompt="Don't have an account?"
title="Sign up"
url="/auth/signup"
/>
</Stack>
</Container>
</Section>
-4
View File
@@ -32,10 +32,6 @@ export const server: BunextPageServerFn = async ({ req }) => {
name: AppData["AuthCSRFCookieName"],
httpOnly: true,
},
{
name: AppData["SSOAuthCookieName"],
httpOnly: true,
},
]);
return new_res;
},
+2 -2
View File
@@ -14,7 +14,7 @@ import { SiteData } from "@/src/data/site-data";
export default function LogoutPage() {
useEffect(() => {
setTimeout(() => {
window.location.pathname = "/";
window.location.pathname = "/auth/login";
}, 1000);
}, []);
@@ -43,5 +43,5 @@ export default function LogoutPage() {
export const meta: BunextPageModuleMeta = {
title: `Logging Out | ${SiteData["SiteName"]}`,
description: "You are being logged out and redirected to the homepage.",
description: "You are being logged out and redirected to the login page.",
};
@@ -1,38 +0,0 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams, LoginFormObject } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import saveSSOPendingLogin from "@/src/functions/frontend/save-sso-pending-login";
export default async function submitRequestReset({
setLoading,
setStatus,
form,
}: ReturnType<typeof useFormInit<LoginFormObject>>) {
setLoading(true);
const identifier = form.username_or_email?.trim() || "";
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/auth/request-password-reset`,
{
method: "POST",
body: {
login: {
email_or_username: identifier,
},
},
},
);
if (res.success) {
saveSSOPendingLogin({ identifier, method: "reset" });
window.location.pathname = `/auth/reset-password/confirm`;
} else {
setStatus({
error: true,
msg: res.msg || "Couldn't send a reset code. Please try again.",
});
setLoading(false);
}
}
@@ -1,30 +0,0 @@
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import Button from "@moduletrace/twui/layout/Button";
import useFormInit from "@/src/hooks/use-form-init";
import type { LoginFormObject } from "@/src/types";
import LoginShell from "@/src/components/general/login-shell";
import LoginFormEmailUsername from "@/src/pages/auth/login/(partials)/login-form/login-form-email-username";
import submitRequestReset from "../../(functions)/submit-request-reset";
export default function RequestResetForm() {
const init = useFormInit<LoginFormObject>();
const { loading, status } = init;
return (
<LoginShell status={status}>
<Form
className="w-full flex items-center justify-center relative gap-4"
onSubmit={() => {
submitRequestReset(init);
}}
>
{loading && <LoadingOverlay />}
<LoginFormEmailUsername {...init} />
<Button title="Email me a reset code" type="submit" className="w-full">
Email me a reset code
</Button>
</Form>
</LoginShell>
);
}
@@ -1,28 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import AuthHero from "@/src/components/general/auth-hero";
import AuthFooterLink from "@/src/components/general/auth-footer-link";
import RequestResetForm from "../(partials)/request-reset-form";
export default function RequestResetSection() {
return (
<Section>
<Container>
<Stack className="w-full items-center max-w-md mx-auto gap-8">
<AuthHero
eyebrow="Forgot password"
title="Reset your password"
description="Enter your email or username and we'll email you a code to set a new password."
/>
<RequestResetForm />
<AuthFooterLink
prompt="Remembered it?"
title="Log in"
url="/auth/login"
/>
</Stack>
</Container>
</Section>
);
}
@@ -1,46 +0,0 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams, PasswordResetFormObject } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import validatePassword from "@/src/utils/validate-password";
export default async function submitConfirmReset({
setLoading,
setStatus,
form,
}: ReturnType<typeof useFormInit<PasswordResetFormObject>>) {
const password_error = validatePassword({ password: form.new_password });
if (password_error) {
setStatus({ error: true, msg: password_error });
return;
}
if (form.new_password != form.confirm_password) {
setStatus({ error: true, msg: "The passwords don't match." });
return;
}
setLoading(true);
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/auth/reset-password`,
{
method: "POST",
body: {
sso_code: form.code?.trim(),
new_password: form.new_password,
},
},
);
if (res.success) {
window.location.pathname = `/admin`;
} else {
setStatus({
error: true,
msg: res.msg || "Couldn't reset your password. Please try again.",
});
setLoading(false);
}
}
@@ -1,57 +0,0 @@
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import Button from "@moduletrace/twui/layout/Button";
import useFormInit from "@/src/hooks/use-form-init";
import type { PasswordResetFormObject } from "@/src/types";
import { AppData } from "@/src/data/app-data";
import LoginShell from "@/src/components/general/login-shell";
import LoginCodeInput from "@/src/components/general/login-code-input";
import PasswordInput from "@/src/components/general/password-input";
import ResendCodeButton from "@/src/components/general/resend-code-button";
import submitConfirmReset from "../../(functions)/submit-confirm-reset";
export default function ConfirmResetForm() {
const init = useFormInit<PasswordResetFormObject>();
const { loading, status, setForm } = init;
return (
<LoginShell status={status}>
<Form
className="w-full flex items-center justify-center relative gap-4"
onSubmit={() => {
submitConfirmReset(init);
}}
>
{loading && <LoadingOverlay />}
<LoginCodeInput
onCodeChange={(code) => {
setForm((prev) => ({ ...prev, code }));
}}
/>
<PasswordInput
id="new_password"
label="New password"
autoComplete="new-password"
info={`At least ${AppData["PasswordMinLength"]} characters.`}
onValueChange={(new_password) => {
setForm((prev) => ({ ...prev, new_password }));
}}
required
/>
<PasswordInput
id="confirm_password"
label="Confirm new password"
autoComplete="new-password"
onValueChange={(confirm_password) => {
setForm((prev) => ({ ...prev, confirm_password }));
}}
required
/>
<Button title="Save password and log in" type="submit" className="w-full">
Save password and log in
</Button>
</Form>
<ResendCodeButton {...init} />
</LoginShell>
);
}
@@ -1,38 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import AuthHero from "@/src/components/general/auth-hero";
import AuthFooterLink from "@/src/components/general/auth-footer-link";
import useSSOPendingLogin from "@/src/hooks/use-sso-pending-login";
import { AppData } from "@/src/data/app-data";
import ConfirmResetForm from "../(partials)/confirm-reset-form";
export default function ConfirmResetSection() {
const pending = useSSOPendingLogin();
const expiry = `It expires in ${AppData["SSOCodeExpiryMinutes"]} minutes.`;
return (
<Section>
<Container>
<Stack className="w-full items-center max-w-md mx-auto gap-8">
<AuthHero
eyebrow="Check your email"
title="Set a new password"
description={
pending?.identifier
? `If ${pending.identifier} matches an account, we've emailed it a 6-digit reset code. ${expiry}`
: `Enter the 6-digit reset code we emailed you. ${expiry}`
}
/>
<ConfirmResetForm />
<AuthFooterLink
prompt="Wrong account?"
title="Start over"
url="/auth/reset-password"
/>
</Stack>
</Container>
</Section>
);
}
@@ -1,18 +0,0 @@
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
/**
* Password resets only exist in password login mode.
*/
export const server: BunextPageServerFn = async () => {
if (grabLoginMethod() != "password") {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
return {};
};
@@ -1,12 +0,0 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import ConfirmResetSection from "./(sections)/confirm-reset-section";
import { SiteData } from "@/src/data/site-data";
export default function ConfirmResetPage() {
return <ConfirmResetSection />;
}
export const meta: BunextPageModuleMeta = {
title: `Set New Password | ${SiteData["SiteName"]}`,
description: `Set a new password for your ${SiteData["SiteName"]} account.`,
};
@@ -1,18 +0,0 @@
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
/**
* Password resets only exist in password login mode.
*/
export const server: BunextPageServerFn = async () => {
if (grabLoginMethod() != "password") {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
return {};
};
-12
View File
@@ -1,12 +0,0 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import RequestResetSection from "./(sections)/request-reset-section";
import { SiteData } from "@/src/data/site-data";
export default function ResetPasswordPage() {
return <RequestResetSection />;
}
export const meta: BunextPageModuleMeta = {
title: `Reset Password | ${SiteData["SiteName"]}`,
description: `Reset your ${SiteData["SiteName"]} password.`,
};
@@ -1,31 +1,25 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams, LoginMethod, SignupFormObject } from "@/src/types";
import type { ApiReqParams, SignupFormObject } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import saveSSOPendingLogin from "@/src/functions/frontend/save-sso-pending-login";
import validatePassword from "@/src/utils/validate-password";
type Params = ReturnType<typeof useFormInit<SignupFormObject>> & {
method: LoginMethod;
};
type Params = ReturnType<typeof useFormInit<SignupFormObject>>;
export default async function submitSignupForm({
setLoading,
setStatus,
form,
method,
}: Params) {
const password_error =
method == "password"
? validatePassword({ password: form.password })
: undefined;
const password_error = validatePassword({ password: form.password });
if (password_error) {
setStatus({ error: true, msg: password_error });
return;
}
if (method == "password" && form.password != form.confirm_password) {
if (form.password != form.confirm_password) {
setStatus({ error: true, msg: "The passwords don't match." });
return;
}
@@ -42,18 +36,14 @@ export default async function submitSignupForm({
last_name: form.last_name,
username: form.username,
email: form.email,
password: method == "password" ? form.password : undefined,
password: form.password,
},
},
},
);
if (res.success) {
saveSSOPendingLogin({
identifier: form.email?.trim() || "",
method,
});
window.location.pathname = `/auth/sso`;
window.location.pathname = AdminHomePath;
} else {
setStatus({
error: true,
@@ -1,7 +1,6 @@
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import useFormInit from "@/src/hooks/use-form-init";
import useLoginMethod from "@/src/hooks/use-login-method";
import { type SignupFormObject } from "@/src/types";
import LoginShell from "@/src/components/general/login-shell";
import SignupFormName from "./signup-form-name";
@@ -9,34 +8,27 @@ import SignupFormUsername from "./signup-form-username";
import SignupFormEmail from "./signup-form-email";
import SignupFormPassword from "./signup-form-password";
import SignupFormConfirmPassword from "./signup-form-confirm-password";
import GoogleAuthSection from "@/src/components/general/google-auth-section";
import SignupFormAction from "./signup-form-action";
import submitSignupForm from "../../(functions)/submit-signup-form";
export default function SignupForm() {
const init = useFormInit<SignupFormObject>();
const { loading, status } = init;
const method = useLoginMethod();
return (
<LoginShell status={status}>
<GoogleAuthSection {...init} mode="signup" />
<Form
className="w-full flex items-center justify-center relative gap-4"
onSubmit={() => {
submitSignupForm({ ...init, method });
submitSignupForm(init);
}}
>
{loading && <LoadingOverlay />}
<SignupFormEmail {...init} />
<SignupFormName {...init} />
<SignupFormUsername {...init} />
{method == "password" ? (
<>
<SignupFormPassword {...init} />
<SignupFormConfirmPassword {...init} />
</>
) : null}
<SignupFormPassword {...init} />
<SignupFormConfirmPassword {...init} />
<SignupFormAction {...init} />
</Form>
</LoginShell>
@@ -6,8 +6,8 @@ export default function SignupFormAction({}: ReturnType<
typeof useFormInit<SignupFormObject>
>) {
return (
<Button title="Create account" type="submit" className="w-full">
Create account
<Button title="Create super admin" type="submit" className="w-full">
Create super admin
</Button>
);
}
@@ -2,33 +2,23 @@ import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import AuthHero from "@/src/components/general/auth-hero";
import AuthFooterLink from "@/src/components/general/auth-footer-link";
import { SiteData } from "@/src/data/site-data";
import SignupForm from "../(partials)/signup-form";
import useLoginMethod from "@/src/hooks/use-login-method";
/**
* First-run setup. Only reachable while no account exists.
*/
export default function SignupFormSection() {
const method = useLoginMethod();
return (
<Section>
<Container>
<Stack className="w-full items-center max-w-md mx-auto gap-8">
<AuthHero
eyebrow="Get started"
title="Create your account"
description={
method == "password"
? `Sign up for ${SiteData["SiteName"]}. We'll email you a code to confirm it's you.`
: `Sign up for ${SiteData["SiteName"]} and we'll email you a one-time login code.`
}
eyebrow="Set up"
title="Create the super admin"
description={`This is the first account on this ${SiteData["SiteName"]} install. It can add everyone else.`}
/>
<SignupForm />
<AuthFooterLink
prompt="Already have an account?"
title="Log in"
url="/auth/login"
/>
</Stack>
</Container>
</Section>
+2 -2
View File
@@ -7,6 +7,6 @@ export default function SignupPage() {
}
export const meta: BunextPageModuleMeta = {
title: `Sign Up | ${SiteData["SiteName"]}`,
description: `Create your ${SiteData["SiteName"]} account.`,
title: `Set Up | ${SiteData["SiteName"]}`,
description: `Create the super admin for this ${SiteData["SiteName"]} install.`,
};
@@ -1,32 +0,0 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams, SSOFormObject } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function submitSSOForm({
setLoading,
setStatus,
form,
}: ReturnType<typeof useFormInit<SSOFormObject>>) {
setLoading(true);
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/auth/login`,
{
method: "POST",
body: {
sso_code: form.code?.trim(),
},
},
);
if (res.success) {
window.location.pathname = `/admin`;
} else {
setStatus({
error: true,
msg: res.msg || "Couldn't log you in. Please try again.",
});
setLoading(false);
}
}
@@ -1,36 +0,0 @@
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import Button from "@moduletrace/twui/layout/Button";
import useFormInit from "@/src/hooks/use-form-init";
import { type SSOFormObject } from "@/src/types";
import LoginShell from "@/src/components/general/login-shell";
import LoginCodeInput from "@/src/components/general/login-code-input";
import ResendCodeButton from "@/src/components/general/resend-code-button";
import submitSSOForm from "../../(functions)/submit-sso-form";
export default function SSOForm() {
const init = useFormInit<SSOFormObject>();
const { loading, status, setForm } = init;
return (
<LoginShell status={status}>
<Form
className="w-full flex items-center justify-center relative gap-4"
onSubmit={() => {
submitSSOForm(init);
}}
>
{loading && <LoadingOverlay />}
<LoginCodeInput
onCodeChange={(code) => {
setForm((prev) => ({ ...prev, code }));
}}
/>
<Button title="Log in" type="submit" className="w-full">
Log in
</Button>
</Form>
<ResendCodeButton {...init} />
</LoginShell>
);
}
@@ -1,37 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import AuthHero from "@/src/components/general/auth-hero";
import AuthFooterLink from "@/src/components/general/auth-footer-link";
import SSOForm from "../(partials)/sso-form";
import useSSOPendingLogin from "@/src/hooks/use-sso-pending-login";
import ssoHeroDescription from "../(utils)/sso-hero-description";
export default function SSOFormSection() {
const pending = useSSOPendingLogin();
const is_two_step = pending?.method == "password";
return (
<Section>
<Container>
<Stack className="w-full items-center max-w-md mx-auto gap-8">
<AuthHero
eyebrow={
is_two_step ? "Two-step verification" : "Check your email"
}
title={
is_two_step ? "Verify it's you" : "Enter your login code"
}
description={ssoHeroDescription({ pending })}
/>
<SSOForm />
<AuthFooterLink
prompt="Wrong account?"
title="Start over"
url="/auth/login"
/>
</Stack>
</Container>
</Section>
);
}
@@ -1,25 +0,0 @@
import { AppData } from "@/src/data/app-data";
import type { SSOPendingLogin } from "@/src/types";
type Params = {
pending?: SSOPendingLogin;
};
/**
* The code-only wording reads the same whether or not the account exists,
* matching the no-enumeration response of `get-login-code`. A password
* login only gets here once the password was correct.
*/
export default function ssoHeroDescription({ pending }: Params) {
const expiry = `It expires in ${AppData["SSOCodeExpiryMinutes"]} minutes.`;
if (pending?.method == "password") {
return `We've emailed a 6-digit code to the address on your account. ${expiry}`;
}
if (pending?.identifier) {
return `If ${pending.identifier} matches an account, we've emailed it a 6-digit login code. ${expiry}`;
}
return `Enter the 6-digit code we emailed you. ${expiry}`;
}
-12
View File
@@ -1,12 +0,0 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import SSOFormSection from "./(sections)/sso-form-section";
import { SiteData } from "@/src/data/site-data";
export default function SSOPage() {
return <SSOFormSection />;
}
export const meta: BunextPageModuleMeta = {
title: `Enter Login Code | ${SiteData["SiteName"]}`,
description: `Enter your one-time login code to log in to ${SiteData["SiteName"]}.`,
};