This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
// "Delete my account": the person types the account's e-mail address and confirms, from a session
|
||||
// whose sign-in is recent (the server checks both; this form only says so first). The answer is the
|
||||
// deletion's state: completed, or erasing while a module's service is unavailable -- in both cases the
|
||||
// person is signed out everywhere, and the app's browser modules clear what they kept (`onDeleted`).
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { DeletionView } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountDeletionLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
/** The services whose data is erased, already named; empty when there are none besides the app. */
|
||||
readonly services: string
|
||||
/** Set when the person is the last owner of an organization others belong to: the form is closed. */
|
||||
readonly lastOwner?: string
|
||||
/** Set when an organization would be left without members. */
|
||||
readonly onlyMember?: string
|
||||
readonly reauth: string
|
||||
readonly reauthLink: string
|
||||
readonly notInteractive: string
|
||||
readonly confirmLabel: string
|
||||
readonly confirmHint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly completed: string
|
||||
/** With `{modules}` still in it. */
|
||||
readonly erasing: string
|
||||
readonly home: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
readonly sectionNames: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface AccountDeletionProps {
|
||||
readonly labels: AccountDeletionLabels
|
||||
/** Whether the session may delete now: a person, signed in recently, owning no organization alone. */
|
||||
readonly interactive: boolean
|
||||
readonly recentSignIn: boolean
|
||||
/** Where a person signs in again and comes back to this page. */
|
||||
readonly reauthHref: string
|
||||
/** A deletion already under way, when the page was rendered. */
|
||||
readonly deletion: DeletionView | null
|
||||
/** Runs once the deletion answered, before the result is shown: the app ends its browser modules' context. */
|
||||
readonly onDeleted?: (deletion: DeletionView) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly kind: "idle" | "busy" }
|
||||
| { readonly kind: "deleted"; readonly deletion: DeletionView }
|
||||
| { readonly kind: "failed"; readonly detail: string; readonly code: string }
|
||||
|
||||
const codeOf = (error: unknown): string => {
|
||||
const problem = (error as { problem?: { code?: unknown } } | null)?.problem
|
||||
return typeof problem?.code === "string" ? problem.code : "unknown"
|
||||
}
|
||||
|
||||
export function AccountDeletionPanel(props: AccountDeletionProps): ReactElement {
|
||||
const { labels } = props
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>(
|
||||
props.deletion ? { kind: "deleted", deletion: props.deletion } : { kind: "idle" },
|
||||
)
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const confirm = String(new FormData(event.currentTarget).get("confirm") ?? "").trim()
|
||||
if (confirm.length === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const deletion = (await call("account.deletion.request", { confirm })) as DeletionView
|
||||
// The session is revoked now: what the browser modules kept for it goes too (contract §12).
|
||||
await Promise.resolve(props.onDeleted?.(deletion)).catch(() => undefined)
|
||||
setState({ kind: "deleted", deletion })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error), code: codeOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const reauth = (
|
||||
<p role="status">
|
||||
{labels.reauth} <a href={props.reauthHref}>{labels.reauthLink}</a>
|
||||
</p>
|
||||
)
|
||||
|
||||
if (state.kind === "deleted") {
|
||||
const pending = state.deletion.pending.map((key) => labels.sectionNames[key] ?? key).join(", ")
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p role="status">
|
||||
{state.deletion.state === "completed" ? labels.completed : labels.erasing.replace("{modules}", pending)}
|
||||
</p>
|
||||
<a href="/">{labels.home}</a>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = !props.interactive || labels.lastOwner !== undefined
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
{labels.services ? <p>{labels.services}</p> : null}
|
||||
{labels.onlyMember ? <p>{labels.onlyMember}</p> : null}
|
||||
{labels.lastOwner ? <p role="status">{labels.lastOwner}</p> : null}
|
||||
{!props.interactive ? <p role="status">{labels.notInteractive}</p> : null}
|
||||
{props.interactive && !props.recentSignIn ? reauth : null}
|
||||
{blocked ? null : (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<div className="kit-field">
|
||||
<label htmlFor={inputId}>{labels.confirmLabel}</label>
|
||||
<input
|
||||
id={inputId}
|
||||
name="confirm"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby={hintId}
|
||||
/>
|
||||
<p id={hintId}>{labels.confirmHint}</p>
|
||||
</div>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{state.kind === "failed" && state.code === "reauthentication_required" ? reauth : null}
|
||||
{state.kind === "failed" && state.code !== "reauthentication_required" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user