brackt/app/routes/settings.tsx
Claude 1ff4082129
Give user settings sections their own URLs
Each section of the user settings page (Profile, Account, Notifications,
API Access, Data & Privacy) is now a real, linkable path
(/settings/profile, /settings/account, etc.) instead of local toggle
state, so sections can be bookmarked, shared, opened in a new tab, and
reached via the browser back/forward buttons.

- Route now matches an optional segment (settings/:section?), keeping the
  single route so the shared loader/action and form submissions are
  unchanged. An unknown section redirects back to /settings.
- The shared settings nav components render proper <Link>s when given a
  buildHref/backHref, and keep their button/onClick behaviour for the
  league settings page (which has unsaved-changes guards).
- The settings page derives the active section and mobile grid/section
  view from the URL param instead of useState.
- Notifications "Account settings" link and the Discord OAuth callback
  now point at /settings/account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jt8Hhsio4bHGMaDZy7ftBs
2026-06-30 06:48:09 +00:00

296 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { redirect } from "react-router";
import { Bell, Key, Lock, Shield, User } from "lucide-react";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, isUserInActiveDraft, updateUser, anonymizeUserAccount, USERNAME_RE } from "~/models/user";
import { findLinkedAccountsByUserId } from "~/models/account";
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, escapeHtml } from "~/lib/email.server";
import { logger } from "~/lib/logger";
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
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 { NotificationsSection } from "~/components/user/settings/NotificationsSection";
import type { Route } from "./+types/settings";
type SectionId = "profile" | "account" | "notifications" | "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: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
{ 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;
const VALID_TIMEZONES = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value)));
const VALID_SECTION_IDS = new Set(SECTIONS.map((s) => s.id as SectionId));
const DATA_REQUEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type ActionData =
| { intent: "update-avatar-flag"; success: true }
| { intent: "update-avatar-flag"; error: string }
| { intent: "remove-avatar-photo"; success: true }
| { intent: "update-profile"; success: true }
| { intent: "update-profile"; error: string }
| { intent: "update-discord-ping"; success: true }
| { intent: "update-discord-ping"; error: string }
| { intent: "update-email-draft-notifications"; success: true }
| { intent: "update-email-draft-notifications"; error: string }
| { intent: "request-data"; dataRequestSuccess: true }
| { intent: "request-data"; dataRequestError: string }
| { intent: string; error: string };
export function meta(): Route.MetaDescriptors {
return [{ title: "Settings - Brackt" }];
}
export async function loader(args: Route.LoaderArgs) {
const section = args.params.section;
if (section && !(VALID_SECTION_IDS as Set<string>).has(section)) {
return redirect("/settings");
}
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, linkedAccounts] = await Promise.all([
isUserInActiveDraft(session.user.id),
findLinkedAccountsByUserId(session.user.id),
]);
const dataRequestCooldownUntil = user.lastDataRequestAt
? new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS)
: null;
return { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil };
}
export async function action(args: Route.ActionArgs): Promise<ActionData | Response> {
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: "update-avatar-flag", error: "Flag config is required." };
}
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(rawConfig);
} catch {
return { intent: "update-avatar-flag", error: "Invalid flag config." };
}
const flagConfig = parseFlagConfig(parsedConfig);
if (!flagConfig) {
return { intent: "update-avatar-flag", error: "Invalid flag config." };
}
await updateUser(session.user.id, { flagConfig, avatarType: "flag" });
return { intent: "update-avatar-flag", success: true };
}
if (intent === "remove-avatar-photo") {
await 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: "remove-avatar-photo", success: true };
}
if (intent === "update-profile") {
const username = (formData.get("username") as string | null)?.trim() ?? "";
const timezone = formData.get("timezone") as string | null;
const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
if (username !== "") {
if (!USERNAME_RE.test(username)) {
return { intent: "update-profile", error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens." };
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { intent: "update-profile", error: "That username is already taken." };
}
}
try {
await updateUser(session.user.id, {
...(username !== "" ? { username } : {}),
timezone: timezoneValue,
});
} catch {
return { intent: "update-profile", error: "That username is already taken." };
}
return { intent: "update-profile", success: true };
}
if (intent === "update-discord-ping") {
const enabled = formData.get("discordPingEnabled") === "true";
if (enabled) {
const accounts = await findLinkedAccountsByUserId(session.user.id);
if (!accounts.some((a) => a.providerId === "discord")) {
return { intent: "update-discord-ping", error: "Link your Discord account first." };
}
}
await updateUser(session.user.id, { discordPingEnabled: enabled });
return { intent: "update-discord-ping", success: true };
}
if (intent === "update-email-draft-notifications") {
const enabled = formData.get("draftEmailNotificationsEnabled") === "true";
await updateUser(session.user.id, { draftEmailNotificationsEnabled: enabled });
return { intent: "update-email-draft-notifications", success: true };
}
if (intent === "request-data") {
if (user.lastDataRequestAt) {
const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS);
if (cooldownExpires > new Date()) {
return {
intent: "request-data",
dataRequestError: `You already submitted a data request. You may submit another after ${cooldownExpires.toLocaleDateString()}.`,
};
}
}
const notes = (formData.get("dataRequestNotes") as string | null) ?? "";
const html = wrapInEmailTemplate(`
${emailParagraph(`A data export request has been submitted by <strong>${escapeHtml(user.email)}</strong> (user ID: ${escapeHtml(user.id)}).`)}
${notes ? emailParagraph(`Notes: ${escapeHtml(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: "request-data", dataRequestError: "Failed to submit request. Please email privacy@brackt.com directly." };
}
await updateUser(session.user.id, { lastDataRequestAt: new Date() });
return { intent: "request-data", 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: intent as string, error: "Unknown action." };
}
export default function SettingsPage({ loaderData, actionData, params }: Route.ComponentProps) {
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
const section = params.section;
const activeSection: SectionId =
section && (VALID_SECTION_IDS as Set<string>).has(section) ? (section as SectionId) : "profile";
const mobileView: "grid" | "section" = section ? "section" : "grid";
const ad = actionData as ActionData | undefined;
const profileSuccess = ad?.intent === "update-profile" && "success" in ad;
const profileError = ad?.intent === "update-profile" && "error" in ad ? ad.error : undefined;
const avatarSuccess =
(ad?.intent === "update-avatar-flag" || ad?.intent === "remove-avatar-photo") &&
"success" in ad;
const dataRequestSuccess = ad?.intent === "request-data" && "dataRequestSuccess" in ad;
const dataRequestError =
ad?.intent === "request-data" && "dataRequestError" in ad ? ad.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>
{mobileView === "grid" && (
<div className="lg:hidden">
<SettingsMobileGridNav
sections={SECTIONS}
buildHref={(id) => `/settings/${id}`}
/>
</div>
)}
{mobileView === "section" && (
<SettingsMobileSectionPill backHref="/settings" />
)}
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
<SettingsDesktopNav
sections={SECTIONS}
activeSection={activeSection}
buildHref={(id) => `/settings/${id}`}
navLabel="Account settings"
/>
<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 === "notifications" && (
<NotificationsSection
discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
/>
)}
{activeSection === "api" && <ApiSection />}
{activeSection === "privacy" && (
<PrivacySection
userEmail={user.email}
dataRequestCooldownUntil={dataRequestCooldownUntil}
dataRequestSuccess={dataRequestSuccess}
dataRequestError={dataRequestError}
/>
)}
</main>
</div>
</div>
);
}