import { redirect } from "react-router"; import { useState } from "react"; import { Bell, Key, Lock, Shield, User } from "lucide-react"; import { auth } from "~/lib/auth.server"; import { findUserById, isUserInActiveDraft, updateUser, anonymizeUserAccount } 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: "Discord pings" }, { 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 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: "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 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 { 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; const timezone = formData.get("timezone") as string | null; const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined; await updateUser(session.user.id, { username: username || undefined, timezone: timezoneValue, }); 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 === "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 ${escapeHtml(user.email)} (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 }: Route.ComponentProps) { const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData; const [activeSection, setActiveSection] = useState("profile"); const [mobileView, setMobileView] = useState<"grid" | "section">("grid"); const handleSectionChange = (id: string, mobile = false) => { setActiveSection(id as SectionId); if (mobile) setMobileView("section"); }; 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 (

Settings

{mobileView === "grid" && (
handleSectionChange(id, true)} />
)} {mobileView === "section" && ( setMobileView("grid")} /> )}
handleSectionChange(id)} />
{activeSection === "profile" && ( )} {activeSection === "account" && ( )} {activeSection === "notifications" && ( a.providerId === "discord")} success={ad?.intent === "update-discord-ping" && "success" in ad} error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined} onNavigateToAccount={() => handleSectionChange("account")} /> )} {activeSection === "api" && } {activeSection === "privacy" && ( )}
); }