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

{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..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}. + + )} + + +
+ + ( + + Name + + + + + + )} + /> + + + +
+
+ + !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. +

+ ) : ( +
+ + ( + + 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