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