diff --git a/README.md b/README.md index c1e6c6c..7318ebe 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ To Be Played is split into three repositories: to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; a repaired cover comes back as `imageUrl`. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout. +- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the + account at once and removes it after 30 days; the page ends the session and goes to the landing page. - **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its commit. CI passes them as Docker build args; a local build shows `dev`. - **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode). diff --git a/public/i18n/de.json b/public/i18n/de.json index d37be52..1e18dae 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -106,7 +106,21 @@ "UNLINKED": "Steam-Verknüpfung gelöst." }, "VERSION": "Version", - "VERSION_UNKNOWN": "unbekannt" + "VERSION_UNKNOWN": "unbekannt", + "DELETE_ACCOUNT": { + "TITLE": "Konto löschen", + "EXPLAIN": "Dein Konto wird sofort gesperrt: kein Login, kein öffentliches Profil. Nach 30 Tagen wird es endgültig entfernt, mit Bibliothek, Backlog und Statistiken.", + "BUTTON": "Mein Konto löschen", + "DIALOG_TITLE": "Konto wirklich löschen?", + "TYPE_EMAIL": "Gib zur Bestätigung deine E-Mail ein", + "PASSWORD": "Passwort", + "CONFIRM": "Endgültig löschen", + "CANCEL": "Abbrechen", + "DONE": "Dein Konto ist gelöscht.", + "WRONG_PASSWORD": "Das Passwort ist falsch.", + "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", + "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." + } }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", diff --git a/public/i18n/en.json b/public/i18n/en.json index 5f6041e..d590705 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -106,7 +106,21 @@ "UNLINKED": "Steam account unlinked." }, "VERSION": "Version", - "VERSION_UNKNOWN": "unknown" + "VERSION_UNKNOWN": "unknown", + "DELETE_ACCOUNT": { + "TITLE": "Delete account", + "EXPLAIN": "Your account is blocked at once: no login, no public profile. After 30 days it is removed for good, with your library, backlog and stats.", + "BUTTON": "Delete my account", + "DIALOG_TITLE": "Delete your account?", + "TYPE_EMAIL": "Type your email to confirm", + "PASSWORD": "Password", + "CONFIRM": "Delete for good", + "CANCEL": "Cancel", + "DONE": "Your account is deleted.", + "WRONG_PASSWORD": "The password is wrong.", + "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", + "ERROR": "The account could not be deleted. Please try again later." + } }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", diff --git a/src/components/DeleteAccountCard.jsx b/src/components/DeleteAccountCard.jsx new file mode 100644 index 0000000..1125d1d --- /dev/null +++ b/src/components/DeleteAccountCard.jsx @@ -0,0 +1,112 @@ +import {useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Loader2, Trash2} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {useResetAppearance} from '../context/PreferencesContext.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {deleteAccount} from '../utils/account.js'; +import {clearSession} from '../utils/auth.js'; + +const sameEmail = (typed, email) => !!email && typed.trim().toLowerCase() === email.toLowerCase(); + +/** + * "Delete my account": Auth blocks the account at once and removes it after 30 days. The user types the own + * email against a misclick and the password against a borrowed session. + */ +export default function DeleteAccountCard({email}) { + const {t} = useTranslation(); + const navigate = useNavigate(); + const resetAppearance = useResetAppearance(); + const expireSession = useSessionExpired(); + const [typedEmail, setTypedEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + + const confirm = async () => { + setBusy(true); + try { + const response = await deleteAccount(password); + if (response.status === 204) { + clearSession(); + resetAppearance(); + toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE')); + navigate('/', {replace: true}); + return; + } + if (response.status === 401) { + expireSession(); + } else if (response.status === 403) { + toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD')); + } else if (response.status === 429) { + const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); + toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes})); + } else { + toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR')); + } + } catch (err) { + console.error('Delete account error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } + setBusy(false); + }; + + return ( + + + {t('SETTINGS.DELETE_ACCOUNT.TITLE')} + + +

{t('SETTINGS.DELETE_ACCOUNT.EXPLAIN')}

+ { if (!open) { setTypedEmail(''); setPassword(''); } }}> + + + + + + {t('SETTINGS.DELETE_ACCOUNT.DIALOG_TITLE')} + {t('SETTINGS.DELETE_ACCOUNT.EXPLAIN')} + +
+ + setTypedEmail(event.target.value)}/> +
+
+ + setPassword(event.target.value)}/> +
+ + + + + + +
+
+
+
+ ); +} diff --git a/src/components/DeleteAccountCard.test.jsx b/src/components/DeleteAccountCard.test.jsx new file mode 100644 index 0000000..d559187 --- /dev/null +++ b/src/components/DeleteAccountCard.test.jsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter, Route, Routes} from 'react-router-dom'; +import {toast} from 'sonner'; +import DeleteAccountCard from './DeleteAccountCard.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const EMAIL = 'player@example.com'; + +const answer = (status, headers = {}) => ({ok: status < 300, status, headers: new Headers(headers), json: async () => ({})}); + +const renderCard = () => render( + + + }/> + landing

}/> + login

}/> +
+
); + +const openAndFill = (email, password) => { + fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'})); + fireEvent.change(screen.getByLabelText('SETTINGS.DELETE_ACCOUNT.TYPE_EMAIL'), {target: {value: email}}); + fireEvent.change(screen.getByLabelText('SETTINGS.DELETE_ACCOUNT.PASSWORD'), {target: {value: password}}); +}; + +const confirmButton = () => screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.CONFIRM'}); + +describe('DeleteAccountCard', () => { + beforeEach(() => { + localStorage.setItem('tbp_session', '1'); + globalThis.fetch = vi.fn(async () => answer(204)); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('asks for the own email and a password before it deletes', () => { + renderCard(); + + openAndFill('someone@else.com', 'secret'); + expect(confirmButton().disabled).toBe(true); + + fireEvent.change(screen.getByLabelText('SETTINGS.DELETE_ACCOUNT.TYPE_EMAIL'), {target: {value: ' Player@Example.com '}}); + expect(confirmButton().disabled).toBe(false); + }); + + it('sends the password, ends the session and goes to the landing page', async () => { + renderCard(); + openAndFill(EMAIL, 'secret'); + + fireEvent.click(confirmButton()); + + expect(await screen.findByText('landing')).toBeTruthy(); + const [url, init] = fetch.mock.calls[0]; + expect(url).toMatch(/\/api\/users\/me$/); + expect(init.method).toBe('DELETE'); + expect(JSON.parse(init.body)).toEqual({password: 'secret'}); + expect(localStorage.getItem('tbp_session')).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.DONE'); + }); + + it('keeps the dialog open on a wrong password', async () => { + fetch.mockResolvedValue(answer(403)); + renderCard(); + openAndFill(EMAIL, 'wrong'); + + fireEvent.click(confirmButton()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD')); + expect(confirmButton()).toBeTruthy(); + expect(localStorage.getItem('tbp_session')).toBe('1'); + }); + + it('shows the lock message after too many wrong passwords', async () => { + fetch.mockResolvedValue(answer(429, {'Retry-After': '900'})); + renderCard(); + openAndFill(EMAIL, 'wrong'); + + fireEvent.click(confirmButton()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.TOO_MANY')); + }); + + it('opens the login page when the session is gone', async () => { + fetch.mockResolvedValue(answer(401)); + renderCard(); + openAndFill(EMAIL, 'secret'); + + fireEvent.click(confirmButton()); + + expect(await screen.findByText('login')).toBeTruthy(); + }); + + it('shows the network error when Auth does not answer', async () => { + fetch.mockRejectedValue(new TypeError('Failed to fetch')); + renderCard(); + openAndFill(EMAIL, 'secret'); + + fireEvent.click(confirmButton()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE')); + }); +}); diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx new file mode 100644 index 0000000..1352b46 --- /dev/null +++ b/src/components/ui/dialog.jsx @@ -0,0 +1,106 @@ +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}) { + return +} + +function DialogTrigger({ + ...props +}) { + return +} + +function DialogClose({ + ...props +}) { + return +} + +function DialogContent({ + className, + children, + ...props +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ + className, + ...props +}) { + return
+} + +function DialogFooter({ + className, + ...props +}) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 771fdc3..a28786c 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -15,6 +15,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import {Skeleton} from '@/components/ui/skeleton'; import {Switch} from '@/components/ui/switch'; import BuildVersionCard from '../components/BuildVersionCard.jsx'; +import DeleteAccountCard from '../components/DeleteAccountCard.jsx'; import FormField from '../components/FormField.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx'; import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx'; @@ -466,6 +467,8 @@ export default function SettingsPage() { + +
); } diff --git a/src/utils/account.js b/src/utils/account.js new file mode 100644 index 0000000..a802987 --- /dev/null +++ b/src/utils/account.js @@ -0,0 +1,11 @@ +import {apiFetch} from './api.js'; + +/** + * DELETE /api/users/me on Auth: 204 (cookies cleared), 403 INVALID_PASSWORD, 429 with Retry-After in seconds. + */ +export const deleteAccount = (password) => + apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me`, { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({password}), + });