From 9a4a2d8a1eac446d021fd47bc9236d02f18834bf Mon Sep 17 00:00:00 2001 From: RadAlpaca11 Date: Sat, 20 Jun 2026 11:37:18 -0700 Subject: [PATCH 1/2] feat: adding settings to change name/email and sign-out confirmation --- src/app/(dashboard)/settings/page.tsx | 19 +++ src/components/layout/Sidebar.tsx | 34 ++++- src/components/settings/SettingsForm.tsx | 159 ++++++++++++++++++++++ src/components/ui/alert-dialog.tsx | 162 +++++++++++++++++++++++ src/lib/auth/auth.ts | 23 ++++ 5 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 src/app/(dashboard)/settings/page.tsx create mode 100644 src/components/settings/SettingsForm.tsx create mode 100644 src/components/ui/alert-dialog.tsx diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..1bedbcb --- /dev/null +++ b/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; + +import { SettingsForm } from "@/components/settings/SettingsForm"; +import { getSessionUser } from "@/lib/auth/session"; + +export default async function SettingsPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + return ( +
+
+

Account settings

+

Update your name and email address.

+
+ +
+ ); +} 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} />
-
+

{name}

{email}

-
+ + + + + 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..5fa6f5e --- /dev/null +++ b/src/components/settings/SettingsForm.tsx @@ -0,0 +1,159 @@ +"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 { 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 }: { name: string; email: string }) { + const router = useRouter(); + const [nameSubmitting, setNameSubmitting] = useState(false); + const [emailSubmitting, setEmailSubmitting] = useState(false); + const [emailSent, setEmailSent] = useState(false); + + const nameForm = useForm({ + resolver: zodResolver(nameSchema), + defaultValues: { name }, + }); + + const emailForm = useForm({ + resolver: zodResolver(emailSchema), + defaultValues: { newEmail: "" }, + }); + + async function onNameSubmit(values: NameValues) { + if (values.name === name) { + toast.info("No change to save."); + return; + } + setNameSubmitting(true); + const { error } = await authClient.updateUser({ name: values.name }); + setNameSubmitting(false); + if (error) { + toast.error(error.message ?? "Failed to update name"); + return; + } + toast.success("Name updated."); + router.refresh(); + } + + 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 + + +
+ + ( + + Name + + + + + + )} + /> + + + +
+
+ + + + Email address + Current: {email} + + + {emailSent ? ( +

+ A confirmation link has been sent to your new address. Click it to + complete the change. +

+ ) : ( +
+ + ( + + New email + + + + + + )} + /> + + + + )} +
+
+
+ ); +} 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 @@ -112,6 +153,27 @@ export function SettingsForm({ name, email }: { name: string; email: string }) { + !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 diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 64ea45a..718069c 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -98,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(