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
This commit is contained in:
parent
388071fef8
commit
f462d0f0c7
11 changed files with 5945 additions and 121 deletions
|
|
@ -16,14 +16,27 @@ import {
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
dataRequestCooldownUntil: Date | null;
|
||||||
dataRequestSuccess?: boolean;
|
dataRequestSuccess?: boolean;
|
||||||
dataRequestError?: string;
|
dataRequestError?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PrivacySection({ userEmail, dataRequestSuccess, dataRequestError }: Props) {
|
export function PrivacySection({
|
||||||
|
userEmail,
|
||||||
|
dataRequestCooldownUntil,
|
||||||
|
dataRequestSuccess,
|
||||||
|
dataRequestError,
|
||||||
|
}: Props) {
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDialogOpenChange = (open: boolean) => {
|
||||||
|
setDialogOpen(open);
|
||||||
|
if (!open) setConfirmed(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inCooldown = dataRequestCooldownUntil != null && dataRequestCooldownUntil > new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -53,7 +66,14 @@ export function PrivacySection({ userEmail, dataRequestSuccess, dataRequestError
|
||||||
<p className="text-sm text-destructive">{dataRequestError}</p>
|
<p className="text-sm text-destructive">{dataRequestError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!dataRequestSuccess && (
|
{inCooldown && !dataRequestSuccess && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your data request is being processed. You can submit another request
|
||||||
|
after {dataRequestCooldownUntil!.toLocaleDateString()}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!dataRequestSuccess && !inCooldown && (
|
||||||
<Form method="post" className="space-y-3">
|
<Form method="post" className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="dataRequestNotes">
|
<Label htmlFor="dataRequestNotes">
|
||||||
|
|
@ -91,7 +111,7 @@ export function PrivacySection({ userEmail, dataRequestSuccess, dataRequestError
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<AlertDialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" size="sm">
|
<Button variant="destructive" size="sm">
|
||||||
Delete Account
|
Delete Account
|
||||||
|
|
@ -120,9 +140,7 @@ export function PrivacySection({ userEmail, dataRequestSuccess, dataRequestError
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => setConfirmed(false)}>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ vi.mock("~/models/user", () => ({
|
||||||
isUserAdmin: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: vi.fn(),
|
database: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -115,7 +117,7 @@ describe("hasCommissionerRecord", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removeAllCommissionersByUserId", () => {
|
describe("removeAllCommissionersByUserId", () => {
|
||||||
it("deletes all commissioner rows for a given user", async () => {
|
it("deletes from the commissioners table for the given user", async () => {
|
||||||
const whereFn = vi.fn().mockResolvedValue(undefined);
|
const whereFn = vi.fn().mockResolvedValue(undefined);
|
||||||
const deleteFn = vi.fn().mockReturnValue({ where: whereFn });
|
const deleteFn = vi.fn().mockReturnValue({ where: whereFn });
|
||||||
vi.mocked(database).mockReturnValue({ delete: deleteFn } as never);
|
vi.mocked(database).mockReturnValue({ delete: deleteFn } as never);
|
||||||
|
|
@ -123,6 +125,7 @@ describe("removeAllCommissionersByUserId", () => {
|
||||||
await removeAllCommissionersByUserId(USER_ID);
|
await removeAllCommissionersByUserId(USER_ID);
|
||||||
|
|
||||||
expect(deleteFn).toHaveBeenCalledTimes(1);
|
expect(deleteFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteFn).toHaveBeenCalledWith(schema.commissioners);
|
||||||
expect(whereFn).toHaveBeenCalledTimes(1);
|
expect(whereFn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,35 +6,22 @@ vi.mock("~/database/context", () => ({
|
||||||
|
|
||||||
import { anonymizeUserAccount } from "../user";
|
import { anonymizeUserAccount } from "../user";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||||
|
|
||||||
function makeDeleteChain() {
|
function makeTransactionDb() {
|
||||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeUpdateChain() {
|
|
||||||
const whereFn = vi.fn().mockResolvedValue(undefined);
|
const whereFn = vi.fn().mockResolvedValue(undefined);
|
||||||
const setFn = vi.fn().mockReturnValue({ where: whereFn });
|
const setFn = vi.fn().mockReturnValue({ where: whereFn });
|
||||||
return { set: setFn, _where: whereFn };
|
const deleteFn = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||||
}
|
const updateFn = vi.fn().mockReturnValue({ set: setFn });
|
||||||
|
|
||||||
function makeAnonymizeDb() {
|
const tx = { delete: deleteFn, update: updateFn };
|
||||||
const deleteChains: ReturnType<typeof makeDeleteChain>[] = [];
|
const db = {
|
||||||
const updateChain = makeUpdateChain();
|
transaction: vi.fn().mockImplementation((fn: (t: typeof tx) => Promise<void>) => fn(tx)),
|
||||||
|
|
||||||
const deleteFn = vi.fn().mockImplementation(() => {
|
|
||||||
const chain = makeDeleteChain();
|
|
||||||
deleteChains.push(chain);
|
|
||||||
return chain;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
db: { delete: deleteFn, update: vi.fn().mockReturnValue(updateChain) },
|
|
||||||
deleteChains,
|
|
||||||
updateChain,
|
|
||||||
deleteFn,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { db, tx, deleteFn, updateFn, setFn, whereFn };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -42,23 +29,26 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("anonymizeUserAccount", () => {
|
describe("anonymizeUserAccount", () => {
|
||||||
it("deletes sessions and accounts for the user", async () => {
|
it("deletes sessions then accounts inside a transaction", async () => {
|
||||||
const { db, deleteFn } = makeAnonymizeDb();
|
const { db, tx, deleteFn } = makeTransactionDb();
|
||||||
vi.mocked(database).mockReturnValue(db as never);
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
||||||
await anonymizeUserAccount(USER_ID);
|
await anonymizeUserAccount(USER_ID);
|
||||||
|
|
||||||
|
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(deleteFn).toHaveBeenCalledTimes(2);
|
expect(deleteFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deleteFn).toHaveBeenNthCalledWith(1, schema.sessions);
|
||||||
|
expect(deleteFn).toHaveBeenNthCalledWith(2, schema.accounts);
|
||||||
|
expect(tx.update).toHaveBeenCalledWith(schema.users);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls update on the users table with anonymized fields", async () => {
|
it("calls update on the users table with anonymized fields", async () => {
|
||||||
const { db, updateChain } = makeAnonymizeDb();
|
const { db, setFn } = makeTransactionDb();
|
||||||
vi.mocked(database).mockReturnValue(db as never);
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
||||||
await anonymizeUserAccount(USER_ID);
|
await anonymizeUserAccount(USER_ID);
|
||||||
|
|
||||||
expect(db.update).toHaveBeenCalledTimes(1);
|
const setCall = setFn.mock.calls[0][0];
|
||||||
const setCall = updateChain.set.mock.calls[0][0];
|
|
||||||
expect(setCall.email).toMatch(/^deleted-[a-f0-9]{8}@deleted\.brackt\.com$/);
|
expect(setCall.email).toMatch(/^deleted-[a-f0-9]{8}@deleted\.brackt\.com$/);
|
||||||
expect(setCall.displayName).toBeNull();
|
expect(setCall.displayName).toBeNull();
|
||||||
expect(setCall.username).toBeNull();
|
expect(setCall.username).toBeNull();
|
||||||
|
|
@ -70,13 +60,13 @@ describe("anonymizeUserAccount", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives the short ID from the UUID", async () => {
|
it("derives the short ID from the UUID", async () => {
|
||||||
const { db, updateChain } = makeAnonymizeDb();
|
const { db, setFn } = makeTransactionDb();
|
||||||
vi.mocked(database).mockReturnValue(db as never);
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
||||||
await anonymizeUserAccount(USER_ID);
|
await anonymizeUserAccount(USER_ID);
|
||||||
|
|
||||||
const setCall = updateChain.set.mock.calls[0][0];
|
const setCall = setFn.mock.calls[0][0];
|
||||||
// USER_ID without dashes starts with "a1b2c3d4" → shortId = "a1b2c3d4"
|
// USER_ID without dashes: "a1b2c3d4e5f67890abcdef1234567890" → first 8 = "a1b2c3d4"
|
||||||
expect(setCall.email).toBe("deleted-a1b2c3d4@deleted.brackt.com");
|
expect(setCall.email).toBe("deleted-a1b2c3d4@deleted.brackt.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
16
app/models/account.ts
Normal file
16
app/models/account.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
export type LinkedAccount = {
|
||||||
|
id: string;
|
||||||
|
providerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function findLinkedAccountsByUserId(userId: string): Promise<LinkedAccount[]> {
|
||||||
|
const db = database();
|
||||||
|
return db
|
||||||
|
.select({ id: schema.accounts.id, providerId: schema.accounts.providerId })
|
||||||
|
.from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.userId, userId));
|
||||||
|
}
|
||||||
|
|
@ -51,30 +51,27 @@ export async function updateUser(
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(id: string): Promise<void> {
|
|
||||||
const db = database();
|
|
||||||
await db.delete(schema.users).where(eq(schema.users.id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function anonymizeUserAccount(id: string): Promise<void> {
|
export async function anonymizeUserAccount(id: string): Promise<void> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const shortId = id.replace(/-/g, "").slice(0, 8);
|
const shortId = id.replace(/-/g, "").slice(0, 8);
|
||||||
await db.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
await db.transaction(async (tx) => {
|
||||||
await db.delete(schema.accounts).where(eq(schema.accounts.userId, id));
|
await tx.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
||||||
await db
|
await tx.delete(schema.accounts).where(eq(schema.accounts.userId, id));
|
||||||
.update(schema.users)
|
await tx
|
||||||
.set({
|
.update(schema.users)
|
||||||
email: `deleted-${shortId}@deleted.brackt.com`,
|
.set({
|
||||||
displayName: null,
|
email: `deleted-${shortId}@deleted.brackt.com`,
|
||||||
username: null,
|
displayName: null,
|
||||||
imageUrl: null,
|
username: null,
|
||||||
customAvatarUrl: null,
|
imageUrl: null,
|
||||||
flagConfig: null,
|
customAvatarUrl: null,
|
||||||
avatarType: "flag",
|
flagConfig: null,
|
||||||
deletedAt: new Date(),
|
avatarType: "flag",
|
||||||
updatedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
})
|
updatedAt: new Date(),
|
||||||
.where(eq(schema.users.id, id));
|
})
|
||||||
|
.where(eq(schema.users.id, id));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findAdmins(): Promise<User[]> {
|
export async function findAdmins(): Promise<User[]> {
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ describe('Team Management - Admin User List', () => {
|
||||||
avatarType: 'flag',
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
|
lastDataRequestAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -212,6 +213,7 @@ describe('Team Management - Admin User List', () => {
|
||||||
avatarType: 'flag',
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
|
lastDataRequestAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -250,7 +252,8 @@ describe('Team Management - Admin User List', () => {
|
||||||
avatarType: 'flag',
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
deletedAt: null,
|
lastDataRequestAt: null,
|
||||||
|
deletedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
import { redirect } from "react-router";
|
import { redirect } from "react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { Key, Lock, Shield, User } from "lucide-react";
|
import { Key, Lock, Shield, User } from "lucide-react";
|
||||||
import { auth } from "~/lib/auth.server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { findUserById, isUserInActiveDraft, updateUser, anonymizeUserAccount } from "~/models/user";
|
||||||
import * as schema from "~/database/schema";
|
import { findLinkedAccountsByUserId } from "~/models/account";
|
||||||
import {
|
|
||||||
findUserById,
|
|
||||||
isUserInActiveDraft,
|
|
||||||
anonymizeUserAccount,
|
|
||||||
} from "~/models/user";
|
|
||||||
import { findTeamsByOwnerId, removeTeamOwner } from "~/models/team";
|
import { findTeamsByOwnerId, removeTeamOwner } from "~/models/team";
|
||||||
import { removeAllCommissionersByUserId } from "~/models/commissioner";
|
import { removeAllCommissionersByUserId } from "~/models/commissioner";
|
||||||
import { parseFlagConfig } from "~/lib/flag-types";
|
import { parseFlagConfig } from "~/lib/flag-types";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||||
import { sendEmail, wrapInEmailTemplate, emailParagraph } from "~/lib/email.server";
|
import { sendEmail, wrapInEmailTemplate, emailParagraph, escapeHtml } from "~/lib/email.server";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
||||||
import {
|
import {
|
||||||
SettingsDesktopNav,
|
SettingsDesktopNav,
|
||||||
SettingsMobileGridNav,
|
SettingsMobileGridNav,
|
||||||
|
|
@ -37,6 +32,20 @@ const SECTIONS: readonly SettingsGridSection[] = [
|
||||||
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
|
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
|
||||||
] as const;
|
] 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 {
|
export function meta(): Route.MetaDescriptors {
|
||||||
return [{ title: "Settings - Brackt" }];
|
return [{ title: "Settings - Brackt" }];
|
||||||
}
|
}
|
||||||
|
|
@ -50,18 +59,19 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
|
const [isInActiveDraft, linkedAccounts] = await Promise.all([
|
||||||
|
isUserInActiveDraft(session.user.id),
|
||||||
|
findLinkedAccountsByUserId(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
const db = database();
|
const dataRequestCooldownUntil = user.lastDataRequestAt
|
||||||
const linkedAccounts = await db
|
? new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS)
|
||||||
.select({ id: schema.accounts.id, providerId: schema.accounts.providerId })
|
: null;
|
||||||
.from(schema.accounts)
|
|
||||||
.where(eq(schema.accounts.userId, session.user.id));
|
|
||||||
|
|
||||||
return { user, isInActiveDraft, linkedAccounts };
|
return { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs): Promise<ActionData | Response> {
|
||||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect("/login?redirectTo=/settings");
|
return redirect("/login?redirectTo=/settings");
|
||||||
|
|
@ -78,60 +88,60 @@ export async function action(args: Route.ActionArgs) {
|
||||||
if (intent === "update-avatar-flag") {
|
if (intent === "update-avatar-flag") {
|
||||||
const rawConfig = formData.get("flagConfig");
|
const rawConfig = formData.get("flagConfig");
|
||||||
if (typeof rawConfig !== "string") {
|
if (typeof rawConfig !== "string") {
|
||||||
return { intent, error: "Flag config is required." };
|
return { intent: "update-avatar-flag", error: "Flag config is required." };
|
||||||
}
|
}
|
||||||
let parsedConfig: unknown;
|
let parsedConfig: unknown;
|
||||||
try {
|
try {
|
||||||
parsedConfig = JSON.parse(rawConfig);
|
parsedConfig = JSON.parse(rawConfig);
|
||||||
} catch {
|
} catch {
|
||||||
return { intent, error: "Invalid flag config." };
|
return { intent: "update-avatar-flag", error: "Invalid flag config." };
|
||||||
}
|
}
|
||||||
const flagConfig = parseFlagConfig(parsedConfig);
|
const flagConfig = parseFlagConfig(parsedConfig);
|
||||||
if (!flagConfig) {
|
if (!flagConfig) {
|
||||||
return { intent, error: "Invalid flag config." };
|
return { intent: "update-avatar-flag", error: "Invalid flag config." };
|
||||||
}
|
}
|
||||||
await import("~/models/user").then(({ updateUser }) =>
|
await updateUser(session.user.id, { flagConfig, avatarType: "flag" });
|
||||||
updateUser(session.user.id, { flagConfig, avatarType: "flag" })
|
return { intent: "update-avatar-flag", success: true };
|
||||||
);
|
|
||||||
return { intent, success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "remove-avatar-photo") {
|
if (intent === "remove-avatar-photo") {
|
||||||
await import("~/models/user").then(({ updateUser }) =>
|
await updateUser(session.user.id, { customAvatarUrl: null, avatarType: "flag" });
|
||||||
updateUser(session.user.id, { customAvatarUrl: null, avatarType: "flag" })
|
|
||||||
);
|
|
||||||
if (user.customAvatarUrl) {
|
if (user.customAvatarUrl) {
|
||||||
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((err) => {
|
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((err) => {
|
||||||
logger.error("Failed to delete user avatar from Cloudinary:", err);
|
logger.error("Failed to delete user avatar from Cloudinary:", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { intent, success: true };
|
return { intent: "remove-avatar-photo", success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "update-profile") {
|
if (intent === "update-profile") {
|
||||||
const displayName = formData.get("displayName") as string;
|
const displayName = formData.get("displayName") as string;
|
||||||
const username = formData.get("username") as string | null;
|
const username = formData.get("username") as string | null;
|
||||||
const timezone = formData.get("timezone") as string | null;
|
const timezone = formData.get("timezone") as string | null;
|
||||||
|
const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
|
||||||
const { TIMEZONE_OPTIONS } = await import("~/components/league/TimezoneSelect");
|
await updateUser(session.user.id, {
|
||||||
const validTimezones = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value)));
|
displayName: displayName || undefined,
|
||||||
const timezoneValue = timezone && validTimezones.has(timezone) ? timezone : undefined;
|
username: username || undefined,
|
||||||
|
timezone: timezoneValue,
|
||||||
await import("~/models/user").then(({ updateUser }) =>
|
});
|
||||||
updateUser(session.user.id, {
|
return { intent: "update-profile", success: true };
|
||||||
displayName: displayName || undefined,
|
|
||||||
username: username || undefined,
|
|
||||||
timezone: timezoneValue,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return { intent, success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "request-data") {
|
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 notes = (formData.get("dataRequestNotes") as string | null) ?? "";
|
||||||
const html = wrapInEmailTemplate(`
|
const html = wrapInEmailTemplate(`
|
||||||
${emailParagraph(`A data export request has been submitted by <strong>${user.email}</strong> (user ID: ${user.id}).`)}
|
${emailParagraph(`A data export request has been submitted by <strong>${escapeHtml(user.email)}</strong> (user ID: ${escapeHtml(user.id)}).`)}
|
||||||
${notes ? emailParagraph(`Notes: ${notes}`) : ""}
|
${notes ? emailParagraph(`Notes: ${escapeHtml(notes)}`) : ""}
|
||||||
${emailParagraph("Please fulfill this request within 30 days per GDPR/CCPA requirements.")}
|
${emailParagraph("Please fulfill this request within 30 days per GDPR/CCPA requirements.")}
|
||||||
`);
|
`);
|
||||||
const { error } = await sendEmail({
|
const { error } = await sendEmail({
|
||||||
|
|
@ -142,34 +152,31 @@ export async function action(args: Route.ActionArgs) {
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.error("Failed to send data request email:", 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: "request-data", dataRequestError: "Failed to submit request. Please email privacy@brackt.com directly." };
|
||||||
}
|
}
|
||||||
return { intent, dataRequestSuccess: true };
|
await updateUser(session.user.id, { lastDataRequestAt: new Date() });
|
||||||
|
return { intent: "request-data", dataRequestSuccess: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "delete-account") {
|
if (intent === "delete-account") {
|
||||||
const customAvatarUrl = user.customAvatarUrl;
|
const customAvatarUrl = user.customAvatarUrl;
|
||||||
|
|
||||||
const ownedTeams = await findTeamsByOwnerId(session.user.id);
|
const ownedTeams = await findTeamsByOwnerId(session.user.id);
|
||||||
await Promise.all(ownedTeams.map((team) => removeTeamOwner(team.id)));
|
await Promise.all(ownedTeams.map((team) => removeTeamOwner(team.id)));
|
||||||
|
|
||||||
await removeAllCommissionersByUserId(session.user.id);
|
await removeAllCommissionersByUserId(session.user.id);
|
||||||
await anonymizeUserAccount(session.user.id);
|
await anonymizeUserAccount(session.user.id);
|
||||||
|
|
||||||
if (customAvatarUrl) {
|
if (customAvatarUrl) {
|
||||||
deleteCloudinaryImageByUrl(customAvatarUrl).catch((err) => {
|
deleteCloudinaryImageByUrl(customAvatarUrl).catch((err) => {
|
||||||
logger.error("Failed to delete user avatar from Cloudinary after account deletion:", err);
|
logger.error("Failed to delete user avatar from Cloudinary after account deletion:", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { intent, error: "Unknown action." };
|
return { intent: intent as string, error: "Unknown action." };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
|
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { user, isInActiveDraft, linkedAccounts } = loaderData;
|
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
|
||||||
const [activeSection, setActiveSection] = useState<SectionId>("profile");
|
const [activeSection, setActiveSection] = useState<SectionId>("profile");
|
||||||
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
|
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
|
||||||
|
|
||||||
|
|
@ -178,11 +185,15 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
||||||
if (mobile) setMobileView("section");
|
if (mobile) setMobileView("section");
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileSuccess = actionData?.intent === "update-profile" && "success" in actionData && actionData.success === true;
|
const ad = actionData as ActionData | undefined;
|
||||||
const profileError = actionData?.intent === "update-profile" && "error" in actionData ? (actionData as { error: string }).error : undefined;
|
const profileSuccess = ad?.intent === "update-profile" && "success" in ad;
|
||||||
const avatarSuccess = (actionData?.intent === "update-avatar-flag" || actionData?.intent === "remove-avatar-photo") && "success" in actionData;
|
const profileError = ad?.intent === "update-profile" && "error" in ad ? ad.error : undefined;
|
||||||
const dataRequestSuccess = actionData?.intent === "request-data" && "dataRequestSuccess" in actionData ? true : undefined;
|
const avatarSuccess =
|
||||||
const dataRequestError = actionData?.intent === "request-data" && "dataRequestError" in actionData ? (actionData as { dataRequestError: string }).dataRequestError : undefined;
|
(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 (
|
return (
|
||||||
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
|
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
|
||||||
|
|
@ -190,7 +201,6 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: grid of sections */}
|
|
||||||
{mobileView === "grid" && (
|
{mobileView === "grid" && (
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<SettingsMobileGridNav
|
<SettingsMobileGridNav
|
||||||
|
|
@ -200,7 +210,6 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile: back pill when viewing a section */}
|
|
||||||
{mobileView === "section" && (
|
{mobileView === "section" && (
|
||||||
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -222,15 +231,13 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeSection === "account" && (
|
{activeSection === "account" && (
|
||||||
<AccountSection
|
<AccountSection email={user.email} linkedAccounts={linkedAccounts} />
|
||||||
email={user.email}
|
|
||||||
linkedAccounts={linkedAccounts}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{activeSection === "api" && <ApiSection />}
|
{activeSection === "api" && <ApiSection />}
|
||||||
{activeSection === "privacy" && (
|
{activeSection === "privacy" && (
|
||||||
<PrivacySection
|
<PrivacySection
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
|
dataRequestCooldownUntil={dataRequestCooldownUntil}
|
||||||
dataRequestSuccess={dataRequestSuccess}
|
dataRequestSuccess={dataRequestSuccess}
|
||||||
dataRequestError={dataRequestError}
|
dataRequestError={dataRequestError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const users = pgTable("users", {
|
||||||
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
|
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
|
||||||
isAdmin: boolean("is_admin").notNull().default(false),
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
||||||
|
lastDataRequestAt: timestamp("last_data_request_at"),
|
||||||
deletedAt: timestamp("deleted_at"),
|
deletedAt: timestamp("deleted_at"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
|
|
||||||
1
drizzle/0101_simple_black_bolt.sql
Normal file
1
drizzle/0101_simple_black_bolt.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "users" ADD COLUMN "last_data_request_at" timestamp;
|
||||||
5781
drizzle/meta/0101_snapshot.json
Normal file
5781
drizzle/meta/0101_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -708,6 +708,13 @@
|
||||||
"when": 1778440988519,
|
"when": 1778440988519,
|
||||||
"tag": "0100_mighty_weapon_omega",
|
"tag": "0100_mighty_weapon_omega",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 101,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778445560663,
|
||||||
|
"tag": "0101_simple_black_bolt",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue