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
This commit is contained in:
parent
04a92ec7de
commit
388071fef8
18 changed files with 6511 additions and 198 deletions
|
|
@ -50,10 +50,10 @@ export function UserMenu({
|
|||
</div>
|
||||
<div className="border-t border-border my-1" />
|
||||
<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"
|
||||
>
|
||||
Profile
|
||||
Settings
|
||||
</Link>
|
||||
<div className="border-t border-border my-1" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
143
app/components/user/settings/PrivacySection.tsx
Normal file
143
app/components/user/settings/PrivacySection.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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;
|
||||
dataRequestSuccess?: boolean;
|
||||
dataRequestError?: string;
|
||||
};
|
||||
|
||||
export function PrivacySection({ userEmail, dataRequestSuccess, dataRequestError }: Props) {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{!dataRequestSuccess && (
|
||||
<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={setDialogOpen}>
|
||||
<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 onClick={() => setConfirmed(false)}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ vi.mock("~/database/context", () => ({
|
|||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
import { isCommissioner, hasCommissionerRecord } from "../commissioner";
|
||||
import { isCommissioner, hasCommissionerRecord, removeAllCommissionersByUserId } from "../commissioner";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
|
|
@ -113,3 +113,16 @@ describe("hasCommissionerRecord", () => {
|
|||
expect(isUserAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllCommissionersByUserId", () => {
|
||||
it("deletes all commissioner rows for a 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(whereFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
82
app/models/__tests__/user.test.ts
Normal file
82
app/models/__tests__/user.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
import { anonymizeUserAccount } from "../user";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
function makeDeleteChain() {
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
function makeUpdateChain() {
|
||||
const whereFn = vi.fn().mockResolvedValue(undefined);
|
||||
const setFn = vi.fn().mockReturnValue({ where: whereFn });
|
||||
return { set: setFn, _where: whereFn };
|
||||
}
|
||||
|
||||
function makeAnonymizeDb() {
|
||||
const deleteChains: ReturnType<typeof makeDeleteChain>[] = [];
|
||||
const updateChain = makeUpdateChain();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("anonymizeUserAccount", () => {
|
||||
it("deletes sessions and accounts for the user", async () => {
|
||||
const { db, deleteFn } = makeAnonymizeDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await anonymizeUserAccount(USER_ID);
|
||||
|
||||
expect(deleteFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("calls update on the users table with anonymized fields", async () => {
|
||||
const { db, updateChain } = makeAnonymizeDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await anonymizeUserAccount(USER_ID);
|
||||
|
||||
expect(db.update).toHaveBeenCalledTimes(1);
|
||||
const setCall = updateChain.set.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, updateChain } = makeAnonymizeDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await anonymizeUserAccount(USER_ID);
|
||||
|
||||
const setCall = updateChain.set.mock.calls[0][0];
|
||||
// USER_ID without dashes starts with "a1b2c3d4" → shortId = "a1b2c3d4"
|
||||
expect(setCall.email).toBe("deleted-a1b2c3d4@deleted.brackt.com");
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,27 @@ export async function deleteUser(id: string): Promise<void> {
|
|||
await db.delete(schema.users).where(eq(schema.users.id, id));
|
||||
}
|
||||
|
||||
export async function anonymizeUserAccount(id: string): Promise<void> {
|
||||
const db = database();
|
||||
const shortId = id.replace(/-/g, "").slice(0, 8);
|
||||
await db.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.userId, id));
|
||||
await db
|
||||
.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[]> {
|
||||
const db = database();
|
||||
return await db.query.users.findMany({
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ export default [
|
|||
route("check-email", "routes/check-email.tsx"),
|
||||
route("forgot-password", "routes/forgot-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("rules", "routes/rules.tsx"),
|
||||
route("support", "routes/support.tsx"),
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ describe('Team Management - Admin User List', () => {
|
|||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
@ -211,6 +212,7 @@ describe('Team Management - Admin User List', () => {
|
|||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
@ -248,6 +250,7 @@ describe('Team Management - Admin User List', () => {
|
|||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
|
|||
242
app/routes/settings.tsx
Normal file
242
app/routes/settings.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { redirect } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Key, Lock, Shield, User } from "lucide-react";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import {
|
||||
findUserById,
|
||||
isUserInActiveDraft,
|
||||
anonymizeUserAccount,
|
||||
} from "~/models/user";
|
||||
import { findTeamsByOwnerId, removeTeamOwner } from "~/models/team";
|
||||
import { removeAllCommissionersByUserId } from "~/models/commissioner";
|
||||
import { parseFlagConfig } from "~/lib/flag-types";
|
||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||
import { sendEmail, wrapInEmailTemplate, emailParagraph } from "~/lib/email.server";
|
||||
import { logger } from "~/lib/logger";
|
||||
import {
|
||||
SettingsDesktopNav,
|
||||
SettingsMobileGridNav,
|
||||
SettingsMobileSectionPill,
|
||||
type SettingsGridSection,
|
||||
} from "~/components/league/settings/SettingsNavigation";
|
||||
import { ProfileSection } from "~/components/user/settings/ProfileSection";
|
||||
import { AccountSection } from "~/components/user/settings/AccountSection";
|
||||
import { ApiSection } from "~/components/user/settings/ApiSection";
|
||||
import { PrivacySection } from "~/components/user/settings/PrivacySection";
|
||||
import type { Route } from "./+types/settings";
|
||||
|
||||
type SectionId = "profile" | "account" | "api" | "privacy";
|
||||
|
||||
const SECTIONS: readonly SettingsGridSection[] = [
|
||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
|
||||
{ id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" },
|
||||
{ id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" },
|
||||
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
|
||||
] as const;
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Settings - Brackt" }];
|
||||
}
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
if (!session) {
|
||||
return redirect("/login?redirectTo=/settings");
|
||||
}
|
||||
const user = await findUserById(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/");
|
||||
}
|
||||
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
|
||||
|
||||
const db = database();
|
||||
const linkedAccounts = await db
|
||||
.select({ id: schema.accounts.id, providerId: schema.accounts.providerId })
|
||||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.userId, session.user.id));
|
||||
|
||||
return { user, isInActiveDraft, linkedAccounts };
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
if (!session) {
|
||||
return redirect("/login?redirectTo=/settings");
|
||||
}
|
||||
|
||||
const user = await findUserById(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const formData = await args.request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "update-avatar-flag") {
|
||||
const rawConfig = formData.get("flagConfig");
|
||||
if (typeof rawConfig !== "string") {
|
||||
return { intent, error: "Flag config is required." };
|
||||
}
|
||||
let parsedConfig: unknown;
|
||||
try {
|
||||
parsedConfig = JSON.parse(rawConfig);
|
||||
} catch {
|
||||
return { intent, error: "Invalid flag config." };
|
||||
}
|
||||
const flagConfig = parseFlagConfig(parsedConfig);
|
||||
if (!flagConfig) {
|
||||
return { intent, error: "Invalid flag config." };
|
||||
}
|
||||
await import("~/models/user").then(({ updateUser }) =>
|
||||
updateUser(session.user.id, { flagConfig, avatarType: "flag" })
|
||||
);
|
||||
return { intent, success: true };
|
||||
}
|
||||
|
||||
if (intent === "remove-avatar-photo") {
|
||||
await import("~/models/user").then(({ updateUser }) =>
|
||||
updateUser(session.user.id, { customAvatarUrl: null, avatarType: "flag" })
|
||||
);
|
||||
if (user.customAvatarUrl) {
|
||||
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((err) => {
|
||||
logger.error("Failed to delete user avatar from Cloudinary:", err);
|
||||
});
|
||||
}
|
||||
return { intent, success: true };
|
||||
}
|
||||
|
||||
if (intent === "update-profile") {
|
||||
const displayName = formData.get("displayName") as string;
|
||||
const username = formData.get("username") as string | null;
|
||||
const timezone = formData.get("timezone") as string | null;
|
||||
|
||||
const { TIMEZONE_OPTIONS } = await import("~/components/league/TimezoneSelect");
|
||||
const validTimezones = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value)));
|
||||
const timezoneValue = timezone && validTimezones.has(timezone) ? timezone : undefined;
|
||||
|
||||
await import("~/models/user").then(({ updateUser }) =>
|
||||
updateUser(session.user.id, {
|
||||
displayName: displayName || undefined,
|
||||
username: username || undefined,
|
||||
timezone: timezoneValue,
|
||||
})
|
||||
);
|
||||
return { intent, success: true };
|
||||
}
|
||||
|
||||
if (intent === "request-data") {
|
||||
const notes = (formData.get("dataRequestNotes") as string | null) ?? "";
|
||||
const html = wrapInEmailTemplate(`
|
||||
${emailParagraph(`A data export request has been submitted by <strong>${user.email}</strong> (user ID: ${user.id}).`)}
|
||||
${notes ? emailParagraph(`Notes: ${notes}`) : ""}
|
||||
${emailParagraph("Please fulfill this request within 30 days per GDPR/CCPA requirements.")}
|
||||
`);
|
||||
const { error } = await sendEmail({
|
||||
to: "privacy@brackt.com",
|
||||
subject: `Data Request from ${user.email}`,
|
||||
html,
|
||||
replyTo: user.email,
|
||||
});
|
||||
if (error) {
|
||||
logger.error("Failed to send data request email:", error);
|
||||
return { intent, dataRequestError: "Failed to submit request. Please email privacy@brackt.com directly." };
|
||||
}
|
||||
return { intent, dataRequestSuccess: true };
|
||||
}
|
||||
|
||||
if (intent === "delete-account") {
|
||||
const customAvatarUrl = user.customAvatarUrl;
|
||||
|
||||
const ownedTeams = await findTeamsByOwnerId(session.user.id);
|
||||
await Promise.all(ownedTeams.map((team) => removeTeamOwner(team.id)));
|
||||
|
||||
await removeAllCommissionersByUserId(session.user.id);
|
||||
await anonymizeUserAccount(session.user.id);
|
||||
|
||||
if (customAvatarUrl) {
|
||||
deleteCloudinaryImageByUrl(customAvatarUrl).catch((err) => {
|
||||
logger.error("Failed to delete user avatar from Cloudinary after account deletion:", err);
|
||||
});
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return { intent, error: "Unknown action." };
|
||||
}
|
||||
|
||||
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { user, isInActiveDraft, linkedAccounts } = loaderData;
|
||||
const [activeSection, setActiveSection] = useState<SectionId>("profile");
|
||||
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
|
||||
|
||||
const handleSectionChange = (id: string, mobile = false) => {
|
||||
setActiveSection(id as SectionId);
|
||||
if (mobile) setMobileView("section");
|
||||
};
|
||||
|
||||
const profileSuccess = actionData?.intent === "update-profile" && "success" in actionData && actionData.success === true;
|
||||
const profileError = actionData?.intent === "update-profile" && "error" in actionData ? (actionData as { error: string }).error : undefined;
|
||||
const avatarSuccess = (actionData?.intent === "update-avatar-flag" || actionData?.intent === "remove-avatar-photo") && "success" in actionData;
|
||||
const dataRequestSuccess = actionData?.intent === "request-data" && "dataRequestSuccess" in actionData ? true : undefined;
|
||||
const dataRequestError = actionData?.intent === "request-data" && "dataRequestError" in actionData ? (actionData as { dataRequestError: string }).dataRequestError : undefined;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Mobile: grid of sections */}
|
||||
{mobileView === "grid" && (
|
||||
<div className="lg:hidden">
|
||||
<SettingsMobileGridNav
|
||||
sections={SECTIONS}
|
||||
onSectionChange={(id) => handleSectionChange(id, true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: back pill when viewing a section */}
|
||||
{mobileView === "section" && (
|
||||
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
|
||||
<SettingsDesktopNav
|
||||
sections={SECTIONS}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={(id) => handleSectionChange(id)}
|
||||
/>
|
||||
|
||||
<main className="min-w-0">
|
||||
{activeSection === "profile" && (
|
||||
<ProfileSection
|
||||
user={user}
|
||||
isInActiveDraft={isInActiveDraft}
|
||||
success={profileSuccess || avatarSuccess}
|
||||
error={profileError}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "account" && (
|
||||
<AccountSection
|
||||
email={user.email}
|
||||
linkedAccounts={linkedAccounts}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "api" && <ApiSection />}
|
||||
{activeSection === "privacy" && (
|
||||
<PrivacySection
|
||||
userEmail={user.email}
|
||||
dataRequestSuccess={dataRequestSuccess}
|
||||
dataRequestError={dataRequestError}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,7 @@ export const users = pgTable("users", {
|
|||
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
createdAt: timestamp("created_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;
|
||||
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
|
|
@ -701,6 +701,13 @@
|
|||
"when": 1778341807236,
|
||||
"tag": "0099_peaceful_hannibal_king",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 100,
|
||||
"version": "7",
|
||||
"when": 1778440988519,
|
||||
"tag": "0100_mighty_weapon_omega",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue