Refactor user profile to comprehensive settings page (#403)
* 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>
This commit is contained in:
parent
04a92ec7de
commit
22726550a6
21 changed files with 12337 additions and 200 deletions
|
|
@ -50,10 +50,10 @@ export function UserMenu({
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border my-1" />
|
||||||
<Link
|
<Link
|
||||||
to="/user-profile"
|
to="/settings"
|
||||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
Profile
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border my-1" />
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
79
app/components/user/settings/AccountSection.tsx
Normal file
79
app/components/user/settings/AccountSection.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
type LinkedAccount = {
|
||||||
|
id: string;
|
||||||
|
providerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
email: string;
|
||||||
|
linkedAccounts: LinkedAccount[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
credential: "Email & Password",
|
||||||
|
google: "Google",
|
||||||
|
discord: "Discord",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountSection({ email, linkedAccounts }: Props) {
|
||||||
|
const hasPassword = linkedAccounts.some((a) => a.providerId === "credential");
|
||||||
|
const oauthAccounts = linkedAccounts.filter((a) => a.providerId !== "credential");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Account</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your login email and connected providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">Email Address</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">Sign-in Methods</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hasPassword && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Email & Password</span>
|
||||||
|
<Badge variant="secondary">Active</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{oauthAccounts.map((account) => (
|
||||||
|
<div key={account.id} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">
|
||||||
|
{PROVIDER_LABELS[account.providerId] ?? account.providerId}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">Connected</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{linkedAccounts.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No sign-in methods found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPassword && (
|
||||||
|
<div className="rounded-lg border p-4 space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Password</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To change your password, use the forgot password flow.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/components/user/settings/ApiSection.tsx
Normal file
24
app/components/user/settings/ApiSection.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Key } from "lucide-react";
|
||||||
|
|
||||||
|
export function ApiSection() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">API Access</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage API keys for third-party integrations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Key className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium">Coming Soon</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
API access will be available in a future update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
app/components/user/settings/PrivacySection.tsx
Normal file
161
app/components/user/settings/PrivacySection.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { Form } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogCancel,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
userEmail: string;
|
||||||
|
dataRequestCooldownUntil: Date | null;
|
||||||
|
dataRequestSuccess?: boolean;
|
||||||
|
dataRequestError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivacySection({
|
||||||
|
userEmail,
|
||||||
|
dataRequestCooldownUntil,
|
||||||
|
dataRequestSuccess,
|
||||||
|
dataRequestError,
|
||||||
|
}: Props) {
|
||||||
|
const [confirmed, setConfirmed] = 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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Data & Privacy</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Request a copy of your data or permanently delete your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Request */}
|
||||||
|
<div className="rounded-lg border p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Request My Data</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Under GDPR and CCPA you have the right to a copy of all personal data
|
||||||
|
we hold about you. We will respond within 30 days to{" "}
|
||||||
|
<span className="font-medium">{userEmail}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dataRequestSuccess && (
|
||||||
|
<p className="text-sm text-green-500">
|
||||||
|
Request received. We'll email you within 30 days.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{dataRequestError && (
|
||||||
|
<p className="text-sm text-destructive">{dataRequestError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dataRequestNotes">
|
||||||
|
Additional notes{" "}
|
||||||
|
<span className="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="dataRequestNotes"
|
||||||
|
name="dataRequestNotes"
|
||||||
|
placeholder="e.g. specific data types you are interested in"
|
||||||
|
rows={3}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name="intent"
|
||||||
|
value="request-data"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Submit Data Request
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="rounded-lg border border-destructive/40 p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-destructive">Delete Account</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Permanently remove your personal information from Brackt. Your leagues and
|
||||||
|
draft history will be preserved but your name, email, and avatar will be
|
||||||
|
wiped. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Your email, display name, username, and avatar will be permanently
|
||||||
|
erased. Your leagues and draft history remain intact but will show{" "}
|
||||||
|
<span className="font-medium">Deleted User</span> in your place. You
|
||||||
|
will be signed out immediately.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="confirm-delete"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => setConfirmed(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="confirm-delete" className="text-sm leading-snug cursor-pointer">
|
||||||
|
I understand this is permanent and cannot be undone
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name="intent"
|
||||||
|
value="delete-account"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!confirmed}
|
||||||
|
>
|
||||||
|
Delete My Account
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
app/components/user/settings/ProfileSection.tsx
Normal file
105
app/components/user/settings/ProfileSection.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Form } from "react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
||||||
|
import { AvatarEditor } from "~/components/ui/AvatarEditor";
|
||||||
|
import type { User } from "~/models/user";
|
||||||
|
|
||||||
|
const VALID_TIMEZONES = new Set(
|
||||||
|
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: User;
|
||||||
|
isInActiveDraft: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileSection({ user, isInActiveDraft, success, error }: Props) {
|
||||||
|
const [timezone, setTimezone] = useState(user.timezone ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user.timezone) {
|
||||||
|
try {
|
||||||
|
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
if (detected && VALID_TIMEZONES.has(detected)) {
|
||||||
|
setTimezone(detected);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Intl not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user.timezone]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Profile</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your display name, username, avatar, and timezone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<p className="text-sm text-green-500">Profile updated successfully.</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="border-b pb-6">
|
||||||
|
<AvatarEditor
|
||||||
|
id={user.id}
|
||||||
|
flagConfig={user.flagConfig}
|
||||||
|
uploadedPhotoUrl={user.avatarType === "uploaded" ? user.customAvatarUrl : null}
|
||||||
|
uploadUrl="/api/upload-avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="displayName">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
defaultValue={user.displayName ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
defaultValue={user.username ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timezone">Timezone</Label>
|
||||||
|
<input type="hidden" name="timezone" value={timezone} />
|
||||||
|
<TimezoneSelect
|
||||||
|
value={timezone}
|
||||||
|
onChange={setTimezone}
|
||||||
|
disabled={isInActiveDraft}
|
||||||
|
/>
|
||||||
|
{isInActiveDraft ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Timezone cannot be changed while you are in an active draft.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used for overnight pick protection. We pre-filled your browser's detected timezone.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" name="intent" value="update-profile">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,13 @@ 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(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isCommissioner, hasCommissionerRecord } from "../commissioner";
|
import { isCommissioner, hasCommissionerRecord, removeAllCommissionersByUserId } from "../commissioner";
|
||||||
import { isUserAdmin } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
|
|
@ -113,3 +115,17 @@ describe("hasCommissionerRecord", () => {
|
||||||
expect(isUserAdmin).not.toHaveBeenCalled();
|
expect(isUserAdmin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("removeAllCommissionersByUserId", () => {
|
||||||
|
it("deletes from the commissioners table for the given user", async () => {
|
||||||
|
const whereFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const deleteFn = vi.fn().mockReturnValue({ where: whereFn });
|
||||||
|
vi.mocked(database).mockReturnValue({ delete: deleteFn } as never);
|
||||||
|
|
||||||
|
await removeAllCommissionersByUserId(USER_ID);
|
||||||
|
|
||||||
|
expect(deleteFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteFn).toHaveBeenCalledWith(schema.commissioners);
|
||||||
|
expect(whereFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
72
app/models/__tests__/user.test.ts
Normal file
72
app/models/__tests__/user.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { anonymizeUserAccount } from "../user";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||||
|
|
||||||
|
function makeTransactionDb() {
|
||||||
|
const whereFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const setFn = vi.fn().mockReturnValue({ where: whereFn });
|
||||||
|
const deleteFn = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||||
|
const updateFn = vi.fn().mockReturnValue({ set: setFn });
|
||||||
|
|
||||||
|
const tx = { delete: deleteFn, update: updateFn };
|
||||||
|
const db = {
|
||||||
|
transaction: vi.fn().mockImplementation((fn: (t: typeof tx) => Promise<void>) => fn(tx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { db, tx, deleteFn, updateFn, setFn, whereFn };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("anonymizeUserAccount", () => {
|
||||||
|
it("deletes sessions then accounts inside a transaction", async () => {
|
||||||
|
const { db, tx, deleteFn } = makeTransactionDb();
|
||||||
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
||||||
|
await anonymizeUserAccount(USER_ID);
|
||||||
|
|
||||||
|
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
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 () => {
|
||||||
|
const { db, setFn } = makeTransactionDb();
|
||||||
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
||||||
|
await anonymizeUserAccount(USER_ID);
|
||||||
|
|
||||||
|
const setCall = setFn.mock.calls[0][0];
|
||||||
|
expect(setCall.email).toMatch(/^deleted-[a-f0-9]{8}@deleted\.brackt\.com$/);
|
||||||
|
expect(setCall.displayName).toBeNull();
|
||||||
|
expect(setCall.username).toBeNull();
|
||||||
|
expect(setCall.imageUrl).toBeNull();
|
||||||
|
expect(setCall.customAvatarUrl).toBeNull();
|
||||||
|
expect(setCall.flagConfig).toBeNull();
|
||||||
|
expect(setCall.avatarType).toBe("flag");
|
||||||
|
expect(setCall.deletedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the short ID from the UUID", async () => {
|
||||||
|
const { db, setFn } = makeTransactionDb();
|
||||||
|
vi.mocked(database).mockReturnValue(db as never);
|
||||||
|
|
||||||
|
await anonymizeUserAccount(USER_ID);
|
||||||
|
|
||||||
|
const setCall = setFn.mock.calls[0][0];
|
||||||
|
// USER_ID without dashes: "a1b2c3d4e5f67890abcdef1234567890" → first 8 = "a1b2c3d4"
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
@ -97,3 +97,8 @@ export async function removeCommissionerByLeagueAndUser(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeAllCommissionersByUserId(userId: string): Promise<void> {
|
||||||
|
const db = database();
|
||||||
|
await db.delete(schema.commissioners).where(eq(schema.commissioners.userId, userId));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,27 @@ export async function updateUser(
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(id: string): Promise<void> {
|
export async function anonymizeUserAccount(id: string): Promise<void> {
|
||||||
const db = database();
|
const db = database();
|
||||||
await db.delete(schema.users).where(eq(schema.users.id, id));
|
const shortId = id.replace(/-/g, "").slice(0, 8);
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
||||||
|
await tx.delete(schema.accounts).where(eq(schema.accounts.userId, id));
|
||||||
|
await tx
|
||||||
|
.update(schema.users)
|
||||||
|
.set({
|
||||||
|
email: `deleted-${shortId}@deleted.brackt.com`,
|
||||||
|
displayName: null,
|
||||||
|
username: null,
|
||||||
|
imageUrl: null,
|
||||||
|
customAvatarUrl: null,
|
||||||
|
flagConfig: null,
|
||||||
|
avatarType: "flag",
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.users.id, id));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findAdmins(): Promise<User[]> {
|
export async function findAdmins(): Promise<User[]> {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,8 @@ export default [
|
||||||
route("check-email", "routes/check-email.tsx"),
|
route("check-email", "routes/check-email.tsx"),
|
||||||
route("forgot-password", "routes/forgot-password.tsx"),
|
route("forgot-password", "routes/forgot-password.tsx"),
|
||||||
route("reset-password", "routes/reset-password.tsx"),
|
route("reset-password", "routes/reset-password.tsx"),
|
||||||
route("user-profile", "routes/user-profile.tsx"),
|
route("settings", "routes/settings.tsx"),
|
||||||
|
route("user-profile", "routes/user-profile-redirect.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
route("support", "routes/support.tsx"),
|
route("support", "routes/support.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ describe('Team Management - Admin User List', () => {
|
||||||
avatarType: 'flag',
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
|
lastDataRequestAt: null,
|
||||||
|
deletedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
@ -211,6 +213,8 @@ describe('Team Management - Admin User List', () => {
|
||||||
avatarType: 'flag',
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
|
lastDataRequestAt: null,
|
||||||
|
deletedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
@ -248,6 +252,8 @@ describe('Team Management - Admin User List', () => {
|
||||||
avatarType: 'flag',
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
|
lastDataRequestAt: null,
|
||||||
|
deletedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
249
app/routes/settings.tsx
Normal file
249
app/routes/settings.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/routes/user-profile-redirect.tsx
Normal file
5
app/routes/user-profile-redirect.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
|
export function loader() {
|
||||||
|
return redirect("/settings", 301);
|
||||||
|
}
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
import { redirect, Form } from "react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { auth } from "~/lib/auth.server";
|
|
||||||
import { findUserById, updateUser, isUserInActiveDraft } from "~/models/user";
|
|
||||||
import type { Route } from "./+types/user-profile";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
|
||||||
import { AvatarEditor } from "~/components/ui/AvatarEditor";
|
|
||||||
import { parseFlagConfig } from "~/lib/flag-types";
|
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
|
||||||
import { logger } from "~/lib/logger";
|
|
||||||
|
|
||||||
const VALID_TIMEZONES = new Set(
|
|
||||||
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
|
|
||||||
);
|
|
||||||
|
|
||||||
export function meta(): Route.MetaDescriptors {
|
|
||||||
return [{ title: "Profile - Brackt" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
|
||||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
||||||
if (!session) {
|
|
||||||
return redirect("/login?redirectTo=/user-profile");
|
|
||||||
}
|
|
||||||
const user = await findUserById(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
return redirect("/");
|
|
||||||
}
|
|
||||||
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
|
|
||||||
return { user, isInActiveDraft };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
|
||||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
||||||
if (!session) {
|
|
||||||
return redirect("/login?redirectTo=/user-profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { error: "Flag config is required." };
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedConfig: unknown;
|
|
||||||
try {
|
|
||||||
parsedConfig = JSON.parse(rawConfig);
|
|
||||||
} catch {
|
|
||||||
return { error: "Invalid flag config." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const flagConfig = parseFlagConfig(parsedConfig);
|
|
||||||
if (!flagConfig) {
|
|
||||||
return { error: "Invalid flag config." };
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateUser(session.user.id, {
|
|
||||||
flagConfig,
|
|
||||||
avatarType: "flag",
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "remove-avatar-photo") {
|
|
||||||
await updateUser(session.user.id, {
|
|
||||||
customAvatarUrl: null,
|
|
||||||
avatarType: "flag",
|
|
||||||
});
|
|
||||||
if (user.customAvatarUrl) {
|
|
||||||
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((error) => {
|
|
||||||
logger.error("Failed to delete user avatar from Cloudinary:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) {
|
|
||||||
const { user, isInActiveDraft } = loaderData;
|
|
||||||
const [timezone, setTimezone] = useState(user.timezone ?? "");
|
|
||||||
|
|
||||||
// Auto-detect browser timezone if not already set
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user.timezone) {
|
|
||||||
try {
|
|
||||||
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
if (detected && VALID_TIMEZONES.has(detected)) {
|
|
||||||
setTimezone(detected);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Intl not available — leave blank
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [user.timezone]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-8 px-4 max-w-lg">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Your Profile</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{actionData?.success && (
|
|
||||||
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
|
|
||||||
)}
|
|
||||||
{actionData && "error" in actionData && actionData.error && (
|
|
||||||
<p className="text-sm text-destructive mb-4">{actionData.error}</p>
|
|
||||||
)}
|
|
||||||
<div className="mb-6 border-b pb-6">
|
|
||||||
<AvatarEditor
|
|
||||||
id={user.id}
|
|
||||||
flagConfig={user.flagConfig}
|
|
||||||
uploadedPhotoUrl={user.avatarType === "uploaded" ? user.customAvatarUrl : null}
|
|
||||||
uploadUrl="/api/upload-avatar"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Form method="post" className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="displayName">Display Name</Label>
|
|
||||||
<Input
|
|
||||||
id="displayName"
|
|
||||||
name="displayName"
|
|
||||||
defaultValue={user.displayName ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
defaultValue={user.username ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Email</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timezone">Timezone</Label>
|
|
||||||
{/* Hidden input carries the controlled value into the form submission */}
|
|
||||||
<input type="hidden" name="timezone" value={timezone} />
|
|
||||||
<TimezoneSelect
|
|
||||||
value={timezone}
|
|
||||||
onChange={setTimezone}
|
|
||||||
disabled={isInActiveDraft}
|
|
||||||
/>
|
|
||||||
{isInActiveDraft ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Timezone cannot be changed while you are in an active draft.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Used for overnight pick protection. We pre-filled your browser's detected timezone.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full">Save Changes</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -14,6 +14,8 @@ 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"),
|
||||||
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/0100_mighty_weapon_omega.sql
Normal file
1
drizzle/0100_mighty_weapon_omega.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "users" ADD COLUMN "deleted_at" timestamp;
|
||||||
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;
|
||||||
5775
drizzle/meta/0100_snapshot.json
Normal file
5775
drizzle/meta/0100_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
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
|
|
@ -701,6 +701,20 @@
|
||||||
"when": 1778341807236,
|
"when": 1778341807236,
|
||||||
"tag": "0099_peaceful_hannibal_king",
|
"tag": "0099_peaceful_hannibal_king",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 100,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778440988519,
|
||||||
|
"tag": "0100_mighty_weapon_omega",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 101,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778445560663,
|
||||||
|
"tag": "0101_simple_black_bolt",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue