* Add account deletion and multi-section settings page - Rename /user-profile → /settings with 301 redirect from old URL - Add multi-section settings nav (Profile, Account, API placeholder, Data & Privacy) reusing existing SettingsDesktopNav/SettingsMobileGridNav components - Implement account deletion via anonymization: wipes all PII from users row, releases team ownerships, removes commissioner records, deletes sessions/accounts - Add data export request form that emails privacy@brackt.com via Resend - Add deletedAt timestamp column to users table (migration 0100) - Add anonymizeUserAccount() to user model - Add removeAllCommissionersByUserId() to commissioner model - Tests for both new model functions - Update UserMenu "Profile" link → "Settings" at /settings https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X * Address code review feedback on settings/account deletion 1. Wrap anonymizeUserAccount in a DB transaction so partial failures (e.g. session delete succeeds but user update fails) can't leave accounts in an inconsistent state 2. Escape user email and notes with escapeHtml() before interpolating into the data request email body 3. Reset AlertDialog confirmed state when dialog is dismissed via backdrop click or Escape key (onOpenChange handler) 4. Extract accounts DB query to app/models/account.ts (findLinkedAccountsByUserId) to comply with the "always query through app/models/" convention 5. Replace dynamic imports() in action handlers with static top-level imports 6. Remove the unused hard-delete deleteUser() function 7. Add lastDataRequestAt timestamp to users (migration 0101) and enforce a 30-day server-side cooldown on data export requests 8. Replace fragile actionData type casts with a proper ActionData discriminated union; narrowing now works without `as` assertions 9. Strengthen tests: verify which schema tables are passed to delete() and that operations run inside the transaction https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X * Fix no-non-null-assertion lint error in PrivacySection Replace non-null assertion with optional chaining on dataRequestCooldownUntil to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X --------- Co-authored-by: Claude <noreply@anthropic.com>
249 lines
9.8 KiB
TypeScript
249 lines
9.8 KiB
TypeScript
import { redirect } from "react-router";
|
|
import { useState } from "react";
|
|
import { 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 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;
|
|
|
|
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: "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<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 displayName = formData.get("displayName") as string;
|
|
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, {
|
|
displayName: displayName || undefined,
|
|
username: username || undefined,
|
|
timezone: timezoneValue,
|
|
});
|
|
return { intent: "update-profile", 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 }: Route.ComponentProps) {
|
|
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
|
|
const [activeSection, setActiveSection] = useState<SectionId>("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 (
|
|
<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}
|
|
onSectionChange={(id) => handleSectionChange(id, true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mobileView === "section" && (
|
|
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
|
)}
|
|
|
|
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
|
|
<SettingsDesktopNav
|
|
sections={SECTIONS}
|
|
activeSection={activeSection}
|
|
onSectionChange={(id) => handleSectionChange(id)}
|
|
/>
|
|
|
|
<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 === "api" && <ApiSection />}
|
|
{activeSection === "privacy" && (
|
|
<PrivacySection
|
|
userEmail={user.email}
|
|
dataRequestCooldownUntil={dataRequestCooldownUntil}
|
|
dataRequestSuccess={dataRequestSuccess}
|
|
dataRequestError={dataRequestError}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|