diff --git a/frontend/src/App.css b/frontend/src/App.css index 64d42dc..6073fbb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -231,6 +231,17 @@ button:disabled { color: #fff; } +.danger-zone-confirm { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.danger-zone-confirm p { + margin: 0; + color: #9aa0ab; +} + .filter-pills { display: flex; gap: 0.5rem; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e21cdb7..3ad2121 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -106,6 +106,10 @@ export const api = { getProfile: () => request("/api/profile"), updateProfile: (profile: Profile) => request("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), + // Permanently deletes the signed-in user's entire account. The caller is + // responsible for following a successful call with a real logout + // navigation -- this does not touch the session cookie (see Profile.tsx). + deleteProfile: () => request("/api/profile", { method: "DELETE" }), // Review queue -- cursor-paginated: pass the previous page's next_cursor // as `before` to fetch the next one. Fetching every activity's laps and diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 41aea2c..406c1f1 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { api } from "../api/client"; +import { api, BASE_URL } from "../api/client"; import { ColorField } from "../components/ColorField"; import { GarminConnection } from "../components/GarminConnection"; import { NullableNumberField } from "../components/NullableNumberField"; @@ -47,6 +47,9 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) { const profileRef = useRef(null); profileRef.current = profile; const saveTimeoutRef = useRef | null>(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [deleting, setDeleting] = useState(false); useEffect(() => { api.getProfile().then(setProfile).catch((e) => setError(String(e))); @@ -100,6 +103,26 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) { } } + async function deleteAccount() { + setDeleting(true); + setError(null); + try { + await api.deleteProfile(); + // Deletion doesn't clear the session cookie -- follow with a real + // logout navigation (must be a POST, same as the header's Log out + // button in App.tsx) so Keycloak's own SSO session ends too, not just + // geniusrun's local one. + const form = document.createElement("form"); + form.method = "post"; + form.action = `${BASE_URL}/api/session/logout`; + document.body.appendChild(form); + form.submit(); + } catch (e) { + setError(String(e)); + setDeleting(false); + } + } + if (!profile) { return (
@@ -240,6 +263,51 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) { + +
+ Danger zone + {!deleteConfirmOpen ? ( + + ) : ( +
+

+ This permanently deletes your account -- Garmin credentials, every custom rule, and every synced + activity -- and logs you out. This cannot be undone. +

+ +
+ + +
+
+ )} +
); }