brackt/app/routes/settings.tsx
Claude 30085ab3e1
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m17s
Skip settings loader revalidation on section switches
Section navigation only changes the :section path param, but the loader
returns the same user/draft-status/linked-account payload for every
section. By default React Router re-runs the loader (3 DB queries) on
each tab switch. Add shouldRevalidate to skip revalidation when only the
section changes, while still refreshing after mutations.

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

310 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, type ShouldRevalidateFunctionArgs } 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 function shouldRevalidate({
currentParams,
nextParams,
formMethod,
defaultShouldRevalidate,
}: ShouldRevalidateFunctionArgs) {
// Mutations (profile/avatar/notification updates) must refresh loader data.
if (formMethod && formMethod !== "GET") return true;
// Switching sections only changes the path param, and the loader payload is the
// same for every section, so don't refetch user/draft status/linked accounts.
if (currentParams.section !== nextParams.section) return false;
return defaultShouldRevalidate;
}
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>
);
}