- 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
242 lines
9 KiB
TypeScript
242 lines
9 KiB
TypeScript
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 <strong>${user.email}</strong> (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<SectionId>("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 (
|
|
<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>
|
|
|
|
{/* Mobile: grid of sections */}
|
|
{mobileView === "grid" && (
|
|
<div className="lg:hidden">
|
|
<SettingsMobileGridNav
|
|
sections={SECTIONS}
|
|
onSectionChange={(id) => handleSectionChange(id, true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile: back pill when viewing a section */}
|
|
{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}
|
|
dataRequestSuccess={dataRequestSuccess}
|
|
dataRequestError={dataRequestError}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|