feat(profile): add Danger zone account deletion UI

This commit is contained in:
2026-07-26 10:20:56 +02:00
parent ab8cdea214
commit e2533bd1a8
3 changed files with 84 additions and 1 deletions

View File

@@ -231,6 +231,17 @@ button:disabled {
color: #fff; color: #fff;
} }
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
.filter-pills { .filter-pills {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

View File

@@ -106,6 +106,10 @@ export const api = {
getProfile: () => request<Profile>("/api/profile"), getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) => updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), request<Profile>("/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<void>("/api/profile", { method: "DELETE" }),
// Review queue -- cursor-paginated: pass the previous page's next_cursor // Review queue -- cursor-paginated: pass the previous page's next_cursor
// as `before` to fetch the next one. Fetching every activity's laps and // as `before` to fetch the next one. Fetching every activity's laps and

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "../api/client"; import { api, BASE_URL } from "../api/client";
import { ColorField } from "../components/ColorField"; import { ColorField } from "../components/ColorField";
import { GarminConnection } from "../components/GarminConnection"; import { GarminConnection } from "../components/GarminConnection";
import { NullableNumberField } from "../components/NullableNumberField"; import { NullableNumberField } from "../components/NullableNumberField";
@@ -47,6 +47,9 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const profileRef = useRef<ProfileType | null>(null); const profileRef = useRef<ProfileType | null>(null);
profileRef.current = profile; profileRef.current = profile;
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e))); 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) { if (!profile) {
return ( return (
<div className="page"> <div className="page">
@@ -240,6 +263,51 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
</fieldset> </fieldset>
<TrainingTypesCard /> <TrainingTypesCard />
<fieldset className="kind-editor danger-zone">
<legend>Danger zone</legend>
{!deleteConfirmOpen ? (
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
Delete profile
</button>
) : (
<div className="danger-zone-confirm">
<p>
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
activity -- and logs you out. This cannot be undone.
</p>
<label>
Type DELETE to confirm
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
disabled={deleting}
/>
</label>
<div className="controls">
<button
type="button"
disabled={deleting}
onClick={() => {
setDeleteConfirmOpen(false);
setDeleteConfirmText("");
}}
>
Cancel
</button>
<button
type="button"
className="button-danger"
disabled={deleting || deleteConfirmText !== "DELETE"}
onClick={deleteAccount}
>
Permanently delete
</button>
</div>
</div>
)}
</fieldset>
</div> </div>
); );
} }