Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drizzle/migrations/0010_loving_tarantula.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `name_changed_at` integer;
7 changes: 7 additions & 0 deletions drizzle/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
40 changes: 40 additions & 0 deletions src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl">Account settings</h1>
<p className="text-muted-foreground">Update your name and email address.</p>
</div>
<SettingsForm
name={user.name}
email={user.email}
onCooldown={onCooldown}
cooldownUntil={cooldownUntil}
/>
</div>
);
}
45 changes: 45 additions & 0 deletions src/app/api/user/name/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
34 changes: 31 additions & 3 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -60,23 +71,40 @@ export function Sidebar({
grantedFeatures={grantedFeatures}
/>
<div className="border-sidebar-border flex items-center gap-3 border-t px-4 py-3">
<div className="min-w-0 flex-1">
<Link
href="/settings"
className="hover:bg-sidebar-accent min-w-0 flex-1 rounded-md p-1 transition-colors"
>
<p className="truncate text-sm font-medium" title={name}>
{name}
</p>
<p className="text-muted-foreground truncate text-xs" title={email}>
{email}
</p>
</div>
</Link>
<Button
variant="outline"
size="icon"
onClick={handleSignOut}
disabled={loading}
className="h-9 w-9 shrink-0"
onClick={() => setConfirmOpen(true)}
>
<LogOut className="size-4" />
</Button>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;ll need to sign in again to access the dashboard.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSignOut}>Sign out</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</aside>
);
Expand Down
221 changes: 221 additions & 0 deletions src/components/settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof nameSchema>;
type EmailValues = z.infer<typeof emailSchema>;

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<string | null>(null);

const nameForm = useForm<NameValues>({
resolver: zodResolver(nameSchema),
defaultValues: { name },
});

const emailForm = useForm<EmailValues>({
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 (
<div className="max-w-lg space-y-6">
<Card>
<CardHeader>
<CardTitle>Display name</CardTitle>
{onCooldown && cooldownUntil && (
<CardDescription>
You can change your name again on {cooldownUntil}.
</CardDescription>
)}
</CardHeader>
<CardContent>
<Form {...nameForm}>
<form onSubmit={nameForm.handleSubmit(onNameSubmit)} className="space-y-4">
<FormField
control={nameForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} disabled={onCooldown} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={nameSubmitting || onCooldown}>
{nameSubmitting ? "Saving…" : "Save name"}
</Button>
</form>
</Form>
</CardContent>
</Card>

<AlertDialog
open={pendingName != null}
onOpenChange={(open) => !open && setPendingName(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Change display name?</AlertDialogTitle>
<AlertDialogDescription>
Your name will be changed to &ldquo;{pendingName}&rdquo;. You won&apos;t
be able to change it again for 7 days.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmNameChange} disabled={nameSubmitting}>
{nameSubmitting ? "Saving…" : "Change name"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

<Card>
<CardHeader>
<CardTitle>Email address</CardTitle>
<CardDescription>Current: {email}</CardDescription>
</CardHeader>
<CardContent>
{emailSent ? (
<p className="text-muted-foreground text-sm">
A confirmation link has been sent to your new address. Click it to
complete the change.
</p>
) : (
<Form {...emailForm}>
<form
onSubmit={emailForm.handleSubmit(onEmailSubmit)}
className="space-y-4"
>
<FormField
control={emailForm.control}
name="newEmail"
render={({ field }) => (
<FormItem>
<FormLabel>New email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="new@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={emailSubmitting}>
{emailSubmitting ? "Sending…" : "Send confirmation"}
</Button>
</form>
</Form>
)}
</CardContent>
</Card>
</div>
);
}
Loading