Add account deletion and multi-section settings page

- Rename /user-profile → /settings with 301 redirect from old URL
- Add multi-section settings nav (Profile, Account, API placeholder, Data & Privacy)
  reusing existing SettingsDesktopNav/SettingsMobileGridNav components
- Implement account deletion via anonymization: wipes all PII from users row,
  releases team ownerships, removes commissioner records, deletes sessions/accounts
- Add data export request form that emails privacy@brackt.com via Resend
- Add deletedAt timestamp column to users table (migration 0100)
- Add anonymizeUserAccount() to user model
- Add removeAllCommissionersByUserId() to commissioner model
- Tests for both new model functions
- Update UserMenu "Profile" link → "Settings" at /settings

https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X
This commit is contained in:
Claude 2026-05-10 20:28:23 +00:00
parent 04a92ec7de
commit 388071fef8
No known key found for this signature in database
18 changed files with 6511 additions and 198 deletions

View file

@ -50,10 +50,10 @@ export function UserMenu({
</div>
<div className="border-t border-border my-1" />
<Link
to="/user-profile"
to="/settings"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
Profile
Settings
</Link>
<div className="border-t border-border my-1" />
<button

View file

@ -0,0 +1,79 @@
import { Link } from "react-router";
import { Badge } from "~/components/ui/badge";
type LinkedAccount = {
id: string;
providerId: string;
};
type Props = {
email: string;
linkedAccounts: LinkedAccount[];
};
const PROVIDER_LABELS: Record<string, string> = {
credential: "Email & Password",
google: "Google",
discord: "Discord",
};
export function AccountSection({ email, linkedAccounts }: Props) {
const hasPassword = linkedAccounts.some((a) => a.providerId === "credential");
const oauthAccounts = linkedAccounts.filter((a) => a.providerId !== "credential");
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">Account</h2>
<p className="text-sm text-muted-foreground">
Your login email and connected providers.
</p>
</div>
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="text-sm font-medium">Email Address</h3>
<p className="text-sm text-muted-foreground">{email}</p>
</div>
<div className="rounded-lg border p-4 space-y-3">
<h3 className="text-sm font-medium">Sign-in Methods</h3>
<div className="space-y-2">
{hasPassword && (
<div className="flex items-center justify-between">
<span className="text-sm">Email &amp; Password</span>
<Badge variant="secondary">Active</Badge>
</div>
)}
{oauthAccounts.map((account) => (
<div key={account.id} className="flex items-center justify-between">
<span className="text-sm">
{PROVIDER_LABELS[account.providerId] ?? account.providerId}
</span>
<Badge variant="secondary">Connected</Badge>
</div>
))}
{linkedAccounts.length === 0 && (
<p className="text-sm text-muted-foreground">No sign-in methods found.</p>
)}
</div>
</div>
{hasPassword && (
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-sm font-medium">Password</h3>
<p className="text-sm text-muted-foreground">
To change your password, use the forgot password flow.
</p>
<Link
to="/forgot-password"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Reset password
</Link>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
import { Key } from "lucide-react";
export function ApiSection() {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">API Access</h2>
<p className="text-sm text-muted-foreground">
Manage API keys for third-party integrations.
</p>
</div>
<div className="rounded-lg border border-dashed p-8 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Key className="h-5 w-5 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium">Coming Soon</h3>
<p className="mt-1 text-sm text-muted-foreground">
API access will be available in a future update.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { Form } from "react-router";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogCancel,
} from "~/components/ui/alert-dialog";
type Props = {
userEmail: string;
dataRequestSuccess?: boolean;
dataRequestError?: string;
};
export function PrivacySection({ userEmail, dataRequestSuccess, dataRequestError }: Props) {
const [confirmed, setConfirmed] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="space-y-8">
<div>
<h2 className="text-lg font-semibold">Data &amp; Privacy</h2>
<p className="text-sm text-muted-foreground">
Request a copy of your data or permanently delete your account.
</p>
</div>
{/* Data Request */}
<div className="rounded-lg border p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold">Request My Data</h3>
<p className="mt-1 text-sm text-muted-foreground">
Under GDPR and CCPA you have the right to a copy of all personal data
we hold about you. We will respond within 30 days to{" "}
<span className="font-medium">{userEmail}</span>.
</p>
</div>
{dataRequestSuccess && (
<p className="text-sm text-green-500">
Request received. We&apos;ll email you within 30 days.
</p>
)}
{dataRequestError && (
<p className="text-sm text-destructive">{dataRequestError}</p>
)}
{!dataRequestSuccess && (
<Form method="post" className="space-y-3">
<div className="space-y-2">
<Label htmlFor="dataRequestNotes">
Additional notes{" "}
<span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Textarea
id="dataRequestNotes"
name="dataRequestNotes"
placeholder="e.g. specific data types you are interested in"
rows={3}
className="resize-none"
/>
</div>
<Button
type="submit"
name="intent"
value="request-data"
variant="outline"
>
Submit Data Request
</Button>
</Form>
)}
</div>
{/* Danger Zone */}
<div className="rounded-lg border border-destructive/40 p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-destructive">Delete Account</h3>
<p className="mt-1 text-sm text-muted-foreground">
Permanently remove your personal information from Brackt. Your leagues and
draft history will be preserved but your name, email, and avatar will be
wiped. This action cannot be undone.
</p>
</div>
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
Your email, display name, username, and avatar will be permanently
erased. Your leagues and draft history remain intact but will show{" "}
<span className="font-medium">Deleted User</span> in your place. You
will be signed out immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
<input
type="checkbox"
id="confirm-delete"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0"
/>
<Label htmlFor="confirm-delete" className="text-sm leading-snug cursor-pointer">
I understand this is permanent and cannot be undone
</Label>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmed(false)}>
Cancel
</AlertDialogCancel>
<Form method="post">
<Button
type="submit"
name="intent"
value="delete-account"
variant="destructive"
disabled={!confirmed}
>
Delete My Account
</Button>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
}

View file

@ -0,0 +1,105 @@
import { Form } from "react-router";
import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
import { AvatarEditor } from "~/components/ui/AvatarEditor";
import type { User } from "~/models/user";
const VALID_TIMEZONES = new Set(
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
);
type Props = {
user: User;
isInActiveDraft: boolean;
success?: boolean;
error?: string;
};
export function ProfileSection({ user, isInActiveDraft, success, error }: Props) {
const [timezone, setTimezone] = useState(user.timezone ?? "");
useEffect(() => {
if (!user.timezone) {
try {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (detected && VALID_TIMEZONES.has(detected)) {
setTimezone(detected);
}
} catch {
// Intl not available
}
}
}, [user.timezone]);
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm text-muted-foreground">
Your display name, username, avatar, and timezone.
</p>
</div>
{success && (
<p className="text-sm text-green-500">Profile updated successfully.</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="border-b pb-6">
<AvatarEditor
id={user.id}
flagConfig={user.flagConfig}
uploadedPhotoUrl={user.avatarType === "uploaded" ? user.customAvatarUrl : null}
uploadUrl="/api/upload-avatar"
/>
</div>
<Form method="post" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
name="displayName"
defaultValue={user.displayName ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
defaultValue={user.username ?? ""}
/>
</div>
<div className="space-y-1">
<Label>Email</Label>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<input type="hidden" name="timezone" value={timezone} />
<TimezoneSelect
value={timezone}
onChange={setTimezone}
disabled={isInActiveDraft}
/>
{isInActiveDraft ? (
<p className="text-xs text-muted-foreground">
Timezone cannot be changed while you are in an active draft.
</p>
) : (
<p className="text-xs text-muted-foreground">
Used for overnight pick protection. We pre-filled your browser&apos;s detected timezone.
</p>
)}
</div>
<Button type="submit" name="intent" value="update-profile">
Save Changes
</Button>
</Form>
</div>
);
}

View file

@ -8,7 +8,7 @@ vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { isCommissioner, hasCommissionerRecord } from "../commissioner";
import { isCommissioner, hasCommissionerRecord, removeAllCommissionersByUserId } from "../commissioner";
import { isUserAdmin } from "~/models/user";
import { database } from "~/database/context";
@ -113,3 +113,16 @@ describe("hasCommissionerRecord", () => {
expect(isUserAdmin).not.toHaveBeenCalled();
});
});
describe("removeAllCommissionersByUserId", () => {
it("deletes all commissioner rows for a given user", async () => {
const whereFn = vi.fn().mockResolvedValue(undefined);
const deleteFn = vi.fn().mockReturnValue({ where: whereFn });
vi.mocked(database).mockReturnValue({ delete: deleteFn } as never);
await removeAllCommissionersByUserId(USER_ID);
expect(deleteFn).toHaveBeenCalledTimes(1);
expect(whereFn).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { anonymizeUserAccount } from "../user";
import { database } from "~/database/context";
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
function makeDeleteChain() {
return { where: vi.fn().mockResolvedValue(undefined) };
}
function makeUpdateChain() {
const whereFn = vi.fn().mockResolvedValue(undefined);
const setFn = vi.fn().mockReturnValue({ where: whereFn });
return { set: setFn, _where: whereFn };
}
function makeAnonymizeDb() {
const deleteChains: ReturnType<typeof makeDeleteChain>[] = [];
const updateChain = makeUpdateChain();
const deleteFn = vi.fn().mockImplementation(() => {
const chain = makeDeleteChain();
deleteChains.push(chain);
return chain;
});
return {
db: { delete: deleteFn, update: vi.fn().mockReturnValue(updateChain) },
deleteChains,
updateChain,
deleteFn,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("anonymizeUserAccount", () => {
it("deletes sessions and accounts for the user", async () => {
const { db, deleteFn } = makeAnonymizeDb();
vi.mocked(database).mockReturnValue(db as never);
await anonymizeUserAccount(USER_ID);
expect(deleteFn).toHaveBeenCalledTimes(2);
});
it("calls update on the users table with anonymized fields", async () => {
const { db, updateChain } = makeAnonymizeDb();
vi.mocked(database).mockReturnValue(db as never);
await anonymizeUserAccount(USER_ID);
expect(db.update).toHaveBeenCalledTimes(1);
const setCall = updateChain.set.mock.calls[0][0];
expect(setCall.email).toMatch(/^deleted-[a-f0-9]{8}@deleted\.brackt\.com$/);
expect(setCall.displayName).toBeNull();
expect(setCall.username).toBeNull();
expect(setCall.imageUrl).toBeNull();
expect(setCall.customAvatarUrl).toBeNull();
expect(setCall.flagConfig).toBeNull();
expect(setCall.avatarType).toBe("flag");
expect(setCall.deletedAt).toBeInstanceOf(Date);
});
it("derives the short ID from the UUID", async () => {
const { db, updateChain } = makeAnonymizeDb();
vi.mocked(database).mockReturnValue(db as never);
await anonymizeUserAccount(USER_ID);
const setCall = updateChain.set.mock.calls[0][0];
// USER_ID without dashes starts with "a1b2c3d4" → shortId = "a1b2c3d4"
expect(setCall.email).toBe("deleted-a1b2c3d4@deleted.brackt.com");
});
});

View file

@ -97,3 +97,8 @@ export async function removeCommissionerByLeagueAndUser(
)
);
}
export async function removeAllCommissionersByUserId(userId: string): Promise<void> {
const db = database();
await db.delete(schema.commissioners).where(eq(schema.commissioners.userId, userId));
}

View file

@ -56,6 +56,27 @@ export async function deleteUser(id: string): Promise<void> {
await db.delete(schema.users).where(eq(schema.users.id, id));
}
export async function anonymizeUserAccount(id: string): Promise<void> {
const db = database();
const shortId = id.replace(/-/g, "").slice(0, 8);
await db.delete(schema.sessions).where(eq(schema.sessions.userId, id));
await db.delete(schema.accounts).where(eq(schema.accounts.userId, id));
await db
.update(schema.users)
.set({
email: `deleted-${shortId}@deleted.brackt.com`,
displayName: null,
username: null,
imageUrl: null,
customAvatarUrl: null,
flagConfig: null,
avatarType: "flag",
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.users.id, id));
}
export async function findAdmins(): Promise<User[]> {
const db = database();
return await db.query.users.findMany({

View file

@ -60,7 +60,8 @@ export default [
route("check-email", "routes/check-email.tsx"),
route("forgot-password", "routes/forgot-password.tsx"),
route("reset-password", "routes/reset-password.tsx"),
route("user-profile", "routes/user-profile.tsx"),
route("settings", "routes/settings.tsx"),
route("user-profile", "routes/user-profile-redirect.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),
route("rules", "routes/rules.tsx"),
route("support", "routes/support.tsx"),

View file

@ -195,6 +195,7 @@ describe('Team Management - Admin User List', () => {
avatarType: 'flag',
isAdmin: false,
timezone: null,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
@ -211,6 +212,7 @@ describe('Team Management - Admin User List', () => {
avatarType: 'flag',
isAdmin: false,
timezone: null,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
@ -248,6 +250,7 @@ describe('Team Management - Admin User List', () => {
avatarType: 'flag',
isAdmin: false,
timezone: null,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},

242
app/routes/settings.tsx Normal file
View file

@ -0,0 +1,242 @@
import { redirect } from "react-router";
import { useState } from "react";
import { eq } from "drizzle-orm";
import { Key, Lock, Shield, User } from "lucide-react";
import { auth } from "~/lib/auth.server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
findUserById,
isUserInActiveDraft,
anonymizeUserAccount,
} from "~/models/user";
import { findTeamsByOwnerId, removeTeamOwner } from "~/models/team";
import { removeAllCommissionersByUserId } from "~/models/commissioner";
import { parseFlagConfig } from "~/lib/flag-types";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { sendEmail, wrapInEmailTemplate, emailParagraph } from "~/lib/email.server";
import { logger } from "~/lib/logger";
import {
SettingsDesktopNav,
SettingsMobileGridNav,
SettingsMobileSectionPill,
type SettingsGridSection,
} from "~/components/league/settings/SettingsNavigation";
import { ProfileSection } from "~/components/user/settings/ProfileSection";
import { AccountSection } from "~/components/user/settings/AccountSection";
import { ApiSection } from "~/components/user/settings/ApiSection";
import { PrivacySection } from "~/components/user/settings/PrivacySection";
import type { Route } from "./+types/settings";
type SectionId = "profile" | "account" | "api" | "privacy";
const SECTIONS: readonly SettingsGridSection[] = [
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
{ id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" },
{ id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" },
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
] as const;
export function meta(): Route.MetaDescriptors {
return [{ title: "Settings - Brackt" }];
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/settings");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/");
}
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
const db = database();
const linkedAccounts = await db
.select({ id: schema.accounts.id, providerId: schema.accounts.providerId })
.from(schema.accounts)
.where(eq(schema.accounts.userId, session.user.id));
return { user, isInActiveDraft, linkedAccounts };
}
export async function action(args: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/settings");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/");
}
const formData = await args.request.formData();
const intent = formData.get("intent");
if (intent === "update-avatar-flag") {
const rawConfig = formData.get("flagConfig");
if (typeof rawConfig !== "string") {
return { intent, error: "Flag config is required." };
}
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(rawConfig);
} catch {
return { intent, error: "Invalid flag config." };
}
const flagConfig = parseFlagConfig(parsedConfig);
if (!flagConfig) {
return { intent, error: "Invalid flag config." };
}
await import("~/models/user").then(({ updateUser }) =>
updateUser(session.user.id, { flagConfig, avatarType: "flag" })
);
return { intent, success: true };
}
if (intent === "remove-avatar-photo") {
await import("~/models/user").then(({ updateUser }) =>
updateUser(session.user.id, { customAvatarUrl: null, avatarType: "flag" })
);
if (user.customAvatarUrl) {
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((err) => {
logger.error("Failed to delete user avatar from Cloudinary:", err);
});
}
return { intent, success: true };
}
if (intent === "update-profile") {
const displayName = formData.get("displayName") as string;
const username = formData.get("username") as string | null;
const timezone = formData.get("timezone") as string | null;
const { TIMEZONE_OPTIONS } = await import("~/components/league/TimezoneSelect");
const validTimezones = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value)));
const timezoneValue = timezone && validTimezones.has(timezone) ? timezone : undefined;
await import("~/models/user").then(({ updateUser }) =>
updateUser(session.user.id, {
displayName: displayName || undefined,
username: username || undefined,
timezone: timezoneValue,
})
);
return { intent, success: true };
}
if (intent === "request-data") {
const notes = (formData.get("dataRequestNotes") as string | null) ?? "";
const html = wrapInEmailTemplate(`
${emailParagraph(`A data export request has been submitted by <strong>${user.email}</strong> (user ID: ${user.id}).`)}
${notes ? emailParagraph(`Notes: ${notes}`) : ""}
${emailParagraph("Please fulfill this request within 30 days per GDPR/CCPA requirements.")}
`);
const { error } = await sendEmail({
to: "privacy@brackt.com",
subject: `Data Request from ${user.email}`,
html,
replyTo: user.email,
});
if (error) {
logger.error("Failed to send data request email:", error);
return { intent, dataRequestError: "Failed to submit request. Please email privacy@brackt.com directly." };
}
return { intent, dataRequestSuccess: true };
}
if (intent === "delete-account") {
const customAvatarUrl = user.customAvatarUrl;
const ownedTeams = await findTeamsByOwnerId(session.user.id);
await Promise.all(ownedTeams.map((team) => removeTeamOwner(team.id)));
await removeAllCommissionersByUserId(session.user.id);
await anonymizeUserAccount(session.user.id);
if (customAvatarUrl) {
deleteCloudinaryImageByUrl(customAvatarUrl).catch((err) => {
logger.error("Failed to delete user avatar from Cloudinary after account deletion:", err);
});
}
return redirect("/");
}
return { intent, error: "Unknown action." };
}
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
const { user, isInActiveDraft, linkedAccounts } = loaderData;
const [activeSection, setActiveSection] = useState<SectionId>("profile");
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
const handleSectionChange = (id: string, mobile = false) => {
setActiveSection(id as SectionId);
if (mobile) setMobileView("section");
};
const profileSuccess = actionData?.intent === "update-profile" && "success" in actionData && actionData.success === true;
const profileError = actionData?.intent === "update-profile" && "error" in actionData ? (actionData as { error: string }).error : undefined;
const avatarSuccess = (actionData?.intent === "update-avatar-flag" || actionData?.intent === "remove-avatar-photo") && "success" in actionData;
const dataRequestSuccess = actionData?.intent === "request-data" && "dataRequestSuccess" in actionData ? true : undefined;
const dataRequestError = actionData?.intent === "request-data" && "dataRequestError" in actionData ? (actionData as { dataRequestError: string }).dataRequestError : undefined;
return (
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold">Settings</h1>
</div>
{/* Mobile: grid of sections */}
{mobileView === "grid" && (
<div className="lg:hidden">
<SettingsMobileGridNav
sections={SECTIONS}
onSectionChange={(id) => handleSectionChange(id, true)}
/>
</div>
)}
{/* Mobile: back pill when viewing a section */}
{mobileView === "section" && (
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
)}
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
<SettingsDesktopNav
sections={SECTIONS}
activeSection={activeSection}
onSectionChange={(id) => handleSectionChange(id)}
/>
<main className="min-w-0">
{activeSection === "profile" && (
<ProfileSection
user={user}
isInActiveDraft={isInActiveDraft}
success={profileSuccess || avatarSuccess}
error={profileError}
/>
)}
{activeSection === "account" && (
<AccountSection
email={user.email}
linkedAccounts={linkedAccounts}
/>
)}
{activeSection === "api" && <ApiSection />}
{activeSection === "privacy" && (
<PrivacySection
userEmail={user.email}
dataRequestSuccess={dataRequestSuccess}
dataRequestError={dataRequestError}
/>
)}
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "react-router";
export function loader() {
return redirect("/settings", 301);
}

View file

@ -1,194 +0,0 @@
import { redirect, Form } from "react-router";
import { useEffect, useState } from "react";
import { auth } from "~/lib/auth.server";
import { findUserById, updateUser, isUserInActiveDraft } from "~/models/user";
import type { Route } from "./+types/user-profile";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
import { AvatarEditor } from "~/components/ui/AvatarEditor";
import { parseFlagConfig } from "~/lib/flag-types";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
const VALID_TIMEZONES = new Set(
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
);
export function meta(): Route.MetaDescriptors {
return [{ title: "Profile - Brackt" }];
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/user-profile");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/");
}
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
return { user, isInActiveDraft };
}
export async function action(args: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/user-profile");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/");
}
const formData = await args.request.formData();
const intent = formData.get("intent");
if (intent === "update-avatar-flag") {
const rawConfig = formData.get("flagConfig");
if (typeof rawConfig !== "string") {
return { error: "Flag config is required." };
}
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(rawConfig);
} catch {
return { error: "Invalid flag config." };
}
const flagConfig = parseFlagConfig(parsedConfig);
if (!flagConfig) {
return { error: "Invalid flag config." };
}
await updateUser(session.user.id, {
flagConfig,
avatarType: "flag",
});
return { success: true };
}
if (intent === "remove-avatar-photo") {
await updateUser(session.user.id, {
customAvatarUrl: null,
avatarType: "flag",
});
if (user.customAvatarUrl) {
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((error) => {
logger.error("Failed to delete user avatar from Cloudinary:", error);
});
}
return { success: true };
}
const displayName = formData.get("displayName") as string;
const username = formData.get("username") as string | null;
const timezone = formData.get("timezone") as string | null;
const timezoneValue =
timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
await updateUser(session.user.id, {
displayName: displayName || undefined,
username: username || undefined,
timezone: timezoneValue,
});
return { success: true };
}
export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) {
const { user, isInActiveDraft } = loaderData;
const [timezone, setTimezone] = useState(user.timezone ?? "");
// Auto-detect browser timezone if not already set
useEffect(() => {
if (!user.timezone) {
try {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (detected && VALID_TIMEZONES.has(detected)) {
setTimezone(detected);
}
} catch {
// Intl not available — leave blank
}
}
}, [user.timezone]);
return (
<div className="container mx-auto py-8 px-4 max-w-lg">
<Card>
<CardHeader>
<CardTitle>Your Profile</CardTitle>
</CardHeader>
<CardContent>
{actionData?.success && (
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
)}
{actionData && "error" in actionData && actionData.error && (
<p className="text-sm text-destructive mb-4">{actionData.error}</p>
)}
<div className="mb-6 border-b pb-6">
<AvatarEditor
id={user.id}
flagConfig={user.flagConfig}
uploadedPhotoUrl={user.avatarType === "uploaded" ? user.customAvatarUrl : null}
uploadUrl="/api/upload-avatar"
/>
</div>
<Form method="post" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
name="displayName"
defaultValue={user.displayName ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
defaultValue={user.username ?? ""}
/>
</div>
<div className="space-y-1">
<Label>Email</Label>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
{/* Hidden input carries the controlled value into the form submission */}
<input type="hidden" name="timezone" value={timezone} />
<TimezoneSelect
value={timezone}
onChange={setTimezone}
disabled={isInActiveDraft}
/>
{isInActiveDraft ? (
<p className="text-xs text-muted-foreground">
Timezone cannot be changed while you are in an active draft.
</p>
) : (
<p className="text-xs text-muted-foreground">
Used for overnight pick protection. We pre-filled your browser&apos;s detected timezone.
</p>
)}
</div>
<Button type="submit" className="w-full">Save Changes</Button>
</Form>
</CardContent>
</Card>
</div>
);
}

View file

@ -14,6 +14,7 @@ export const users = pgTable("users", {
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
isAdmin: boolean("is_admin").notNull().default(false),
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View file

@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "deleted_at" timestamp;

File diff suppressed because it is too large Load diff

View file

@ -701,6 +701,13 @@
"when": 1778341807236,
"tag": "0099_peaceful_hannibal_king",
"breakpoints": true
},
{
"idx": 100,
"version": "7",
"when": 1778440988519,
"tag": "0100_mighty_weapon_omega",
"breakpoints": true
}
]
}