brackt/app/routes/settings.tsx
Chris Parsons 6f70a19b73
Add admin user management interface with search and username editing (#431)
* Add admin users management page with pagination, search, and inline username editing

https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs

* Fix five issues from admin users page code review

- admin.users: use useEffect to close inline edit on success so validation
  errors are not silently discarded when the save button fires onClick
- root: add /admin to ONBOARDING_EXEMPT so admin users without a username
  are not redirect-looped away from admin pages
- settings: skip username validation when the username field is empty so
  updating timezone alone does not break for accounts without a username
- models/user: exclude soft-deleted users from findUsersPaginated
- admin.users: remove unused hasMore from loader return; simplify header div

https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs

* Fix lint: rename shadowed variables in user model

- findUserByUsername: use imported sql directly instead of destructuring
  it from the callback (shadowed the top-level import)
- findUsersPaginated: rename orderBy callback param from users to t
  (shadowed the outer users result variable)

https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 12:07:51 -07:00

287 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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: "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<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 330 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 === "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 === "notifications" && (
<NotificationsSection
discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => 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" && <ApiSection />}
{activeSection === "privacy" && (
<PrivacySection
userEmail={user.email}
dataRequestCooldownUntil={dataRequestCooldownUntil}
dataRequestSuccess={dataRequestSuccess}
dataRequestError={dataRequestError}
/>
)}
</main>
</div>
</div>
);
}