diff --git a/drizzle/migrations/0010_loving_tarantula.sql b/drizzle/migrations/0010_loving_tarantula.sql
new file mode 100644
index 0000000..502738b
--- /dev/null
+++ b/drizzle/migrations/0010_loving_tarantula.sql
@@ -0,0 +1 @@
+ALTER TABLE `user` ADD `name_changed_at` integer;
\ No newline at end of file
diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json
index 5bb0070..bcfe47d 100644
--- a/drizzle/migrations/meta/_journal.json
+++ b/drizzle/migrations/meta/_journal.json
@@ -71,6 +71,13 @@
"when": 1782000000001,
"tag": "0009_eminent_wild_pack",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "6",
+ "when": 1782168471318,
+ "tag": "0010_loving_tarantula",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx
new file mode 100644
index 0000000..f352faf
--- /dev/null
+++ b/src/app/(dashboard)/settings/page.tsx
@@ -0,0 +1,40 @@
+import { redirect } from "next/navigation";
+
+import { SettingsForm } from "@/components/settings/SettingsForm";
+import { getSessionUser } from "@/lib/auth/session";
+import { NAME_CHANGE_COOLDOWN_MS } from "@/app/api/user/name/route";
+
+function nameCooldownProps(nameChangedAt: number | null) {
+ const now = Date.now();
+ const onCooldown = nameChangedAt != null && now - nameChangedAt < NAME_CHANGE_COOLDOWN_MS;
+ const cooldownUntil =
+ onCooldown && nameChangedAt != null
+ ? new Date(nameChangedAt + NAME_CHANGE_COOLDOWN_MS).toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ })
+ : null;
+ return { onCooldown, cooldownUntil };
+}
+
+export default async function SettingsPage() {
+ const user = await getSessionUser();
+ if (!user) redirect("/login");
+
+ const { onCooldown, cooldownUntil } = nameCooldownProps(user.nameChangedAt ?? null);
+
+ return (
+
+
+
Account settings
+
Update your name and email address.
+
+
+
+ );
+}
diff --git a/src/app/api/user/name/route.ts b/src/app/api/user/name/route.ts
new file mode 100644
index 0000000..e4d58b6
--- /dev/null
+++ b/src/app/api/user/name/route.ts
@@ -0,0 +1,45 @@
+import { eq } from "drizzle-orm";
+import { NextResponse, type NextRequest } from "next/server";
+import { z } from "zod";
+
+import { db } from "@/lib/db";
+import { user } from "@/lib/db/schema";
+import { getSessionUser } from "@/lib/auth/session";
+
+export const NAME_CHANGE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
+
+const schema = z.object({
+ name: z.string().trim().min(1, "Name is required").max(100),
+});
+
+export async function PATCH(req: NextRequest) {
+ const sessionUser = await getSessionUser();
+ if (!sessionUser) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await req.json().catch(() => null);
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const now = Date.now();
+ if (
+ sessionUser.nameChangedAt != null &&
+ now - sessionUser.nameChangedAt < NAME_CHANGE_COOLDOWN_MS
+ ) {
+ const availableAt = sessionUser.nameChangedAt + NAME_CHANGE_COOLDOWN_MS;
+ return NextResponse.json({ error: "cooldown", availableAt }, { status: 429 });
+ }
+
+ db.update(user)
+ .set({ name: parsed.data.name, nameChangedAt: now })
+ .where(eq(user.id, sessionUser.id))
+ .run();
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index fe0f975..ebc7b7d 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -6,6 +6,16 @@ import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { signOut } from "@/lib/auth/client";
import { NavContent } from "./SidebarNav";
@@ -26,6 +36,7 @@ export function Sidebar({
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
async function handleSignOut() {
setLoading(true);
@@ -60,23 +71,40 @@ export function Sidebar({
grantedFeatures={grantedFeatures}
/>
-
+
+
+
+
+ Sign out?
+
+ You'll need to sign in again to access the dashboard.
+
+
+
+ Cancel
+ Sign out
+
+
+
);
diff --git a/src/components/settings/SettingsForm.tsx b/src/components/settings/SettingsForm.tsx
new file mode 100644
index 0000000..4b2877a
--- /dev/null
+++ b/src/components/settings/SettingsForm.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { authClient } from "@/lib/auth/client";
+
+const nameSchema = z.object({
+ name: z.string().trim().min(1, "Name is required").max(100),
+});
+
+const emailSchema = z.object({
+ newEmail: z.string().trim().email("Enter a valid email address").max(200),
+});
+
+type NameValues = z.infer;
+type EmailValues = z.infer;
+
+export function SettingsForm({
+ name,
+ email,
+ onCooldown,
+ cooldownUntil,
+}: {
+ name: string;
+ email: string;
+ onCooldown: boolean;
+ cooldownUntil: string | null;
+}) {
+ const router = useRouter();
+ const [nameSubmitting, setNameSubmitting] = useState(false);
+ const [emailSubmitting, setEmailSubmitting] = useState(false);
+ const [emailSent, setEmailSent] = useState(false);
+ const [pendingName, setPendingName] = useState(null);
+
+ const nameForm = useForm({
+ resolver: zodResolver(nameSchema),
+ defaultValues: { name },
+ });
+
+ const emailForm = useForm({
+ resolver: zodResolver(emailSchema),
+ defaultValues: { newEmail: "" },
+ });
+
+ function onNameSubmit(values: NameValues) {
+ if (values.name === name) {
+ toast.info("No change to save.");
+ return;
+ }
+ setPendingName(values.name);
+ }
+
+ async function confirmNameChange() {
+ if (!pendingName) return;
+ setNameSubmitting(true);
+ try {
+ const res = await fetch("/api/user/name", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: pendingName }),
+ });
+ const data = await res.json().catch(() => null);
+ if (!res.ok) {
+ throw new Error(data?.error ?? "Failed to update name");
+ }
+ toast.success("Name updated.");
+ router.refresh();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setNameSubmitting(false);
+ setPendingName(null);
+ }
+ }
+
+ async function onEmailSubmit(values: EmailValues) {
+ if (values.newEmail === email) {
+ toast.info("That's already your email.");
+ return;
+ }
+ setEmailSubmitting(true);
+ const { error } = await authClient.changeEmail({
+ newEmail: values.newEmail,
+ callbackURL: "/settings",
+ });
+ setEmailSubmitting(false);
+ if (error) {
+ toast.error(error.message ?? "Failed to request email change");
+ return;
+ }
+ setEmailSent(true);
+ emailForm.reset();
+ }
+
+ return (
+
+
+
+ Display name
+ {onCooldown && cooldownUntil && (
+
+ You can change your name again on {cooldownUntil}.
+
+ )}
+
+
+
+
+
+
+
+
!open && setPendingName(null)}
+ >
+
+
+ Change display name?
+
+ Your name will be changed to “{pendingName}”. You won't
+ be able to change it again for 7 days.
+
+
+
+ Cancel
+
+ {nameSubmitting ? "Saving…" : "Change name"}
+
+
+
+
+
+
+
+ Email address
+ Current: {email}
+
+
+ {emailSent ? (
+
+ A confirmation link has been sent to your new address. Click it to
+ complete the change.
+
+ ) : (
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..56eb347
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import * as React from "react";
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return ;
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return ;
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return ;
+}
+
+function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm";
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts
index aaec2e5..718069c 100644
--- a/src/lib/auth/auth.ts
+++ b/src/lib/auth/auth.ts
@@ -50,6 +50,29 @@ export const auth = betterAuth({
autoSignInAfterVerification: true,
},
user: {
+ changeEmail: {
+ enabled: true,
+ async sendChangeEmailVerification({
+ newEmail,
+ url,
+ }: {
+ newEmail: string;
+ url: string;
+ }) {
+ await sendEmail({
+ to: newEmail,
+ subject: "Confirm your new TrickFire email",
+ html: `
+
+
Confirm email change
+
Click the button below to confirm your new email address.
+
Confirm new email
+
If you didn't request this, ignore this email — your address will not change.
+
+ `,
+ });
+ },
+ },
additionalFields: {
role: {
type: "string",
@@ -75,6 +98,12 @@ export const auth = betterAuth({
defaultValue: false,
input: false,
},
+ nameChangedAt: {
+ type: "number",
+ required: false,
+ defaultValue: null,
+ input: false,
+ },
},
},
});
diff --git a/src/lib/db/auth-schema.ts b/src/lib/db/auth-schema.ts
index b5983fe..923b41e 100644
--- a/src/lib/db/auth-schema.ts
+++ b/src/lib/db/auth-schema.ts
@@ -18,6 +18,7 @@ export const user = sqliteTable("user", {
isActive: integer("is_active", { mode: "boolean" }).default(true),
canAccessVault: integer("can_access_vault", { mode: "boolean" }).default(false),
approved: integer("approved", { mode: "boolean" }).default(false),
+ nameChangedAt: integer("name_changed_at"),
});
export const session = sqliteTable(