143 lines
5.7 KiB
TypeScript
143 lines
5.7 KiB
TypeScript
"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>
|
|
)
|
|
}
|