310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
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 3–30 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>
|
||
);
|
||
}
|