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 ${user.email} (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("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 (

Settings

{/* Mobile: grid of sections */} {mobileView === "grid" && (
handleSectionChange(id, true)} />
)} {/* Mobile: back pill when viewing a section */} {mobileView === "section" && ( setMobileView("grid")} /> )}
handleSectionChange(id)} />
{activeSection === "profile" && ( )} {activeSection === "account" && ( )} {activeSection === "api" && } {activeSection === "privacy" && ( )}
); }