@eifi1/ui-kit 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -4
- package/dist/account/account-parts.d.ts +21 -0
- package/dist/account/account-parts.js +40 -0
- package/dist/account/account-parts.js.map +1 -0
- package/dist/account/data-export-setting.d.ts +73 -0
- package/dist/account/data-export-setting.js +113 -0
- package/dist/account/data-export-setting.js.map +1 -0
- package/dist/account/delete-account-setting.d.ts +91 -0
- package/dist/account/delete-account-setting.js +199 -0
- package/dist/account/delete-account-setting.js.map +1 -0
- package/dist/account/email-change-setting.d.ts +136 -0
- package/dist/account/email-change-setting.js +277 -0
- package/dist/account/email-change-setting.js.map +1 -0
- package/dist/account/sessions-setting.d.ts +110 -0
- package/dist/account/sessions-setting.js +146 -0
- package/dist/account/sessions-setting.js.map +1 -0
- package/dist/admin/admin-action-confirm.d.ts +91 -0
- package/dist/admin/admin-action-confirm.js +276 -0
- package/dist/admin/admin-action-confirm.js.map +1 -0
- package/dist/admin/admin-action-log.d.ts +105 -0
- package/dist/admin/admin-action-log.js +153 -0
- package/dist/admin/admin-action-log.js.map +1 -0
- package/dist/admin/admin-parts.d.ts +40 -0
- package/dist/admin/admin-parts.js +45 -0
- package/dist/admin/admin-parts.js.map +1 -0
- package/dist/admin/invitations-panel.d.ts +91 -0
- package/dist/admin/invitations-panel.js +289 -0
- package/dist/admin/invitations-panel.js.map +1 -0
- package/dist/admin/reviewer-scope-editor.d.ts +91 -0
- package/dist/admin/reviewer-scope-editor.js +134 -0
- package/dist/admin/reviewer-scope-editor.js.map +1 -0
- package/dist/admin/role-select.d.ts +91 -0
- package/dist/admin/role-select.js +128 -0
- package/dist/admin/role-select.js.map +1 -0
- package/dist/admin/transfer-ownership-dialog.d.ts +91 -0
- package/dist/admin/transfer-ownership-dialog.js +83 -0
- package/dist/admin/transfer-ownership-dialog.js.map +1 -0
- package/dist/admin/user-roster.d.ts +91 -0
- package/dist/admin/user-roster.js +285 -0
- package/dist/admin/user-roster.js.map +1 -0
- package/dist/auth/accept-invitation.d.ts +90 -0
- package/dist/auth/accept-invitation.js +117 -0
- package/dist/auth/accept-invitation.js.map +1 -0
- package/dist/auth/auth-errors.d.ts +139 -0
- package/dist/auth/auth-errors.js +92 -0
- package/dist/auth/auth-errors.js.map +1 -0
- package/dist/auth/complete-name-dialog.d.ts +71 -0
- package/dist/auth/complete-name-dialog.js +168 -0
- package/dist/auth/complete-name-dialog.js.map +1 -0
- package/dist/auth/email-tag.d.ts +60 -0
- package/dist/auth/email-tag.js +23 -0
- package/dist/auth/email-tag.js.map +1 -0
- package/dist/auth/forgot-password-form.d.ts +86 -0
- package/dist/auth/forgot-password-form.js +123 -0
- package/dist/auth/forgot-password-form.js.map +1 -0
- package/dist/auth/form-rules.d.ts +27 -0
- package/dist/auth/form-rules.js +23 -0
- package/dist/auth/form-rules.js.map +1 -0
- package/dist/auth/new-password-fields.d.ts +38 -0
- package/dist/auth/new-password-fields.js +54 -0
- package/dist/auth/new-password-fields.js.map +1 -0
- package/dist/auth/not-found-page.d.ts +57 -0
- package/dist/auth/not-found-page.js +40 -0
- package/dist/auth/not-found-page.js.map +1 -0
- package/dist/auth/register-form.d.ts +91 -0
- package/dist/auth/register-form.js +270 -0
- package/dist/auth/register-form.js.map +1 -0
- package/dist/auth/reset-password-form.d.ts +131 -0
- package/dist/auth/reset-password-form.js +167 -0
- package/dist/auth/reset-password-form.js.map +1 -0
- package/dist/auth/sign-in-form.d.ts +295 -0
- package/dist/auth/sign-in-form.js +426 -0
- package/dist/auth/sign-in-form.js.map +1 -0
- package/dist/auth/status-parts.d.ts +78 -0
- package/dist/auth/status-parts.js +83 -0
- package/dist/auth/status-parts.js.map +1 -0
- package/dist/auth/verify-email.d.ts +126 -0
- package/dist/auth/verify-email.js +243 -0
- package/dist/auth/verify-email.js.map +1 -0
- package/dist/chart.d.ts +25 -5
- package/dist/components/account-chips.d.ts +47 -7
- package/dist/components/account-chips.js +19 -3
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +12 -1
- package/dist/components/account-settings-labels.js +4 -1
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +61 -6
- package/dist/components/account-settings.js +115 -15
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +26 -5
- package/dist/components/button-group.d.ts +26 -5
- package/dist/components/calculator.d.ts +26 -5
- package/dist/components/confirm-dialog.d.ts +26 -5
- package/dist/components/copy-button.d.ts +23 -2
- package/dist/components/danger-confirm.d.ts +26 -5
- package/dist/components/facing-pair.d.ts +26 -5
- package/dist/components/file-button.d.ts +26 -5
- package/dist/components/file-dropzone.d.ts +26 -5
- package/dist/components/form-actions.d.ts +26 -5
- package/dist/components/iban-input.d.ts +26 -5
- package/dist/components/language-select.d.ts +26 -12
- package/dist/components/legal-page.d.ts +26 -5
- package/dist/components/legal.d.ts +26 -5
- package/dist/components/money-field.d.ts +26 -5
- package/dist/components/number-field.d.ts +26 -5
- package/dist/components/number-input.d.ts +26 -5
- package/dist/components/numpad-sheet.d.ts +26 -5
- package/dist/components/phone-input.d.ts +26 -5
- package/dist/components/series-chart.d.ts +26 -5
- package/dist/components/settings-fields.d.ts +25 -7
- package/dist/components/share-card.d.ts +25 -4
- package/dist/components/text-link.d.ts +26 -5
- package/dist/components/time-input.d.ts +26 -5
- package/dist/components/ui.d.ts +23 -2
- package/dist/components/user-avatar.d.ts +16 -2
- package/dist/components/user-avatar.js +7 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/{kit-labels-Du0uHBq4.d.ts → delete-account-setting-CGVMckMw.d.ts} +6654 -5302
- package/dist/feedback/feedback-attachment.d.ts +26 -5
- package/dist/feedback/feedback-context.d.ts +26 -5
- package/dist/feedback/feedback-crash.d.ts +26 -5
- package/dist/feedback/feedback-dialog.d.ts +26 -5
- package/dist/feedback/feedback-inbox.d.ts +26 -5
- package/dist/feedback/feedback-labels.d.ts +26 -5
- package/dist/feedback/feedback-menu.d.ts +26 -5
- package/dist/feedback/feedback-record.d.ts +26 -5
- package/dist/feedback/feedback-row-detail.d.ts +26 -5
- package/dist/feedback/feedback-status-undo.d.ts +26 -5
- package/dist/feedback/feedback-submit.d.ts +26 -5
- package/dist/feedback/feedback-swipe.d.ts +26 -5
- package/dist/feedback/feedback-table.d.ts +23 -2
- package/dist/feedback/feedback-thread.d.ts +26 -5
- package/dist/feedback.d.ts +26 -5
- package/dist/hooks/use-anchored-panel.d.ts +6 -2
- package/dist/hooks/use-anchored-panel.js +2 -1
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +26 -5
- package/dist/i18n/defaults.d.ts +26 -5
- package/dist/i18n/defaults.js +40 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +26 -5
- package/dist/i18n/german.js +343 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +23 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +21 -204
- package/dist/i18n/locales/de-CH.d.ts +26 -5
- package/dist/i18n/locales/en.d.ts +26 -5
- package/dist/i18n/locales/es.d.ts +26 -5
- package/dist/i18n/locales/es.js +337 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +26 -5
- package/dist/i18n/locales/fr.js +344 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +26 -5
- package/dist/i18n/locales/hu.js +337 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +26 -5
- package/dist/i18n/locales/it.js +342 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +26 -5
- package/dist/i18n/locales/zh.js +333 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +26 -5
- package/dist/i18n/review.js +45 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/person-name.d.ts +69 -0
- package/dist/lib/person-name.js +65 -0
- package/dist/lib/person-name.js.map +1 -0
- package/dist/rhf/fields.d.ts +26 -5
- package/dist/rhf/form.d.ts +26 -5
- package/dist/rhf.d.ts +26 -5
- package/dist/shell/app-shell.d.ts +26 -5
- package/dist/shell/company-switcher.d.ts +59 -0
- package/dist/shell/company-switcher.js +85 -0
- package/dist/shell/company-switcher.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +26 -5
- package/dist/shell/topbar-action-menu.d.ts +1 -0
- package/dist/shell.d.ts +20 -5
- package/dist/wizard/stepper-nav.d.ts +26 -5
- package/dist/wizard.d.ts +26 -5
- package/package.json +1 -1
- package/src/account/account-parts.ts +67 -0
- package/src/account/data-export-setting.tsx +218 -0
- package/src/account/delete-account-setting.tsx +362 -0
- package/src/account/email-change-setting.tsx +457 -0
- package/src/account/sessions-setting.tsx +283 -0
- package/src/admin/admin-action-confirm.tsx +495 -0
- package/src/admin/admin-action-log.tsx +297 -0
- package/src/admin/admin-parts.ts +82 -0
- package/src/admin/invitations-panel.tsx +516 -0
- package/src/admin/reviewer-scope-editor.tsx +234 -0
- package/src/admin/role-select.tsx +286 -0
- package/src/admin/transfer-ownership-dialog.tsx +172 -0
- package/src/admin/user-roster.tsx +573 -0
- package/src/auth/accept-invitation.tsx +227 -0
- package/src/auth/auth-errors.ts +262 -0
- package/src/auth/complete-name-dialog.tsx +253 -0
- package/src/auth/email-tag.ts +75 -0
- package/src/auth/forgot-password-form.tsx +229 -0
- package/src/auth/form-rules.ts +40 -0
- package/src/auth/new-password-fields.tsx +85 -0
- package/src/auth/not-found-page.tsx +98 -0
- package/src/auth/register-form.tsx +477 -0
- package/src/auth/reset-password-form.tsx +351 -0
- package/src/auth/sign-in-form.tsx +859 -0
- package/src/auth/status-parts.tsx +138 -0
- package/src/auth/verify-email.tsx +409 -0
- package/src/components/account-chips.tsx +64 -7
- package/src/components/account-settings-labels.ts +15 -1
- package/src/components/account-settings.tsx +199 -23
- package/src/components/user-avatar.tsx +22 -4
- package/src/hooks/use-anchored-panel.ts +14 -3
- package/src/i18n/defaults.ts +40 -0
- package/src/i18n/german.ts +379 -0
- package/src/i18n/kit-labels.tsx +60 -0
- package/src/i18n/locales/es.ts +371 -1
- package/src/i18n/locales/fr.ts +379 -0
- package/src/i18n/locales/hu.ts +366 -1
- package/src/i18n/locales/it.ts +373 -0
- package/src/i18n/locales/zh.ts +343 -0
- package/src/i18n/review.ts +44 -0
- package/src/index.ts +146 -0
- package/src/lib/person-name.ts +157 -0
- package/src/shell/company-switcher.tsx +140 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pieces the signed-out status pages share — {@link ForgotPasswordForm},
|
|
7
|
+
* {@link ResetPasswordForm}, {@link VerifyEmailStatus}, {@link AcceptInvitation} and
|
|
8
|
+
* {@link NotFoundPage} (docs/auth-harmonization.md §7).
|
|
9
|
+
*
|
|
10
|
+
* @internal Not part of the barrel; the pages are.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A heading level a page part may take. Default `h2`, under the page's `h1`
|
|
14
|
+
* (`AuthLayout`'s `title`, the app's name), as on `SignInForm`'s steps; deeper for a
|
|
15
|
+
* page embedded in another (a preview, the docs). */
|
|
16
|
+
export type AuthHeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
17
|
+
|
|
18
|
+
/** The form pages' heading: start-aligned, the size of `SignInForm`'s step headings. */
|
|
19
|
+
export const FORM_HEADING_CLASS = "text-base font-semibold text-[var(--text-primary)]";
|
|
20
|
+
/** The status pages' heading: centred over the centred outcome below it. */
|
|
21
|
+
export const STATUS_HEADING_CLASS = "text-center text-base font-semibold text-[var(--text-primary)]";
|
|
22
|
+
export const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* kastlan's outcome block (verify-email-page, reset-password-page): the kit's
|
|
26
|
+
* `EmptyState` without its dashed box, because it already sits in `AuthLayout`'s card —
|
|
27
|
+
* a box in a box. The icon and the title carry the tone.
|
|
28
|
+
*/
|
|
29
|
+
export const OUTCOME_CLASS = "border-0 bg-transparent px-0 py-3";
|
|
30
|
+
|
|
31
|
+
/** `href` with `key=value` added to its query, before any `#fragment`: the reset page's
|
|
32
|
+
* "Sign in" carries the address (`/login?email=ada%40example.com`). */
|
|
33
|
+
export function withQuery(href: string, key: string, value: string): string {
|
|
34
|
+
const hash = href.indexOf("#");
|
|
35
|
+
const base = hash === -1 ? href : href.slice(0, hash);
|
|
36
|
+
const fragment = hash === -1 ? "" : href.slice(hash);
|
|
37
|
+
const separator = base.includes("?") ? (base.endsWith("?") || base.endsWith("&") ? "" : "&") : "?";
|
|
38
|
+
return `${base}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}${fragment}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a callback's promise came to. */
|
|
42
|
+
export type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run `start(key)` ONCE per key, and hand what it settles to into `settle` — unless the
|
|
46
|
+
* key moved on in the meantime, when the stale answer is dropped.
|
|
47
|
+
*
|
|
48
|
+
* keksdose's verify-email and join pages fire their request from an effect behind a
|
|
49
|
+
* `useRef` guard, and say why: the token is single-use, and StrictMode's
|
|
50
|
+
* mount → unmount → remount would send it twice and turn a success into "invalid" on
|
|
51
|
+
* the second answer. The guard is the same here; there is deliberately no "unmounted"
|
|
52
|
+
* flag set in the cleanup, because StrictMode's simulated unmount would set it and the
|
|
53
|
+
* remount would never clear the guard — the page would hang on its spinner (the bug
|
|
54
|
+
* keksdose hit on /join with a react-query observer). A state update after a real
|
|
55
|
+
* unmount is a no-op in React 18 and later.
|
|
56
|
+
*
|
|
57
|
+
* `key === null` runs nothing (no token, not signed in yet) and forgets the last key, so
|
|
58
|
+
* the same token runs again once it is back.
|
|
59
|
+
*/
|
|
60
|
+
export function useOncePerKey<T>(
|
|
61
|
+
key: string | null,
|
|
62
|
+
start: (key: string) => T | Promise<T>,
|
|
63
|
+
settle: (key: string, result: Settled<T>) => void,
|
|
64
|
+
): void {
|
|
65
|
+
const latest = useRef({ start, settle });
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
latest.current = { start, settle };
|
|
68
|
+
});
|
|
69
|
+
const current = useRef<string | null>(null);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (key === null) {
|
|
72
|
+
current.current = null;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (current.current === key) return;
|
|
76
|
+
current.current = key;
|
|
77
|
+
const report = (result: Settled<T>) => {
|
|
78
|
+
if (current.current === key) latest.current.settle(key, result);
|
|
79
|
+
};
|
|
80
|
+
let started: T | Promise<T>;
|
|
81
|
+
try {
|
|
82
|
+
started = latest.current.start(key);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
report({ ok: false, error });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
Promise.resolve(started).then(
|
|
88
|
+
(value) => report({ ok: true, value }),
|
|
89
|
+
(error: unknown) => report({ ok: false, error }),
|
|
90
|
+
);
|
|
91
|
+
}, [key]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether a callback's return is a promise to wait for. A callback that returns
|
|
95
|
+
* nothing is done when it returns. */
|
|
96
|
+
export function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
97
|
+
return typeof (value as PromiseLike<unknown> | null)?.then === "function";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Move focus to `ref` when `active` turns true — the outcome a person's own submit led
|
|
102
|
+
* to ("If an account exists … the link is on its way", "Your password has been
|
|
103
|
+
* changed"). The submit button they pressed is gone with the form, which drops focus to
|
|
104
|
+
* `<body>`; a screen reader then hears nothing at all. Focusing the message (a
|
|
105
|
+
* `tabIndex={-1}` box) reads it out and leaves the next Tab where the page continues.
|
|
106
|
+
*/
|
|
107
|
+
export function useFocusWhen(active: boolean, ref: { current: HTMLElement | null }): void {
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (active) ref.current?.focus();
|
|
110
|
+
}, [active, ref]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The box {@link useFocusWhen} focuses: centred, no focus ring of its own (it is not a
|
|
114
|
+
* control; the ring would read as one). */
|
|
115
|
+
export function OutcomeMessage({
|
|
116
|
+
icon,
|
|
117
|
+
children,
|
|
118
|
+
focusRef,
|
|
119
|
+
className,
|
|
120
|
+
}: {
|
|
121
|
+
icon: ReactNode;
|
|
122
|
+
children: ReactNode;
|
|
123
|
+
focusRef?: { current: HTMLDivElement | null };
|
|
124
|
+
className?: string;
|
|
125
|
+
}) {
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={focusRef}
|
|
129
|
+
tabIndex={-1}
|
|
130
|
+
className={cn("flex flex-col items-center gap-2 py-2 text-center outline-none", className)}
|
|
131
|
+
>
|
|
132
|
+
<span aria-hidden className="[&_svg]:size-10">
|
|
133
|
+
{icon}
|
|
134
|
+
</span>
|
|
135
|
+
{children}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
|
+
import { CheckCircle2, MailWarning, TimerOff, XCircle } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
7
|
+
import { AlertBanner } from "../components/alert-banner";
|
|
8
|
+
import { LoadingState } from "../components/loading-state";
|
|
9
|
+
import { Button, EmptyState } from "../components/ui";
|
|
10
|
+
import { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from "./status-parts";
|
|
11
|
+
import { authErrorCode } from "./auth-errors";
|
|
12
|
+
import type { AuthHeadingLevel } from "./status-parts";
|
|
13
|
+
|
|
14
|
+
/* ── Labels ──────────────────────────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
/** Every string the verification page and banner render — the `verifyEmail` namespace.
|
|
17
|
+
* keksdose's words (`verify_email.*`, and its server's "has expired"). */
|
|
18
|
+
export interface VerifyEmailLabels {
|
|
19
|
+
/** The page's heading. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** While the link is redeemed. */
|
|
22
|
+
verifying: string;
|
|
23
|
+
verified: string;
|
|
24
|
+
/** The way on from the page — into the app, or to sign-in. */
|
|
25
|
+
continue: string;
|
|
26
|
+
invalid: string;
|
|
27
|
+
expired: string;
|
|
28
|
+
/** The page was opened without a `token`. */
|
|
29
|
+
noToken: string;
|
|
30
|
+
/** Under `invalid`: why a link stops working. */
|
|
31
|
+
invalidHint: string;
|
|
32
|
+
/** Under a dead link, signed out: where a new one comes from. */
|
|
33
|
+
requestInApp: string;
|
|
34
|
+
/** Under a dead link, signed in, beside "Send again". */
|
|
35
|
+
requestHere: string;
|
|
36
|
+
/** The resend button, on the page and in the banner. */
|
|
37
|
+
resend: string;
|
|
38
|
+
/** The resend button while a just-sent mail cools down, given the seconds left. */
|
|
39
|
+
resendIn: (seconds: number) => string;
|
|
40
|
+
/** Said (and shown) after a resend went out. */
|
|
41
|
+
resent: string;
|
|
42
|
+
/** Said (and shown) after a resend failed. */
|
|
43
|
+
resendError: string;
|
|
44
|
+
/** The banner's message. */
|
|
45
|
+
banner: string;
|
|
46
|
+
/** The banner's ×: it defers, it does not close anything. */
|
|
47
|
+
dismiss: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_VERIFY_EMAIL_LABELS: VerifyEmailLabels = {
|
|
51
|
+
title: "Confirm your email address",
|
|
52
|
+
verifying: "Confirming…",
|
|
53
|
+
verified: "Your email address is confirmed.",
|
|
54
|
+
continue: "Continue",
|
|
55
|
+
invalid: "This confirmation link is not valid",
|
|
56
|
+
expired: "This confirmation link has expired",
|
|
57
|
+
noToken: "This link is missing its confirmation token.",
|
|
58
|
+
invalidHint: "The link may have expired or already been used.",
|
|
59
|
+
requestInApp: "You can request a new one from inside the app.",
|
|
60
|
+
requestHere: "You can request a new one here.",
|
|
61
|
+
resend: "Send again",
|
|
62
|
+
resendIn: (seconds) => `Send again in ${seconds} s`,
|
|
63
|
+
resent: "Confirmation email sent",
|
|
64
|
+
resendError: "Could not send the confirmation email",
|
|
65
|
+
banner: "Please confirm your email address.",
|
|
66
|
+
dismiss: "Not now",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A minute: long enough that an impatient second click does not mail twice, short
|
|
70
|
+
* enough that a mail lost to a spam filter can be re-sent without leaving the page. The
|
|
71
|
+
* server's own limit is the real one (kastlan: 3 an hour per address). */
|
|
72
|
+
const DEFAULT_COOLDOWN = 60;
|
|
73
|
+
|
|
74
|
+
/* ── Resend ──────────────────────────────────────────────────────────────── */
|
|
75
|
+
|
|
76
|
+
type ResendPhase = "idle" | "sending" | "sent" | "failed";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* "Send again" with its own memory: busy while the request runs, then — after a mail
|
|
80
|
+
* went out — held for `cooldown` seconds with the countdown on the button, so a second
|
|
81
|
+
* click cannot mail twice and a throttled server is not asked to refuse. A failure frees
|
|
82
|
+
* it at once. The outcome is a polite live region that is always rendered (a region that
|
|
83
|
+
* mounts with its message is often not read), and visible.
|
|
84
|
+
*/
|
|
85
|
+
function useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {
|
|
86
|
+
const [phase, setPhase] = useState<ResendPhase>("idle");
|
|
87
|
+
// The refusal itself, for `describeError` (0.29.1): the server's own sentence — keksdose's
|
|
88
|
+
// per-address throttle, "try again in N minutes" — beats a fixed "could not send".
|
|
89
|
+
const [error, setError] = useState<unknown>(undefined);
|
|
90
|
+
const [remaining, setRemaining] = useState(0);
|
|
91
|
+
const counting = remaining > 0;
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!counting) return;
|
|
94
|
+
const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
|
|
95
|
+
return () => clearInterval(id);
|
|
96
|
+
}, [counting]);
|
|
97
|
+
|
|
98
|
+
const sent = () => {
|
|
99
|
+
setPhase("sent");
|
|
100
|
+
setRemaining(Math.max(0, Math.round(cooldown)));
|
|
101
|
+
};
|
|
102
|
+
const send = () => {
|
|
103
|
+
if (!onResend || phase === "sending" || counting) return;
|
|
104
|
+
let result: unknown;
|
|
105
|
+
try {
|
|
106
|
+
result = onResend();
|
|
107
|
+
} catch (e) {
|
|
108
|
+
setError(e);
|
|
109
|
+
setPhase("failed");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!isThenable(result)) {
|
|
113
|
+
sent();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
setPhase("sending");
|
|
117
|
+
result.then(sent, (e: unknown) => {
|
|
118
|
+
setError(e);
|
|
119
|
+
setPhase("failed");
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
return { phase, remaining, send, error };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ResendButton({
|
|
126
|
+
state,
|
|
127
|
+
labels,
|
|
128
|
+
variant,
|
|
129
|
+
}: {
|
|
130
|
+
state: ReturnType<typeof useResend>;
|
|
131
|
+
labels: VerifyEmailLabels;
|
|
132
|
+
variant: "secondary" | "brand";
|
|
133
|
+
}) {
|
|
134
|
+
return (
|
|
135
|
+
<Button
|
|
136
|
+
type="button"
|
|
137
|
+
variant={variant}
|
|
138
|
+
size={variant === "secondary" ? "sm" : undefined}
|
|
139
|
+
onClick={state.send}
|
|
140
|
+
pending={state.phase === "sending"}
|
|
141
|
+
// `disabledReason`, not `disabled`: the button just pressed has the focus, and a
|
|
142
|
+
// native `disabled` would drop it to <body>. The reason is what just happened.
|
|
143
|
+
disabledReason={state.remaining > 0 ? labels.resent : undefined}
|
|
144
|
+
>
|
|
145
|
+
{state.remaining > 0 ? labels.resendIn(state.remaining) : labels.resend}
|
|
146
|
+
</Button>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ResendStatus({
|
|
151
|
+
phase,
|
|
152
|
+
error,
|
|
153
|
+
describeError,
|
|
154
|
+
labels,
|
|
155
|
+
className,
|
|
156
|
+
}: {
|
|
157
|
+
phase: ResendPhase;
|
|
158
|
+
error?: unknown;
|
|
159
|
+
describeError?: (error: unknown) => string | undefined;
|
|
160
|
+
labels: VerifyEmailLabels;
|
|
161
|
+
className?: string;
|
|
162
|
+
}) {
|
|
163
|
+
const failed = phase === "failed" ? (describeError?.(error) ?? labels.resendError) : "";
|
|
164
|
+
return (
|
|
165
|
+
<span
|
|
166
|
+
role="status"
|
|
167
|
+
className={cn(phase === "failed" ? "text-[var(--danger)]" : "text-[var(--success)]", className)}
|
|
168
|
+
>
|
|
169
|
+
{phase === "sent" ? labels.resent : failed}
|
|
170
|
+
</span>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── The page ────────────────────────────────────────────────────────────── */
|
|
175
|
+
|
|
176
|
+
/** What became of a confirmation link that did not verify. */
|
|
177
|
+
export type VerifyEmailFailure = "invalid" | "expired";
|
|
178
|
+
|
|
179
|
+
/** The default when the app gives no `classifyError`: the coded refusal decides. */
|
|
180
|
+
function defaultVerifyFailure(error: unknown): VerifyEmailFailure {
|
|
181
|
+
return authErrorCode(error) === "token_expired" ? "expired" : "invalid";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface VerifyEmailStatusProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
|
|
185
|
+
/** The link's token — the app's `?token=`. Empty: the link was cut short, and the
|
|
186
|
+
* page says so without asking anyone. */
|
|
187
|
+
token: string;
|
|
188
|
+
/**
|
|
189
|
+
* Redeem it — `POST /auth/verify-email {token}`, once per token, on mount. A POST and
|
|
190
|
+
* not the link's own GET (§10.10): a mail scanner that opens every link must not
|
|
191
|
+
* confirm an address nobody read. Resolve when it verified (an address that already
|
|
192
|
+
* was counts: keksdose's redeem is idempotent); reject with the client's error.
|
|
193
|
+
*/
|
|
194
|
+
onVerify: (token: string) => Promise<unknown>;
|
|
195
|
+
/** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers
|
|
196
|
+
* answer one `token_invalid`; an app whose server tells expiry apart maps it here. */
|
|
197
|
+
classifyError?: (error: unknown) => VerifyEmailFailure;
|
|
198
|
+
/** 0.29.1: the words for a refused resend — the server's own sentence (a throttle's
|
|
199
|
+
* "try again in N minutes"), else the kit's `resendError`. */
|
|
200
|
+
describeError?: (error: unknown) => string | undefined;
|
|
201
|
+
/** Someone is signed in on this device: "Continue" goes into the app, and a dead link
|
|
202
|
+
* offers "Send again" (with `onResend`). */
|
|
203
|
+
signedIn?: boolean;
|
|
204
|
+
/** Send a new confirmation mail (`POST /auth/verify-email/resend`) — offered on a dead
|
|
205
|
+
* link while signed in. */
|
|
206
|
+
onResend?: () => Promise<unknown> | void;
|
|
207
|
+
/** Seconds "Send again" waits after a mail went out. Default 60; `0` never waits. */
|
|
208
|
+
cooldown?: number;
|
|
209
|
+
/** Where "Continue" goes. Default `/` signed in, `/login` signed out. */
|
|
210
|
+
continueHref?: string;
|
|
211
|
+
/** App content under "confirmed" — keksdose's "Others can now share a budget with you
|
|
212
|
+
* directly." */
|
|
213
|
+
verifiedContent?: ReactNode;
|
|
214
|
+
/** The heading. Default `labels.title`; `null` draws none, for a page that puts it in
|
|
215
|
+
* `AuthLayout`'s `title` instead. */
|
|
216
|
+
title?: ReactNode;
|
|
217
|
+
/** The heading's level. Default `h2`, under the page's `h1`. */
|
|
218
|
+
headingAs?: AuthHeadingLevel;
|
|
219
|
+
labels?: Partial<VerifyEmailLabels>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type VerifyOutcome = "verified" | VerifyEmailFailure;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Redeem an email-confirmation link (`/verify-email?token=…`, docs/auth-harmonization.md
|
|
226
|
+
* §7) — keksdose's `VerifyEmailPage` (features/auth/verify-email-page.tsx), with
|
|
227
|
+
* kastlan's outcome blocks and its separate "expired".
|
|
228
|
+
*
|
|
229
|
+
* Opened from an inbox, often in a browser with no session, so the token is the only
|
|
230
|
+
* credential and the page renders signed-out. It redeems once per token on mount
|
|
231
|
+
* (StrictMode's double effect included — the token is single-use) and shows one of:
|
|
232
|
+
* confirmed; not valid; expired; or the link had no token at all. Verification gates
|
|
233
|
+
* nothing (§2.8), so every outcome ends on "Continue". Signed in, a dead link also
|
|
234
|
+
* offers "Send again", which waits `cooldown` seconds after a mail went out.
|
|
235
|
+
*
|
|
236
|
+
* The outcome replaces the spinner, which says nothing to a screen reader on its own,
|
|
237
|
+
* so it is also announced.
|
|
238
|
+
*/
|
|
239
|
+
export function VerifyEmailStatus({
|
|
240
|
+
token,
|
|
241
|
+
onVerify,
|
|
242
|
+
classifyError,
|
|
243
|
+
describeError,
|
|
244
|
+
signedIn = false,
|
|
245
|
+
onResend,
|
|
246
|
+
cooldown = DEFAULT_COOLDOWN,
|
|
247
|
+
continueHref,
|
|
248
|
+
verifiedContent,
|
|
249
|
+
title,
|
|
250
|
+
headingAs: Heading = "h2",
|
|
251
|
+
labels: labelsProp,
|
|
252
|
+
className,
|
|
253
|
+
...rest
|
|
254
|
+
}: VerifyEmailStatusProps) {
|
|
255
|
+
const labels = useKitLabels("verifyEmail", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);
|
|
256
|
+
const [settled, setSettled] = useState<{ token: string; outcome: VerifyOutcome } | null>(null);
|
|
257
|
+
const outcome: VerifyOutcome | "verifying" =
|
|
258
|
+
token === "" ? "invalid" : settled?.token === token ? settled.outcome : "verifying";
|
|
259
|
+
const { announce, regionProps } = useAnnounce();
|
|
260
|
+
const resend = useResend(onResend, cooldown);
|
|
261
|
+
|
|
262
|
+
useOncePerKey(token === "" ? null : token, onVerify, (key, result) => {
|
|
263
|
+
// Without a classifier, the coded refusal decides (0.29.1): `token_expired` is
|
|
264
|
+
// "expired", anything else "invalid".
|
|
265
|
+
const next: VerifyOutcome = result.ok
|
|
266
|
+
? "verified"
|
|
267
|
+
: (classifyError ?? defaultVerifyFailure)(result.error);
|
|
268
|
+
setSettled({ token: key, outcome: next });
|
|
269
|
+
announce(labels[next]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const href = continueHref ?? (signedIn ? "/" : "/login");
|
|
273
|
+
// `replace`: the token is spent, so Back must not land on this page again (keksdose F1).
|
|
274
|
+
const continueButton = (
|
|
275
|
+
<Button href={href} replace variant={outcome === "verified" ? "brand" : "secondary"}>
|
|
276
|
+
{labels.continue}
|
|
277
|
+
</Button>
|
|
278
|
+
);
|
|
279
|
+
const canResend = signedIn && onResend !== undefined;
|
|
280
|
+
|
|
281
|
+
let body: ReactNode;
|
|
282
|
+
if (outcome === "verifying") {
|
|
283
|
+
body = <LoadingState size="sm" label={labels.verifying} />;
|
|
284
|
+
} else if (outcome === "verified") {
|
|
285
|
+
body = (
|
|
286
|
+
<EmptyState
|
|
287
|
+
tone="success"
|
|
288
|
+
icon={<CheckCircle2 />}
|
|
289
|
+
title={labels.verified}
|
|
290
|
+
hint={verifiedContent}
|
|
291
|
+
action={continueButton}
|
|
292
|
+
className={OUTCOME_CLASS}
|
|
293
|
+
/>
|
|
294
|
+
);
|
|
295
|
+
} else {
|
|
296
|
+
const hint = (
|
|
297
|
+
<>
|
|
298
|
+
{outcome === "invalid" && token !== "" ? `${labels.invalidHint} ` : ""}
|
|
299
|
+
{canResend ? labels.requestHere : labels.requestInApp}
|
|
300
|
+
</>
|
|
301
|
+
);
|
|
302
|
+
body = (
|
|
303
|
+
<EmptyState
|
|
304
|
+
tone="danger"
|
|
305
|
+
icon={outcome === "expired" ? <TimerOff /> : <XCircle />}
|
|
306
|
+
title={token === "" ? labels.noToken : labels[outcome]}
|
|
307
|
+
hint={hint}
|
|
308
|
+
action={
|
|
309
|
+
<>
|
|
310
|
+
{canResend && <ResendButton state={resend} labels={labels} variant="brand" />}
|
|
311
|
+
{continueButton}
|
|
312
|
+
</>
|
|
313
|
+
}
|
|
314
|
+
className={OUTCOME_CLASS}
|
|
315
|
+
/>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div
|
|
321
|
+
{...rest}
|
|
322
|
+
data-state={outcome}
|
|
323
|
+
aria-busy={outcome === "verifying" || undefined}
|
|
324
|
+
className={cn("space-y-3", className)}
|
|
325
|
+
>
|
|
326
|
+
{title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}
|
|
327
|
+
{body}
|
|
328
|
+
{canResend && (
|
|
329
|
+
<ResendStatus
|
|
330
|
+
phase={resend.phase}
|
|
331
|
+
error={resend.error}
|
|
332
|
+
describeError={describeError}
|
|
333
|
+
labels={labels}
|
|
334
|
+
className="block text-center text-xs"
|
|
335
|
+
/>
|
|
336
|
+
)}
|
|
337
|
+
{/* Rendered from the start, so the outcome is read when it arrives. */}
|
|
338
|
+
<div {...regionProps} />
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* ── The banner ──────────────────────────────────────────────────────────── */
|
|
344
|
+
|
|
345
|
+
export interface EmailVerificationBannerProps {
|
|
346
|
+
/** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it
|
|
347
|
+
* went out; reject and the banner says it could not. */
|
|
348
|
+
onResend: () => Promise<unknown> | void;
|
|
349
|
+
/** 0.29.1: the words for a refused resend — the server's own sentence, else the kit's. */
|
|
350
|
+
describeError?: (error: unknown) => string | undefined;
|
|
351
|
+
/** Seconds "Send again" waits after a mail went out. Default 60; `0` never waits. */
|
|
352
|
+
cooldown?: number;
|
|
353
|
+
/** The ×, "Not now". The banner does not hide itself: the app stops rendering it —
|
|
354
|
+
* keksdose for the rest of the session. Left out, no ×. */
|
|
355
|
+
onDismiss?: () => void;
|
|
356
|
+
/** `strip` (default): keksdose's band across the top of the shell. `box`: kastlan's
|
|
357
|
+
* callout inside the page. */
|
|
358
|
+
variant?: "strip" | "box";
|
|
359
|
+
/** The app's sentence after the kit's — keksdose's "Until you do, nobody can share a
|
|
360
|
+
* budget with you directly." */
|
|
361
|
+
children?: ReactNode;
|
|
362
|
+
className?: string;
|
|
363
|
+
labels?: Partial<VerifyEmailLabels>;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The nudge for a signed-in account whose address is not confirmed — keksdose's
|
|
368
|
+
* `VerifyEmailBanner` (features/auth/verify-email-banner.tsx), kastlan's copy of it.
|
|
369
|
+
*
|
|
370
|
+
* A prompt, not a wall: verification gates nothing (§2.8). The app renders it while
|
|
371
|
+
* `/auth/me` says `email_verified: false`, and stops when the user dismisses it or the
|
|
372
|
+
* page above confirms the address. "Send again" waits `cooldown` seconds after a mail
|
|
373
|
+
* went out (the button says how long), and its outcome is written into the banner and
|
|
374
|
+
* read out — no toast needed.
|
|
375
|
+
*/
|
|
376
|
+
export function EmailVerificationBanner({
|
|
377
|
+
onResend,
|
|
378
|
+
describeError,
|
|
379
|
+
cooldown = DEFAULT_COOLDOWN,
|
|
380
|
+
onDismiss,
|
|
381
|
+
variant = "strip",
|
|
382
|
+
children,
|
|
383
|
+
className,
|
|
384
|
+
labels: labelsProp,
|
|
385
|
+
}: EmailVerificationBannerProps) {
|
|
386
|
+
const labels = useKitLabels("verifyEmail", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);
|
|
387
|
+
const resend = useResend(onResend, cooldown);
|
|
388
|
+
return (
|
|
389
|
+
<AlertBanner
|
|
390
|
+
tone="warning"
|
|
391
|
+
variant={variant}
|
|
392
|
+
icon={<MailWarning />}
|
|
393
|
+
action={<ResendButton state={resend} labels={labels} variant="secondary" />}
|
|
394
|
+
onDismiss={onDismiss}
|
|
395
|
+
dismissLabel={onDismiss ? labels.dismiss : undefined}
|
|
396
|
+
className={className}
|
|
397
|
+
>
|
|
398
|
+
{labels.banner}
|
|
399
|
+
{children != null && children !== false && <> {children}</>}{" "}
|
|
400
|
+
<ResendStatus
|
|
401
|
+
phase={resend.phase}
|
|
402
|
+
error={resend.error}
|
|
403
|
+
describeError={describeError}
|
|
404
|
+
labels={labels}
|
|
405
|
+
className="font-medium"
|
|
406
|
+
/>
|
|
407
|
+
</AlertBanner>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
@@ -118,25 +118,55 @@ export function RoleChip<R extends string = string>({
|
|
|
118
118
|
* - `registered` — an allowlist row whose address has an account now (Kurvenschmiede).
|
|
119
119
|
* - `unverified` — the email address is not confirmed (keksdose).
|
|
120
120
|
* - `passwordChange` — must set a new password at the next sign-in (keksdose).
|
|
121
|
+
* - `deletion` — 0.30.0: the user asked for the account to be deleted
|
|
122
|
+
* (docs/user-admin-harmonization.md §2.1, §6.4). It is deactivated at once and erased
|
|
123
|
+
* on a date (`after_days`: keksdose, Kurvenschmiede) or by an operator (kastlan). The
|
|
124
|
+
* chip says the date when there is one ({@link AccountStateChipProps.date}), and
|
|
125
|
+
* "Deletion requested" when there is none. It stands IN PLACE of `inactive`: an
|
|
126
|
+
* account marked for deletion is always deactivated, and two chips would say it twice.
|
|
121
127
|
*
|
|
122
128
|
* An account can be several at once (inactive AND unverified): render one chip per
|
|
123
129
|
* state, as keksdose's state column does.
|
|
124
130
|
*/
|
|
125
|
-
export type AccountState =
|
|
131
|
+
export type AccountState =
|
|
132
|
+
| "active"
|
|
133
|
+
| "inactive"
|
|
134
|
+
| "invited"
|
|
135
|
+
| "registered"
|
|
136
|
+
| "unverified"
|
|
137
|
+
| "passwordChange"
|
|
138
|
+
| "deletion";
|
|
126
139
|
|
|
127
|
-
|
|
140
|
+
/**
|
|
141
|
+
* The `accountState` namespace. One word per state, plus the two forms of `deletion`.
|
|
142
|
+
*
|
|
143
|
+
* The 0.30 keys are OPTIONAL, like every key added to a namespace an app annotates
|
|
144
|
+
* (`const L: AccountStateLabels = {…}`): a new required key would be a compile error in
|
|
145
|
+
* each app on its next update. Left out, they fall back to the provider's, then English.
|
|
146
|
+
*/
|
|
147
|
+
export interface AccountStateLabels extends Record<Exclude<AccountState, "deletion">, string> {
|
|
148
|
+
/** 0.30.0: an account marked for deletion with no date — kastlan's `operator` mode,
|
|
149
|
+
* where an operator erases it (§6.4). "Deletion requested". */
|
|
150
|
+
deletion?: string;
|
|
151
|
+
/** 0.30.0: an account that will be erased on `date`, already formatted in the
|
|
152
|
+
* reader's locale — the `after_days` mode. "Deletion on 6 Nov 2026". */
|
|
153
|
+
deletionOn?: (date: string) => string;
|
|
154
|
+
}
|
|
128
155
|
|
|
129
|
-
export const DEFAULT_ACCOUNT_STATE_LABELS
|
|
156
|
+
export const DEFAULT_ACCOUNT_STATE_LABELS = {
|
|
130
157
|
active: "Active",
|
|
131
158
|
inactive: "Inactive",
|
|
132
159
|
invited: "Invited",
|
|
133
160
|
registered: "Registered",
|
|
134
161
|
unverified: "Unverified",
|
|
135
162
|
passwordChange: "Must change password",
|
|
136
|
-
|
|
163
|
+
deletion: "Deletion requested",
|
|
164
|
+
deletionOn: (date: string) => `Deletion on ${date}`,
|
|
165
|
+
} satisfies Required<AccountStateLabels>;
|
|
137
166
|
|
|
138
|
-
/** The default tone of each state: green for the normal case, red for switched off
|
|
139
|
-
* amber for something the user still has to do, blue for
|
|
167
|
+
/** The default tone of each state: green for the normal case, red for switched off
|
|
168
|
+
* (and for going, `deletion`), amber for something the user still has to do, blue for
|
|
169
|
+
* an open invitation. */
|
|
140
170
|
export const ACCOUNT_STATE_TONES: Readonly<Record<AccountState, ChipTone>> = {
|
|
141
171
|
active: "success",
|
|
142
172
|
inactive: "danger",
|
|
@@ -144,10 +174,22 @@ export const ACCOUNT_STATE_TONES: Readonly<Record<AccountState, ChipTone>> = {
|
|
|
144
174
|
registered: "neutral",
|
|
145
175
|
unverified: "warning",
|
|
146
176
|
passwordChange: "warning",
|
|
177
|
+
// Red like `inactive`, which it replaces: the account cannot sign in, and it is going.
|
|
178
|
+
deletion: "danger",
|
|
147
179
|
};
|
|
148
180
|
|
|
149
181
|
export interface AccountStateChipProps extends AccountChipLook {
|
|
150
182
|
state: AccountState;
|
|
183
|
+
/**
|
|
184
|
+
* 0.30.0, `state="deletion"` only: the day the account will be erased — the row's
|
|
185
|
+
* `deletion_scheduled_at` (§3.2, §6.4). Formatted as a date in the reader's locale
|
|
186
|
+
* ("Deletion on 6 Nov 2026"), through the provider's `formatDate` when the app set
|
|
187
|
+
* one, as {@link DateMark} does. Missing or `null` — an operator erases it, so there
|
|
188
|
+
* is no date — the chip says "Deletion requested". Ignored for every other state.
|
|
189
|
+
*/
|
|
190
|
+
date?: DateInput;
|
|
191
|
+
/** Over the provider's locale, for the `deletion` date. */
|
|
192
|
+
locale?: string;
|
|
151
193
|
/** Over {@link ACCOUNT_STATE_TONES} — Kurvenschmiede draws `inactive` neutral. */
|
|
152
194
|
tone?: ChipTone;
|
|
153
195
|
/** Over the label, for an app whose word for the state is its own ("Deactivated"). */
|
|
@@ -162,6 +204,8 @@ export interface AccountStateChipProps extends AccountChipLook {
|
|
|
162
204
|
*/
|
|
163
205
|
export function AccountStateChip({
|
|
164
206
|
state,
|
|
207
|
+
date,
|
|
208
|
+
locale: localeProp,
|
|
165
209
|
tone,
|
|
166
210
|
children,
|
|
167
211
|
labels: labelsProp,
|
|
@@ -175,10 +219,23 @@ export function AccountStateChip({
|
|
|
175
219
|
// An `undefined` in the prop is skipped (the provider's merge does that), so an
|
|
176
220
|
// optional value cannot blank a default.
|
|
177
221
|
const labels = useKitLabels("accountState", DEFAULT_ACCOUNT_STATE_LABELS, labelsProp);
|
|
222
|
+
const locale = useKitLocale(localeProp);
|
|
223
|
+
const fromProvider = useKitDateFormatter();
|
|
224
|
+
const when = state === "deletion" ? toDate(date ?? null) : null;
|
|
225
|
+
let text: ReactNode;
|
|
226
|
+
if (state !== "deletion") text = labels[state];
|
|
227
|
+
else if (when) {
|
|
228
|
+
// A date in a SENTENCE, so no weekday (keksdose's rule, KitDateFormatContext).
|
|
229
|
+
const day =
|
|
230
|
+
(fromProvider && fromProvider(toLocalIso(when), { unit: "day", source: "dateMark", locale, weekday: false })) ||
|
|
231
|
+
formatDate(when, "medium", { locale });
|
|
232
|
+
text = (labels.deletionOn ?? DEFAULT_ACCOUNT_STATE_LABELS.deletionOn)(day);
|
|
233
|
+
} else text = labels.deletion ?? DEFAULT_ACCOUNT_STATE_LABELS.deletion;
|
|
178
234
|
return (
|
|
179
235
|
<Chip
|
|
180
236
|
{...rest}
|
|
181
237
|
data-state={state}
|
|
238
|
+
data-date={when ? toLocalIso(when) : undefined}
|
|
182
239
|
tone={tone ?? ACCOUNT_STATE_TONES[state]}
|
|
183
240
|
size={size}
|
|
184
241
|
variant={variant}
|
|
@@ -186,7 +243,7 @@ export function AccountStateChip({
|
|
|
186
243
|
caps={caps}
|
|
187
244
|
className={className}
|
|
188
245
|
>
|
|
189
|
-
{children ??
|
|
246
|
+
{children ?? text}
|
|
190
247
|
</Chip>
|
|
191
248
|
);
|
|
192
249
|
}
|