feat(profile): add Danger zone account deletion UI
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user