@celestia-island/hikari 0.55.55 → 0.55.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.55",
3
+ "version": "0.55.56",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,21 @@
1
+ /* ── HkMfaVerifyCard ─────────────────────────────────────────────────
2
+ * The card shell, header and form vocabulary all come from the auth
3
+ * surfaces in styles/admin-tokens.scss (.s-auth-card / .s-auth-header /
4
+ * .s-auth-form); this sheet only owns the piece the card itself
5
+ * introduces: the factor picker's wrapper.
6
+ *
7
+ * The wrapper is a flex COLUMN child of .s-auth-form (admin-tokens
8
+ * stretches `.s-auth-form > *` to full width), so the row centers
9
+ * HORIZONTALLY via align-items — and the group must SHRINK-WRAP to its
10
+ * natural width: the slider thumb anchors from the strip's LEFT edge
11
+ * (inset + index × item-size), so a stretched root strands the thumb a
12
+ * full half-row left of the actually-centered active factor (empirically
13
+ * pinned by a chrome-headless probe when the panel lived in
14
+ * shittim-chest; jsdom cannot see it). This rule moved here verbatim
15
+ * from chest's host chrome sheet when the panel became this component —
16
+ * the host copy is deleted in the adoption change.
17
+ */
18
+
19
+ .s-auth-form .s-mfa-factors {
20
+ align-items: center;
21
+ }
@@ -0,0 +1,292 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick, reactive, ref } from "vue";
3
+
4
+ import { HkMfaVerifyCard, hkIsMfaFactor, hkPreferredMfaFactor } from "./HkMfaVerifyCard";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ /** Mount a render closure, returning its container. Reading reactive
9
+ * state INSIDE the closure keeps re-renders honest (a static vnode
10
+ * never updates, so prop-mutation tests need this shape). */
11
+ function mount(render: () => ReturnType<typeof h>) {
12
+ const container = document.createElement("div");
13
+ document.body.appendChild(container);
14
+ const app = createApp({ render });
15
+ app.mount(container);
16
+ mounts.push({ app, container });
17
+ return { app, container };
18
+ }
19
+
20
+ /** The card instance's exposed handles (clearProof / focusPassword). */
21
+ function exposedOf(app: ReturnType<typeof createApp>): { clearProof: () => void } {
22
+ const instance = app._instance?.subTree?.component as unknown as {
23
+ exposed: { clearProof: () => void } | null;
24
+ } | null;
25
+ expect(instance?.exposed, "the card exposes its proof handles").toBeTruthy();
26
+ return instance!.exposed!;
27
+ }
28
+
29
+ afterEach(() => {
30
+ for (const { app, container } of mounts.splice(0)) {
31
+ app.unmount();
32
+ container.remove();
33
+ }
34
+ });
35
+
36
+ function typeInto(input: HTMLInputElement, value: string) {
37
+ input.value = value;
38
+ input.dispatchEvent(new Event("input", { bubbles: true }));
39
+ }
40
+
41
+ function pickerButtons(c: HTMLElement) {
42
+ return Array.from(c.querySelectorAll<HTMLButtonElement>("button.hk-icon-group-item"));
43
+ }
44
+
45
+ function primaryButton(c: HTMLElement) {
46
+ return c.querySelector(".hk-btn-primary") as HTMLButtonElement;
47
+ }
48
+
49
+ function buttonText(c: HTMLElement, text: string) {
50
+ return Array.from(c.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
51
+ }
52
+
53
+ function otpCells(c: HTMLElement) {
54
+ return Array.from(c.querySelectorAll<HTMLInputElement>(".hk-otp-cell"));
55
+ }
56
+
57
+ function flush() {
58
+ // nextTick, deliberately NOT a setTimeout macrotask: in happy-dom the
59
+ // re-arm assertions only settle deterministically on the microtask
60
+ // queue the components themselves schedule on.
61
+ return nextTick();
62
+ }
63
+
64
+ describe("hkPreferredMfaFactor", () => {
65
+ it("follows the passkey > totp > sms ladder and keeps password last", () => {
66
+ expect(hkPreferredMfaFactor(["password", "totp"])).toBe("totp");
67
+ expect(hkPreferredMfaFactor(["sms", "totp"])).toBe("totp");
68
+ expect(hkPreferredMfaFactor(["password", "sms"])).toBe("sms");
69
+ expect(hkPreferredMfaFactor(["password"])).toBe("password");
70
+ });
71
+
72
+ it("returns null for nothing drivable and filters unknown kinds", () => {
73
+ expect(hkPreferredMfaFactor([])).toBeNull();
74
+ expect(hkPreferredMfaFactor(["eyes", "voice"])).toBeNull();
75
+ });
76
+
77
+ it("type-guards raw server strings", () => {
78
+ expect(hkIsMfaFactor("totp")).toBe(true);
79
+ expect(hkIsMfaFactor("iris")).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("HkMfaVerifyCard", () => {
84
+ it("renders the verify-account shell with the default factor's proof field", () => {
85
+ const { container } = mount(() =>
86
+ h(HkMfaVerifyCard, { factors: ["totp"], accountName: "demierge" }));
87
+ expect(container.querySelector(".s-auth-card")).toBeTruthy();
88
+ expect(container.querySelector(".s-auth-title")?.textContent).toBe("Verify account");
89
+ // The account-aware subtitle carries the name (locale interpolation).
90
+ expect(container.querySelector(".s-auth-subtitle")?.textContent).toContain("demierge");
91
+ // totp is the default → six code cells, TOTP hint, no password field.
92
+ expect(otpCells(container)).toHaveLength(6);
93
+ expect(container.textContent).toContain("Enter the 6-digit TOTP code");
94
+ expect(container.querySelector('input[name="mfa-verify-password"]')).toBeNull();
95
+ });
96
+
97
+ it("keeps the generic subtitle when no account name is given", () => {
98
+ const { container } = mount(() => h(HkMfaVerifyCard, { factors: ["totp"] }));
99
+ expect(container.querySelector(".s-auth-subtitle")?.textContent)
100
+ .toBe("This account requires an extra verification step");
101
+ });
102
+
103
+ it("prefers the ladder's head even when the server announces password first", () => {
104
+ const { container } = mount(() =>
105
+ h(HkMfaVerifyCard, { factors: ["password", "totp"] }));
106
+ expect(otpCells(container).length).toBe(6);
107
+ expect(container.querySelector('input[name="mfa-verify-password"]')).toBeNull();
108
+ });
109
+
110
+ it("renders the password proof with a disabled submit until a password exists", () => {
111
+ const { container } = mount(() =>
112
+ h(HkMfaVerifyCard, { factors: ["password"] }));
113
+ const field = container.querySelector('input[name="mfa-verify-password"]') as HTMLInputElement;
114
+ expect(field).toBeTruthy();
115
+ expect(otpCells(container)).toHaveLength(0);
116
+ const submit = Array.from(container.querySelectorAll("button"))
117
+ .find((b) => b.textContent?.includes("Verify and sign in")) as HTMLButtonElement;
118
+ expect(submit.disabled).toBe(true);
119
+ });
120
+
121
+ it("emits verify with the composed code once the row fills", async () => {
122
+ const onVerify = vi.fn();
123
+ const { container } = mount(() =>
124
+ h(HkMfaVerifyCard, { factors: ["totp"], onVerify }));
125
+ // Typing a whole code into the first cell distributes it forward.
126
+ typeInto(otpCells(container)[0]!, "123456");
127
+ await flush();
128
+ expect(primaryButton(container).disabled).toBe(false);
129
+ primaryButton(container).click();
130
+ await flush();
131
+ expect(onVerify).toHaveBeenCalledWith("totp", "123456");
132
+ });
133
+
134
+ it("does not emit verify while the proof is incomplete", async () => {
135
+ const onVerify = vi.fn();
136
+ const { container } = mount(() =>
137
+ h(HkMfaVerifyCard, { factors: ["totp"], onVerify }));
138
+ typeInto(otpCells(container)[0]!, "123");
139
+ await flush();
140
+ expect(primaryButton(container).disabled).toBe(true);
141
+ primaryButton(container).click();
142
+ await flush();
143
+ expect(onVerify).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("emits the password proof", async () => {
147
+ const onVerify = vi.fn();
148
+ const { container } = mount(() =>
149
+ h(HkMfaVerifyCard, { factors: ["password"], onVerify }));
150
+ typeInto(
151
+ container.querySelector('input[name="mfa-verify-password"]') as HTMLInputElement,
152
+ "secret",
153
+ );
154
+ await flush();
155
+ const submit = Array.from(container.querySelectorAll("button"))
156
+ .find((b) => b.textContent?.includes("Verify and sign in"))!;
157
+ submit.click();
158
+ await flush();
159
+ expect(onVerify).toHaveBeenCalledWith("password", "secret");
160
+ });
161
+
162
+ it("emits an empty passkey proof and shows the retry hint only when passed", async () => {
163
+ const onVerify = vi.fn();
164
+ const { container } = mount(() =>
165
+ h(HkMfaVerifyCard, {
166
+ factors: ["passkey"],
167
+ onVerify,
168
+ passkeyRetryHint: "try another entry",
169
+ }));
170
+ expect(container.textContent).toContain("Verify with Passkey");
171
+ expect(container.querySelector(".hk-alert-warning")?.textContent).toContain("try another entry");
172
+ expect(otpCells(container)).toHaveLength(0);
173
+ primaryButton(container).click();
174
+ await flush();
175
+ expect(onVerify).toHaveBeenCalledWith("passkey", "");
176
+ });
177
+
178
+ it("omits the retry alert when no hint is passed", () => {
179
+ const { container } = mount(() => h(HkMfaVerifyCard, { factors: ["passkey"] }));
180
+ expect(container.querySelector(".hk-alert-warning")).toBeNull();
181
+ });
182
+
183
+ it("pins the proven factor and switches to the step-two wording", async () => {
184
+ const onVerify = vi.fn();
185
+ const { container } = mount(() =>
186
+ h(HkMfaVerifyCard, {
187
+ factors: ["totp", "sms"],
188
+ proven: "totp",
189
+ accountName: "demierge",
190
+ onVerify,
191
+ }));
192
+ expect(container.querySelector(".s-auth-title")?.textContent).toBe("One more verification");
193
+ const subtitle = container.querySelector(".s-auth-subtitle")?.textContent ?? "";
194
+ expect(subtitle).toContain("TOTP authenticator app");
195
+ expect(subtitle).toContain("demierge");
196
+ // The proven entry stays visible, pinned disabled with the marker…
197
+ const buttons = pickerButtons(container);
198
+ expect(buttons).toHaveLength(2);
199
+ const pinned = buttons.find((b) => b.getAttribute("data-marker") === "success")!;
200
+ expect(pinned).toBeTruthy();
201
+ expect(pinned.disabled).toBe(true);
202
+ // …and the default selection moved on to the next ladder entry (sms:
203
+ // separated shape + sms hint).
204
+ await flush();
205
+ expect(container.querySelector(".hk-otp-gap")).toBeTruthy();
206
+ expect(container.textContent).toContain("Enter the 6-digit SMS code");
207
+ // Proving the sms factor emits "sms", not the pinned totp.
208
+ typeInto(otpCells(container)[0]!, "654321");
209
+ await flush();
210
+ primaryButton(container).click();
211
+ await flush();
212
+ expect(onVerify).toHaveBeenCalledWith("sms", "654321");
213
+ });
214
+
215
+ it("re-arms (selection + fields) when the proven factor arrives late", async () => {
216
+ const onVerify = vi.fn();
217
+ const state = reactive<{ proven?: string }>({});
218
+ const { container } = mount(() =>
219
+ h(HkMfaVerifyCard, {
220
+ factors: ["totp", "sms"],
221
+ proven: state.proven,
222
+ onVerify,
223
+ }));
224
+ await nextTick(); // settle the first paint before feeding the row
225
+ expect(otpCells(container)).toHaveLength(6); // totp selected first
226
+ typeInto(otpCells(container)[0]!, "11");
227
+ await nextTick();
228
+ // Step two: totp now proven — the card must reselect AND clear the
229
+ // half-typed code.
230
+ state.proven = "totp";
231
+ await nextTick();
232
+ expect(container.querySelector(".hk-otp-gap")).toBeTruthy(); // sms now selected
233
+ expect(otpCells(container).every((cell) => cell.value === "")).toBe(true);
234
+ expect(onVerify).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it("wires the SMS resend and the back action", async () => {
238
+ const onResend = vi.fn();
239
+ const onBack = vi.fn();
240
+ const { container } = mount(() =>
241
+ h(HkMfaVerifyCard, { factors: ["sms"], onResend, onBack }));
242
+ buttonText(container, "Resend code")!.click();
243
+ await flush();
244
+ expect(onResend).toHaveBeenCalledTimes(1);
245
+ buttonText(container, "Back to sign in")!.click();
246
+ await flush();
247
+ expect(onBack).toHaveBeenCalledTimes(1);
248
+ });
249
+
250
+ it("omits the resend row outside the sms factor", () => {
251
+ const { container } = mount(() => h(HkMfaVerifyCard, { factors: ["totp"] }));
252
+ expect(buttonText(container, "Resend code")).toBeUndefined();
253
+ });
254
+
255
+ it("hides the picker entirely when nothing drivable is announced", () => {
256
+ const { container } = mount(() =>
257
+ h(HkMfaVerifyCard, { factors: ["iris-scan"] }));
258
+ expect(pickerButtons(container)).toHaveLength(0);
259
+ // Degenerate but alive: the card still renders its shell + actions.
260
+ expect(container.querySelector(".s-auth-card")).toBeTruthy();
261
+ expect(buttonText(container, "Back to sign in")).toBeTruthy();
262
+ });
263
+
264
+ it("clears the active proof through the exposed handle", async () => {
265
+ const onVerify = vi.fn();
266
+ const cardRef = ref<{ clearProof: () => void } | null>(null);
267
+ const { app, container } = mount(() =>
268
+ h(HkMfaVerifyCard, { factors: ["totp"], onVerify }));
269
+ cardRef.value = exposedOf(app);
270
+ // A PARTIAL code: a full one would autoSubmit mid-type and emit
271
+ // before the clear ever runs.
272
+ typeInto(otpCells(container)[0]!, "123");
273
+ await flush();
274
+ cardRef.value!.clearProof();
275
+ await flush();
276
+ expect(otpCells(container).every((cell) => cell.value === "")).toBe(true);
277
+ primaryButton(container).click();
278
+ await flush();
279
+ expect(onVerify).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("disables the actions while loading (the picker stays live)", () => {
283
+ const { container } = mount(() =>
284
+ h(HkMfaVerifyCard, { factors: ["passkey", "totp"], loading: true }));
285
+ // The passkey submit + the back action are out of the user's hands;
286
+ // the picker stays live — re-clicking the active factor is a radio
287
+ // no-op, and chest's panel kept it enabled during verify too.
288
+ expect(primaryButton(container).disabled).toBe(true);
289
+ const back = buttonText(container, "Back to sign in") as HTMLButtonElement;
290
+ expect(back.disabled).toBe(true);
291
+ });
292
+ });
@@ -0,0 +1,421 @@
1
+ import { computed, defineComponent, nextTick, ref, watch, type PropType } from "vue";
2
+ import { Asterisk, Fingerprint, KeyRound, MessageSquareText } from "lucide-vue-next";
3
+
4
+ import { useI18n } from "../i18n/context";
5
+ import { HkAuthCard } from "./HkAuthCard";
6
+ import HkAlert from "./HkAlert";
7
+ import HkAuthSubmitButton from "./HkAuthSubmitButton";
8
+ import HkButton from "./HkButton";
9
+ import HkIconButtonGroup, { type HkIconButtonGroupOption } from "./HkIconButtonGroup";
10
+ import HkInput from "./HkInput";
11
+ import { HkOtpInput } from "./HkOtpInput";
12
+ import HkLogo from "./HkLogo";
13
+
14
+ import "./HkMfaVerifyCard.scss";
15
+
16
+ /** Every second-factor kind this card can drive. The first three are the
17
+ * classic second factors a backend announces after the primary login;
18
+ * `password` joins the list in account-first flows, where the password is
19
+ * proven on the verify step as one factor among the announced ones. */
20
+ export const MFA_FACTOR_KINDS = ["passkey", "totp", "sms", "password"] as const;
21
+
22
+ /** One announced second factor. */
23
+ export type HkMfaFactor = (typeof MFA_FACTOR_KINDS)[number];
24
+
25
+ /** Type-guard a raw announced kind (server payloads speak plain strings). */
26
+ export function hkIsMfaFactor(kind: string): kind is HkMfaFactor {
27
+ return (MFA_FACTOR_KINDS as readonly string[]).includes(kind);
28
+ }
29
+
30
+ /** The factor a freshly opened verify panel starts on. Passkey leads the
31
+ * ladder (phishing-resistant, one tap); the code entries follow for
32
+ * accounts without one. `password` is deliberately NOT in the ladder —
33
+ * it is the fallback choice, never the highlighted default when a
34
+ * stronger factor exists. Returns null when nothing verifiable remains
35
+ * (an empty list, or everything already proven). */
36
+ export function hkPreferredMfaFactor(factors: readonly string[]): HkMfaFactor | null {
37
+ const announced = factors.filter(hkIsMfaFactor);
38
+ const ladder: readonly HkMfaFactor[] = ["passkey", "totp", "sms"];
39
+ return ladder.find((kind) => announced.includes(kind))
40
+ ?? (announced.includes("password") ? "password" : null);
41
+ }
42
+
43
+ /** Fill a locale string's `{token}` placeholders. hikari's `t()` is a flat
44
+ * lookup by design, so messages carrying a name (the account being
45
+ * verified, the already-proven factor) interpolate here. */
46
+ function fill(template: string, values: Record<string, string>): string {
47
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
48
+ }
49
+
50
+ /**
51
+ * HkMfaVerifyCard — the shared second-factor ("verify account", step-up)
52
+ * card for every Celestia front end: the factor picker (slider icon
53
+ * group) over the per-factor proof field, submit, and the secondary
54
+ * actions, inside the HkAuthCard shell.
55
+ *
56
+ * Control contract (same split as HkSignInCard): the card owns ALL local
57
+ * input state — the selected factor, the code cells, the password entry —
58
+ * and never talks to a backend. The consumer injects
59
+ * `onVerify(kind, proof)` and feeds the in-flight state back through
60
+ * `loading`; a passkey proof arrives as `kind="passkey"` with an empty
61
+ * string (the WebAuthn ceremony itself is the proof, and stays
62
+ * consumer-side where the authenticator policy lives). After a rejected
63
+ * proof the consumer calls `clearProof()` through the component ref so
64
+ * the next attempt starts from an empty field.
65
+ *
66
+ * Two-proof flows (an account that must present TWO DISTINCT factors)
67
+ * pass `proven`: the proven kind's picker entry wears the success marker,
68
+ * cannot be re-selected, and the default title/subtitle switch to the
69
+ * "one more verification" wording. The card re-selects and clears its
70
+ * fields whenever the announced set or the proven factor changes.
71
+ *
72
+ * i18n: every visible string defaults to the `hikari::mfaVerify.*` locales
73
+ * (11 languages ship with the card); `title` / `subtitle` overrides win
74
+ * when a host needs its own wording. `{name}` / `{proven}` placeholders in
75
+ * the derived subtitle are filled here.
76
+ *
77
+ * ```tsx
78
+ * <HMfaVerifyCard
79
+ * factors={step.factors}
80
+ * proven={proven.value}
81
+ * account-name="demierge"
82
+ * logo-src={logo}
83
+ * loading={verifying.value}
84
+ * onVerify={(kind, proof) => completeStepUp(kind, proof)}
85
+ * onBack={() => (step.value = null)}
86
+ * />
87
+ * ```
88
+ */
89
+ export const HkMfaVerifyCard = defineComponent({
90
+ name: "HkMfaVerifyCard",
91
+ props: {
92
+ /** The factor kinds this account announced, server order. Raw
93
+ * strings are welcome — unknown kinds are filtered, and an emptied
94
+ * list renders a picker-less card rather than a dead one. */
95
+ factors: { type: Array as PropType<readonly string[]>, default: () => [] },
96
+ /** The kind already proven in a two-proof flow: pinned with the
97
+ * success marker, un-selectable, excluded from the default pick. */
98
+ proven: { type: String, default: undefined },
99
+ /** The account being verified — drives the "verifying {name}"
100
+ * subtitle. Empty falls back to the generic subtitle. */
101
+ accountName: { type: String, default: "" },
102
+ /** Title override; defaults to the verify / step-two locale. */
103
+ title: { type: String, default: undefined },
104
+ /** Subtitle override; defaults to the account-aware derivation. */
105
+ subtitle: { type: String, default: undefined },
106
+ /** Optional logo image URL for the card header. */
107
+ logoSrc: { type: String, default: undefined },
108
+ /** External in-flight state; disables every control while true. */
109
+ loading: { type: Boolean, default: false },
110
+ /** Extra guard on top of the built-in in-flight checks. */
111
+ disabled: { type: Boolean, default: false },
112
+ /** In-flight state of the SMS resend; swaps the resend label. */
113
+ resending: { type: Boolean, default: false },
114
+ /** Advisory text under the passkey submit (a failed scoped ceremony
115
+ * nudging toward the picker's other entries). Rendered only when
116
+ * set — the consumer owns the failure bookkeeping that decides it. */
117
+ passkeyRetryHint: { type: String, default: undefined },
118
+ /** Focus the proof field when the card mounts (the verify step just
119
+ * opened). Password re-focuses on every selection change too. */
120
+ autofocus: { type: Boolean, default: false },
121
+ },
122
+ emits: {
123
+ /** The user asks to prove `kind`; `proof` is the password text, the
124
+ * composed code, or "" for passkey (the ceremony is consumer-side). */
125
+ verify: (_kind: HkMfaFactor, _proof: string) => true,
126
+ /** SMS resend requested (only wired while `sms` is selected). */
127
+ resend: () => true,
128
+ /** Abandon the step-up (back to the credentials card). */
129
+ back: () => true,
130
+ },
131
+ setup(props, { emit, slots, expose }) {
132
+ const { t } = useI18n();
133
+
134
+ /** Announced + drivable kinds, server order preserved. */
135
+ const knownFactors = computed(() => props.factors.filter(hkIsMfaFactor));
136
+ /** The proven kind — only if it is one the card can name. */
137
+ const provenKind = computed(() =>
138
+ props.proven && hkIsMfaFactor(props.proven) ? props.proven : null);
139
+ /** The kinds still selectable this visit (two-proof flow skips the
140
+ * pinned one — it is DONE, not a candidate for the next proof). */
141
+ const selectableFactors = computed(() =>
142
+ knownFactors.value.filter((kind) => kind !== provenKind.value));
143
+
144
+ const selected = ref<HkMfaFactor>("totp");
145
+ const code = ref("");
146
+ const password = ref("");
147
+
148
+ /** (Re)select the default factor for the current visit state and
149
+ * clear both proof fields. Runs at mount and whenever the announced
150
+ * set or the proven factor changes (the two-proof step-two re-arm). */
151
+ function rearm() {
152
+ const list = selectableFactors.value;
153
+ selected.value = hkPreferredMfaFactor(list) ?? list[0] ?? "password";
154
+ code.value = "";
155
+ password.value = "";
156
+ if (props.autofocus && selected.value === "password") focusPassword();
157
+ }
158
+
159
+ watch(
160
+ () => `${knownFactors.value.join(",")}|${provenKind.value ?? ""}`,
161
+ () => rearm(),
162
+ );
163
+ rearm();
164
+
165
+ // ── proof-field focus ────────────────────────────────────────────
166
+ const passwordRef = ref<{ $el?: HTMLElement } | null>(null);
167
+ function focusPassword() {
168
+ void nextTick(() => {
169
+ (passwordRef.value?.$el as HTMLElement | undefined)
170
+ ?.querySelector("input")
171
+ ?.focus();
172
+ });
173
+ }
174
+
175
+ /** Switching factors starts a fresh proof (a half-typed code must
176
+ * not leak into the SMS entry); the password entry takes focus so a
177
+ * single-factor password account can type straight away. */
178
+ function onSelect(next: string | string[]) {
179
+ const value = Array.isArray(next) ? next[0] : next;
180
+ if (!hkIsMfaFactor(value) || !selectableFactors.value.includes(value)) return;
181
+ if (value === selected.value) return;
182
+ selected.value = value;
183
+ code.value = "";
184
+ password.value = "";
185
+ if (props.autofocus && value === "password") focusPassword();
186
+ }
187
+
188
+ async function attemptVerify(): Promise<void> {
189
+ if (props.loading || props.disabled) return;
190
+ const kind = selected.value;
191
+ // The submit buttons disable themselves on an incomplete proof;
192
+ // this guard keeps the Enter/auto-submit paths honest.
193
+ if (kind === "passkey") {
194
+ emit("verify", "passkey", "");
195
+ return;
196
+ }
197
+ if (kind === "password") {
198
+ if (!password.value) return;
199
+ emit("verify", "password", password.value);
200
+ return;
201
+ }
202
+ if (code.value.trim().length < 6) return;
203
+ emit("verify", kind, code.value.trim());
204
+ }
205
+
206
+ /** Consumer handles: `clearProof` empties the active field after a
207
+ * rejected proof; `focusPassword` re-arms the password entry's
208
+ * focus; `selectedFactor` reports the factor on stage (a step-two
209
+ * host needs the kind it just proved when the server does not echo
210
+ * it back). */
211
+ function clearProof() {
212
+ code.value = "";
213
+ password.value = "";
214
+ }
215
+
216
+ expose({
217
+ clearProof,
218
+ focusPassword,
219
+ selectedFactor: () => selected.value,
220
+ });
221
+
222
+ // ── derived strings ──────────────────────────────────────────────
223
+ const factorLabel = (kind: HkMfaFactor): string => {
224
+ if (kind === "passkey") return t("hikari::mfaVerify.factorPasskey", "Passkey");
225
+ if (kind === "totp") return t("hikari::mfaVerify.factorTotp", "TOTP authenticator app");
226
+ if (kind === "password") return t("hikari::mfaVerify.factorPassword", "Account password");
227
+ return t("hikari::mfaVerify.factorSms", "SMS code");
228
+ };
229
+
230
+ const titleText = computed(() => {
231
+ if (props.title !== undefined) return props.title;
232
+ return provenKind.value
233
+ ? t("hikari::mfaVerify.step2Title", "One more verification")
234
+ : t("hikari::mfaVerify.title", "Verify account");
235
+ });
236
+
237
+ const subtitleText = computed(() => {
238
+ if (props.subtitle !== undefined) return props.subtitle;
239
+ const name = props.accountName;
240
+ if (provenKind.value) {
241
+ const proven = factorLabel(provenKind.value);
242
+ return name
243
+ ? fill(t("hikari::mfaVerify.step2Subtitle",
244
+ "{proven} passed — choose one more method to finish signing in to {name}"),
245
+ { proven, name })
246
+ : fill(t("hikari::mfaVerify.step2SubtitleGeneric",
247
+ "Verified via {proven} — choose one more method to finish signing in"),
248
+ { proven });
249
+ }
250
+ return name
251
+ ? fill(t("hikari::mfaVerify.verifySubtitle",
252
+ "Verifying {name} — complete a sign-in method to continue"), { name })
253
+ : t("hikari::mfaVerify.subtitle", "This account requires an extra verification step");
254
+ });
255
+
256
+ /** Glyph per factor for the icon-only picker — the labels ride the
257
+ * tooltip. The password entry wears the asterisk, the dot-matrix
258
+ * sign-in input's own shape. */
259
+ const factorIcon = (kind: HkMfaFactor) => {
260
+ if (kind === "passkey") return <Fingerprint size={22} />;
261
+ if (kind === "totp") return <KeyRound size={22} />;
262
+ if (kind === "password") return <Asterisk size={22} />;
263
+ return <MessageSquareText size={22} />;
264
+ };
265
+
266
+ const pickerOptions = computed<HkIconButtonGroupOption[]>(() =>
267
+ knownFactors.value.map((kind) => {
268
+ // Two-proof step two: the proven factor is DONE — it stays in the
269
+ // picker pinned (success marker, disabled, "already verified"
270
+ // tooltip) so the user can see what they already presented, but
271
+ // it is not a candidate for the next proof.
272
+ if (kind === provenKind.value) {
273
+ return {
274
+ key: kind,
275
+ label: factorLabel(kind),
276
+ icon: factorIcon(kind),
277
+ disabled: true,
278
+ marker: "success" as const,
279
+ tooltip: t("hikari::mfaVerify.provenTooltip", "Already verified"),
280
+ };
281
+ }
282
+ return {
283
+ key: kind,
284
+ label: factorLabel(kind),
285
+ icon: factorIcon(kind),
286
+ };
287
+ }));
288
+
289
+ // Resolved per render: a runtime locale switch must reach the
290
+ // in-flight labels on the next render, not stay frozen at setup.
291
+ const busyLabel = () => t("hikari::mfaVerify.loading", "Verifying…");
292
+
293
+ return () => (
294
+ <HkAuthCard title={titleText.value} subtitle={subtitleText.value}>
295
+ {{
296
+ logo: () =>
297
+ slots.logo
298
+ ? slots.logo()
299
+ : props.logoSrc
300
+ ? <HkLogo size="lg" src={props.logoSrc} />
301
+ : null,
302
+ default: () => (
303
+ <>
304
+ {pickerOptions.value.length > 0 && (
305
+ <div class="s-mfa-factors">
306
+ <HkIconButtonGroup
307
+ mode="single"
308
+ variant="slider"
309
+ modelValue={selected.value}
310
+ onUpdate:modelValue={onSelect}
311
+ aria-label={titleText.value}
312
+ options={pickerOptions.value}
313
+ />
314
+ </div>
315
+ )}
316
+ {selected.value === "passkey" ? (
317
+ <>
318
+ {/* Exactly ONE primary action per factor — the passkey
319
+ ceremony IS the step-up, so pairing it with a generic
320
+ code submit drew two equal-weight buttons that ran
321
+ the same handler (user report 2026-09-18). */}
322
+ <HkAuthSubmitButton
323
+ label={props.loading ? busyLabel()
324
+ : t("hikari::mfaVerify.usePasskey", "Verify with Passkey")}
325
+ block
326
+ loading={props.loading}
327
+ disabled={props.loading || props.disabled}
328
+ doSubmit={attemptVerify}
329
+ />
330
+ {props.passkeyRetryHint && (
331
+ <HkAlert
332
+ variant="warning"
333
+ size="sm"
334
+ message={props.passkeyRetryHint}
335
+ />
336
+ )}
337
+ </>
338
+ ) : selected.value === "password" ? (
339
+ <>
340
+ {/* The password factor re-wears the exact sign-in
341
+ input (dot-matrix surface, hold-to-reveal). */}
342
+ <HkInput
343
+ ref={passwordRef}
344
+ variant="password"
345
+ modelValue={password.value}
346
+ onUpdate:modelValue={(v: string) => (password.value = v)}
347
+ name="mfa-verify-password"
348
+ autocomplete="current-password"
349
+ placeholder={t("hikari::mfaVerify.passwordPlaceholder", "Enter your password")}
350
+ disabled={props.loading || props.disabled}
351
+ submitOnEnter={attemptVerify}
352
+ />
353
+ <HkAuthSubmitButton
354
+ label={props.loading ? busyLabel()
355
+ : t("hikari::mfaVerify.verify", "Verify and sign in")}
356
+ block
357
+ loading={props.loading}
358
+ disabled={props.loading || props.disabled || !password.value}
359
+ doSubmit={attemptVerify}
360
+ />
361
+ </>
362
+ ) : (
363
+ <>
364
+ <HkOtpInput
365
+ modelValue={code.value}
366
+ onUpdate:modelValue={(v: string) => (code.value = v)}
367
+ length={6}
368
+ // SMS codes are handed out in the "123 456" shape; the
369
+ // split mirrors what the user is reading off the phone.
370
+ separated={selected.value === "sms"}
371
+ autofocus={props.autofocus}
372
+ ariaLabel={t("hikari::mfaVerify.codeFields", "Verification code")}
373
+ cellAriaLabel={(index: number) =>
374
+ fill(t("hikari::mfaVerify.codeCell", "Digit {index}"), { index: String(index) })}
375
+ hint={selected.value === "totp"
376
+ ? t("hikari::mfaVerify.totpHint", "Enter the 6-digit TOTP code")
377
+ : t("hikari::mfaVerify.smsHint", "Enter the 6-digit SMS code")}
378
+ disabled={props.loading || props.disabled}
379
+ autoSubmit
380
+ submitOnEnter={attemptVerify}
381
+ />
382
+ <HkAuthSubmitButton
383
+ label={props.loading ? busyLabel()
384
+ : t("hikari::mfaVerify.verify", "Verify and sign in")}
385
+ block
386
+ loading={props.loading}
387
+ disabled={props.loading || props.disabled || code.value.trim().length < 6}
388
+ doSubmit={attemptVerify}
389
+ />
390
+ </>
391
+ )}
392
+ {selected.value === "sms" && (
393
+ <HkButton
394
+ variant="ghost"
395
+ size="md"
396
+ block
397
+ disabled={props.resending || props.loading || props.disabled}
398
+ onClick={() => emit("resend")}
399
+ >
400
+ {props.resending ? busyLabel()
401
+ : t("hikari::mfaVerify.resend", "Resend code")}
402
+ </HkButton>
403
+ )}
404
+ <HkButton
405
+ variant="ghost"
406
+ size="md"
407
+ block
408
+ disabled={props.loading}
409
+ onClick={() => emit("back")}
410
+ >
411
+ {t("hikari::mfaVerify.back", "Back to sign in")}
412
+ </HkButton>
413
+ </>
414
+ ),
415
+ }}
416
+ </HkAuthCard>
417
+ );
418
+ },
419
+ });
420
+
421
+ export default HkMfaVerifyCard;
@@ -194,12 +194,25 @@
194
194
  margin: var(--space-6, 0.375rem) 0 0;
195
195
  font-size: var(--text-xs, 0.75rem);
196
196
  color: rgb(var(--color-error));
197
+ /* The messages belong to the row's visual system, and the row centers
198
+ * itself unconditionally (`.hk-otp` is a flex `justify-content: center`
199
+ * line with no opt-out). A paragraph left at the page's default `start`
200
+ * alignment therefore hugs the host card's left edge under a centered
201
+ * row — the deployed chest step-up panel showed the TOTP hint pinned
202
+ * left below centered cells (user screenshot, 2026-09-23). Both message
203
+ * variants follow the row's own centering, so a wrapped second line
204
+ * stays balanced too. Hosts that want start-aligned messages can still
205
+ * override the paragraphs from outside; the class carries no extra
206
+ * specificity, so a one-class host rule wins. */
207
+ text-align: center;
197
208
  }
198
209
 
199
210
  .hk-otp-hint {
200
211
  margin: var(--space-6, 0.375rem) 0 0;
201
212
  font-size: var(--text-xs, 0.75rem);
202
213
  color: rgb(var(--color-muted));
214
+ /* Same centering contract as the error message above. */
215
+ text-align: center;
203
216
  }
204
217
 
205
218
  /* Narrow viewports: shrink the ramps a step so a phone card shows the row
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Centering contract for the HkOtpInput messages.
10
+ *
11
+ * The cell row (`.hk-otp`) centers itself unconditionally — flex
12
+ * `justify-content: center` with no opt-out — and every host card around
13
+ * it (HkAuthCard header, chest's step-up panel) centers too. The hint and
14
+ * error paragraphs therefore must carry their own `text-align: center`:
15
+ * left at the page's default `start`, the TOTP hint hugged the card's
16
+ * left edge under centered cells (user screenshot on the deployed chest
17
+ * step-up panel, 2026-09-23).
18
+ *
19
+ * The assertions run on the COMPILED sheet, not the source text, so a
20
+ * declaration cannot hide behind SCSS nesting, and comments are stripped
21
+ * first so a commented-out `text-align` cannot keep a hollow guard green.
22
+ */
23
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
24
+
25
+ function otpCss(): string {
26
+ const file = resolve(componentDir, "HkOtpInput.scss");
27
+ return compile(file, { style: "expanded", loadPaths: [componentDir] }).css.replace(
28
+ /\/\*[\s\S]*?\*\//g,
29
+ "",
30
+ );
31
+ }
32
+
33
+ function ruleBody(css: string, selector: string): string | null {
34
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ const m = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
36
+ return m ? m[1]! : null;
37
+ }
38
+
39
+ describe("HkOtpInput message centering", () => {
40
+ const css = otpCss();
41
+
42
+ it("compiles", () => {
43
+ expect(css.length).toBeGreaterThan(0);
44
+ // Sanity anchors so a renamed file cannot hollow this guard.
45
+ expect(css).toContain(".hk-otp-hint");
46
+ expect(css).toContain(".hk-otp-error-msg");
47
+ });
48
+
49
+ it("centers the hint under the centered cell row", () => {
50
+ const body = ruleBody(css, ".hk-otp-hint");
51
+ expect(body, "the hint rule exists").not.toBeNull();
52
+ expect(body).toContain("text-align: center");
53
+ });
54
+
55
+ it("centers the error message the same way", () => {
56
+ const body = ruleBody(css, ".hk-otp-error-msg");
57
+ expect(body, "the error-message rule exists").not.toBeNull();
58
+ expect(body).toContain("text-align: center");
59
+ });
60
+
61
+ it("keeps the row's own centering unconditional", () => {
62
+ // The contract above only makes sense while the row stays centered:
63
+ // if the row ever grows an alignment opt-out, these messages need to
64
+ // follow it instead of pinning `center` outright.
65
+ const body = ruleBody(css, ".hk-otp");
66
+ expect(body, "the row rule exists").not.toBeNull();
67
+ expect(body).toContain("justify-content: center");
68
+ });
69
+ });
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "إعادة الترتيب — {label}",
221
221
  "hikari::modal.hiddenLayers": "الطبقات المخفية",
222
222
  "hikari::theme.modeTabHint": "هذه التبويبات تختار لوحة الألوان التي تحرّرها؛ لتبديل الوضع الفاتح/الداكن للتطبيق كله استخدم شريط الأوضاع أعلى نافذة السمة.",
223
- "hikari::emptyState.loading": "جارٍ التحميل"
223
+ "hikari::emptyState.loading": "جارٍ التحميل",
224
+ "hikari::mfaVerify.title": "التحقق من الحساب",
225
+ "hikari::mfaVerify.verifySubtitle": "جارٍ التحقق من {name} — أكمل طريقة تسجيل الدخول للمتابعة",
226
+ "hikari::mfaVerify.subtitle": "يتطلب هذا الحساب خطوة تحقق إضافية",
227
+ "hikari::mfaVerify.step2Title": "تحقق إضافي واحد",
228
+ "hikari::mfaVerify.step2Subtitle": "تم التحقق عبر {proven} — اختر طريقة أخرى لإكمال تسجيل دخول {name}",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "تم التحقق عبر {proven} — اختر طريقة أخرى لإكمال تسجيل الدخول",
230
+ "hikari::mfaVerify.factorPasskey": "مفتاح المرور",
231
+ "hikari::mfaVerify.factorTotp": "تطبيق مصادقة TOTP",
232
+ "hikari::mfaVerify.factorSms": "رمز SMS",
233
+ "hikari::mfaVerify.factorPassword": "كلمة مرور الحساب",
234
+ "hikari::mfaVerify.provenTooltip": "تم التحقق",
235
+ "hikari::mfaVerify.codeFields": "رمز التحقق",
236
+ "hikari::mfaVerify.codeCell": "الخانة {index}",
237
+ "hikari::mfaVerify.totpHint": "أدخل رمز TOTP المكوّن من 6 أرقام",
238
+ "hikari::mfaVerify.smsHint": "أدخل رمز الرسالة المكوّن من 6 أرقام",
239
+ "hikari::mfaVerify.passwordPlaceholder": "أدخل كلمة المرور",
240
+ "hikari::mfaVerify.verify": "تحقق وسجّل الدخول",
241
+ "hikari::mfaVerify.usePasskey": "التحقق بمفتاح المرور",
242
+ "hikari::mfaVerify.resend": "إعادة إرسال الرمز",
243
+ "hikari::mfaVerify.back": "العودة لتسجيل الدخول",
244
+ "hikari::mfaVerify.loading": "جارٍ التحميل..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "Neu anordnen — {label}",
221
221
  "hikari::modal.hiddenLayers": "Verdeckte Ebenen",
222
222
  "hikari::theme.modeTabHint": "Diese Reiter wählen die zu bearbeitende Palette; die appweite Hell/Dunkel-Umschaltung liegt im Modusstreifen des Themen-Popups.",
223
- "hikari::emptyState.loading": "Wird geladen"
223
+ "hikari::emptyState.loading": "Wird geladen",
224
+ "hikari::mfaVerify.title": "Konto verifizieren",
225
+ "hikari::mfaVerify.verifySubtitle": "{name} wird verifiziert — Anmeldung abschließen, um fortzufahren",
226
+ "hikari::mfaVerify.subtitle": "Dieses Konto erfordert einen zusätzlichen Verifizierungsschritt",
227
+ "hikari::mfaVerify.step2Title": "Noch eine Verifizierung",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} bestanden — wählen Sie eine weitere Methode, um die Anmeldung von {name} abzuschließen",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} bestanden — wählen Sie eine weitere Methode, um die Anmeldung abzuschließen",
230
+ "hikari::mfaVerify.factorPasskey": "Passkey",
231
+ "hikari::mfaVerify.factorTotp": "TOTP-Authenticator-App",
232
+ "hikari::mfaVerify.factorSms": "SMS-Code",
233
+ "hikari::mfaVerify.factorPassword": "Kontopasswort",
234
+ "hikari::mfaVerify.provenTooltip": "Bereits verifiziert",
235
+ "hikari::mfaVerify.codeFields": "Bestätigungscode",
236
+ "hikari::mfaVerify.codeCell": "Ziffer {index}",
237
+ "hikari::mfaVerify.totpHint": "6-stelligen TOTP-Code eingeben",
238
+ "hikari::mfaVerify.smsHint": "6-stelligen SMS-Code eingeben",
239
+ "hikari::mfaVerify.passwordPlaceholder": "Passwort eingeben",
240
+ "hikari::mfaVerify.verify": "Verifizieren und anmelden",
241
+ "hikari::mfaVerify.usePasskey": "Mit Passkey verifizieren",
242
+ "hikari::mfaVerify.resend": "Code erneut senden",
243
+ "hikari::mfaVerify.back": "Zurück zur Anmeldung",
244
+ "hikari::mfaVerify.loading": "Laden..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "Reorder {label}",
221
221
  "hikari::modal.hiddenLayers": "Hidden layers",
222
222
  "hikari::theme.modeTabHint": "These tabs pick which palette you are EDITING; to switch the app-wide light/dark, use the mode strip at the top of the theme popup.",
223
- "hikari::emptyState.loading": "Loading"
223
+ "hikari::emptyState.loading": "Loading",
224
+ "hikari::mfaVerify.title": "Verify account",
225
+ "hikari::mfaVerify.verifySubtitle": "Verifying {name} — complete a sign-in method to continue",
226
+ "hikari::mfaVerify.subtitle": "This account requires an extra verification step",
227
+ "hikari::mfaVerify.step2Title": "One more verification",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} passed — choose one more method to finish signing in to {name}",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "Verified via {proven} — choose one more method to finish signing in",
230
+ "hikari::mfaVerify.factorPasskey": "Passkey",
231
+ "hikari::mfaVerify.factorTotp": "TOTP authenticator app",
232
+ "hikari::mfaVerify.factorSms": "SMS code",
233
+ "hikari::mfaVerify.factorPassword": "Account password",
234
+ "hikari::mfaVerify.provenTooltip": "Already verified",
235
+ "hikari::mfaVerify.codeFields": "Verification code",
236
+ "hikari::mfaVerify.codeCell": "Digit {index}",
237
+ "hikari::mfaVerify.totpHint": "Enter the 6-digit TOTP code",
238
+ "hikari::mfaVerify.smsHint": "Enter the 6-digit SMS code",
239
+ "hikari::mfaVerify.passwordPlaceholder": "Enter your password",
240
+ "hikari::mfaVerify.verify": "Verify and sign in",
241
+ "hikari::mfaVerify.usePasskey": "Verify with Passkey",
242
+ "hikari::mfaVerify.resend": "Resend code",
243
+ "hikari::mfaVerify.back": "Back to sign in",
244
+ "hikari::mfaVerify.loading": "Loading..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "Reordenar — {label}",
221
221
  "hikari::modal.hiddenLayers": "Capas ocultas",
222
222
  "hikari::theme.modeTabHint": "Estas pestañas eligen la paleta que estás EDITANDO; para cambiar el modo claro/oscuro de toda la app, usa la franja de modos de la parte superior del panel de temas.",
223
- "hikari::emptyState.loading": "Cargando"
223
+ "hikari::emptyState.loading": "Cargando",
224
+ "hikari::mfaVerify.title": "Verificar cuenta",
225
+ "hikari::mfaVerify.verifySubtitle": "Verificando {name}: completa un método de acceso para continuar",
226
+ "hikari::mfaVerify.subtitle": "Esta cuenta requiere un paso de verificación adicional",
227
+ "hikari::mfaVerify.step2Title": "Una verificación más",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} superado: elige otro método para terminar de iniciar sesión en {name}",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} superado: elige otro método para terminar de iniciar sesión",
230
+ "hikari::mfaVerify.factorPasskey": "Llave de acceso",
231
+ "hikari::mfaVerify.factorTotp": "Aplicación de autenticación TOTP",
232
+ "hikari::mfaVerify.factorSms": "Código SMS",
233
+ "hikari::mfaVerify.factorPassword": "Contraseña de la cuenta",
234
+ "hikari::mfaVerify.provenTooltip": "Ya verificado",
235
+ "hikari::mfaVerify.codeFields": "Código de verificación",
236
+ "hikari::mfaVerify.codeCell": "Dígito {index}",
237
+ "hikari::mfaVerify.totpHint": "Introduce el código TOTP de 6 dígitos",
238
+ "hikari::mfaVerify.smsHint": "Introduce el código SMS de 6 dígitos",
239
+ "hikari::mfaVerify.passwordPlaceholder": "Introduce la contraseña",
240
+ "hikari::mfaVerify.verify": "Verificar e iniciar sesión",
241
+ "hikari::mfaVerify.usePasskey": "Verificar con llave de acceso",
242
+ "hikari::mfaVerify.resend": "Reenviar código",
243
+ "hikari::mfaVerify.back": "Volver al inicio de sesión",
244
+ "hikari::mfaVerify.loading": "Cargando..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "Réorganiser — {label}",
221
221
  "hikari::modal.hiddenLayers": "Couches masquées",
222
222
  "hikari::theme.modeTabHint": "Ces onglets choisissent la palette éditée ; pour basculer clair/sombre dans toute l’app, utilisez la barre de mode du menu de thème.",
223
- "hikari::emptyState.loading": "Chargement"
223
+ "hikari::emptyState.loading": "Chargement",
224
+ "hikari::mfaVerify.title": "Vérifier le compte",
225
+ "hikari::mfaVerify.verifySubtitle": "Vérification de {name} — terminez une méthode de connexion pour continuer",
226
+ "hikari::mfaVerify.subtitle": "Ce compte nécessite une étape de vérification supplémentaire",
227
+ "hikari::mfaVerify.step2Title": "Une vérification de plus",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} validé — choisissez une autre méthode pour terminer la connexion de {name}",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} validé — choisissez une autre méthode pour terminer la connexion",
230
+ "hikari::mfaVerify.factorPasskey": "Clé d'accès",
231
+ "hikari::mfaVerify.factorTotp": "Application d'authentification TOTP",
232
+ "hikari::mfaVerify.factorSms": "Code SMS",
233
+ "hikari::mfaVerify.factorPassword": "Mot de passe du compte",
234
+ "hikari::mfaVerify.provenTooltip": "Déjà vérifié",
235
+ "hikari::mfaVerify.codeFields": "Code de vérification",
236
+ "hikari::mfaVerify.codeCell": "Chiffre {index}",
237
+ "hikari::mfaVerify.totpHint": "Saisissez le code TOTP à 6 chiffres",
238
+ "hikari::mfaVerify.smsHint": "Saisissez le code SMS à 6 chiffres",
239
+ "hikari::mfaVerify.passwordPlaceholder": "Saisissez votre mot de passe",
240
+ "hikari::mfaVerify.verify": "Vérifier et se connecter",
241
+ "hikari::mfaVerify.usePasskey": "Vérifier avec une clé d'accès",
242
+ "hikari::mfaVerify.resend": "Renvoyer le code",
243
+ "hikari::mfaVerify.back": "Retour à la connexion",
244
+ "hikari::mfaVerify.loading": "Chargement..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "並べ替え — {label}",
221
221
  "hikari::modal.hiddenLayers": "非表示の階層",
222
222
  "hikari::theme.modeTabHint": "このタブは編集対象のパレットを選ぶだけです。アプリ全体の明/暗はテーマポップアップ上部のモードストリップで切り替えてください。",
223
- "hikari::emptyState.loading": "読み込み中"
223
+ "hikari::emptyState.loading": "読み込み中",
224
+ "hikari::mfaVerify.title": "アカウントの確認",
225
+ "hikari::mfaVerify.verifySubtitle": "{name} の本人確認を続行します",
226
+ "hikari::mfaVerify.subtitle": "このアカウントでは追加の認証手順が必要です",
227
+ "hikari::mfaVerify.step2Title": "あと 1 つの確認が必要です",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} は確認済み — {name} のサインインを完了するため、もう 1 つの方法を選択してください",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} は確認済み — サインインを完了するため、もう 1 つの方法を選択してください",
230
+ "hikari::mfaVerify.factorPasskey": "パスキー",
231
+ "hikari::mfaVerify.factorTotp": "TOTP 認証アプリ",
232
+ "hikari::mfaVerify.factorSms": "SMS コード",
233
+ "hikari::mfaVerify.factorPassword": "アカウントのパスワード",
234
+ "hikari::mfaVerify.provenTooltip": "確認済み",
235
+ "hikari::mfaVerify.codeFields": "確認コード",
236
+ "hikari::mfaVerify.codeCell": "{index} 桁目",
237
+ "hikari::mfaVerify.totpHint": "TOTP 認証アプリに表示される 6 桁のコードを入力",
238
+ "hikari::mfaVerify.smsHint": "6 桁のSMSコードを入力",
239
+ "hikari::mfaVerify.passwordPlaceholder": "パスワードを入力",
240
+ "hikari::mfaVerify.verify": "認証してログイン",
241
+ "hikari::mfaVerify.usePasskey": "パスキーで認証",
242
+ "hikari::mfaVerify.resend": "コードを再送",
243
+ "hikari::mfaVerify.back": "ログインに戻る",
244
+ "hikari::mfaVerify.loading": "読み込み中..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "순서 변경 — {label}",
221
221
  "hikari::modal.hiddenLayers": "숨겨진 레이어",
222
222
  "hikari::theme.modeTabHint": "이 탭은 편집할 팔레트를 고를 뿐입니다. 앱 전체 밝기/어둠은 테마 팝업 상단의 모드 스트립에서 바꾸세요.",
223
- "hikari::emptyState.loading": "불러오는 중"
223
+ "hikari::emptyState.loading": "불러오는 중",
224
+ "hikari::mfaVerify.title": "계정 확인",
225
+ "hikari::mfaVerify.verifySubtitle": "{name} 님의 신원을 확인하여 로그인을 계속하세요",
226
+ "hikari::mfaVerify.subtitle": "이 계정은 추가 인증 절차가 필요합니다",
227
+ "hikari::mfaVerify.step2Title": "한 가지 더 인증이 필요합니다",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} 통과 — {name} 님의 로그인을 완료하려면 다른 방법을 하나 더 선택하세요",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} 통과 — 로그인을 완료하려면 다른 방법을 하나 더 선택하세요",
230
+ "hikari::mfaVerify.factorPasskey": "패스키",
231
+ "hikari::mfaVerify.factorTotp": "TOTP 인증 앱",
232
+ "hikari::mfaVerify.factorSms": "SMS 코드",
233
+ "hikari::mfaVerify.factorPassword": "계정 비밀번호",
234
+ "hikari::mfaVerify.provenTooltip": "인증 완료",
235
+ "hikari::mfaVerify.codeFields": "인증 코드",
236
+ "hikari::mfaVerify.codeCell": "{index}번째 자리",
237
+ "hikari::mfaVerify.totpHint": "TOTP 인증 앱에 표시된 6자리 코드를 입력하세요",
238
+ "hikari::mfaVerify.smsHint": "6자리 SMS 코드를 입력하세요",
239
+ "hikari::mfaVerify.passwordPlaceholder": "비밀번호를 입력",
240
+ "hikari::mfaVerify.verify": "인증 후 로그인",
241
+ "hikari::mfaVerify.usePasskey": "패스키로 인증",
242
+ "hikari::mfaVerify.resend": "코드 재전송",
243
+ "hikari::mfaVerify.back": "로그인으로 돌아가기",
244
+ "hikari::mfaVerify.loading": "로딩 중..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "Reordenar — {label}",
221
221
  "hikari::modal.hiddenLayers": "Camadas ocultas",
222
222
  "hikari::theme.modeTabHint": "Estas abas escolhem a paleta que você está EDITANDO; para alternar o claro/escuro do app inteiro, use a faixa de modos no topo do painel de temas.",
223
- "hikari::emptyState.loading": "Carregando"
223
+ "hikari::emptyState.loading": "Carregando",
224
+ "hikari::mfaVerify.title": "Verificar conta",
225
+ "hikari::mfaVerify.verifySubtitle": "Verificando {name} — conclua um método de acesso para continuar",
226
+ "hikari::mfaVerify.subtitle": "Esta conta exige uma etapa adicional de verificação",
227
+ "hikari::mfaVerify.step2Title": "Mais uma verificação",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} aprovado — escolha mais um método para concluir o login de {name}",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} aprovado — escolha mais um método para concluir o login",
230
+ "hikari::mfaVerify.factorPasskey": "Chave de acesso",
231
+ "hikari::mfaVerify.factorTotp": "Aplicativo autenticador TOTP",
232
+ "hikari::mfaVerify.factorSms": "Código SMS",
233
+ "hikari::mfaVerify.factorPassword": "Senha da conta",
234
+ "hikari::mfaVerify.provenTooltip": "Já verificado",
235
+ "hikari::mfaVerify.codeFields": "Código de verificação",
236
+ "hikari::mfaVerify.codeCell": "Dígito {index}",
237
+ "hikari::mfaVerify.totpHint": "Digite o código TOTP de 6 dígitos",
238
+ "hikari::mfaVerify.smsHint": "Insira o código SMS de 6 dígitos",
239
+ "hikari::mfaVerify.passwordPlaceholder": "Digite sua senha",
240
+ "hikari::mfaVerify.verify": "Verificar e entrar",
241
+ "hikari::mfaVerify.usePasskey": "Verificar com chave de acesso",
242
+ "hikari::mfaVerify.resend": "Reenviar código",
243
+ "hikari::mfaVerify.back": "Voltar ao login",
244
+ "hikari::mfaVerify.loading": "Carregando..."
224
245
  }
@@ -220,5 +220,26 @@
220
220
  "hikari::tagInput.reorder": "Изменить порядок — {label}",
221
221
  "hikari::modal.hiddenLayers": "Скрытые слои",
222
222
  "hikari::theme.modeTabHint": "Вкладки выбирают редактируемую палитру; общесистемное переключение светлой/тёмной — в полосе режимов вверху меню темы.",
223
- "hikari::emptyState.loading": "Загрузка"
223
+ "hikari::emptyState.loading": "Загрузка",
224
+ "hikari::mfaVerify.title": "Подтверждение аккаунта",
225
+ "hikari::mfaVerify.verifySubtitle": "Проверка {name} — завершите вход, чтобы продолжить",
226
+ "hikari::mfaVerify.subtitle": "Для этой учётной записи требуется дополнительный шаг проверки",
227
+ "hikari::mfaVerify.step2Title": "Ещё одна проверка",
228
+ "hikari::mfaVerify.step2Subtitle": "{proven} пройден — выберите ещё один способ, чтобы завершить вход {name}",
229
+ "hikari::mfaVerify.step2SubtitleGeneric": "{proven} пройден — выберите ещё один способ, чтобы завершить вход",
230
+ "hikari::mfaVerify.factorPasskey": "Ключ доступа",
231
+ "hikari::mfaVerify.factorTotp": "Приложение-аутентификатор TOTP",
232
+ "hikari::mfaVerify.factorSms": "SMS-код",
233
+ "hikari::mfaVerify.factorPassword": "Пароль аккаунта",
234
+ "hikari::mfaVerify.provenTooltip": "Уже подтверждено",
235
+ "hikari::mfaVerify.codeFields": "Код подтверждения",
236
+ "hikari::mfaVerify.codeCell": "Цифра {index}",
237
+ "hikari::mfaVerify.totpHint": "Введите 6-значный код TOTP",
238
+ "hikari::mfaVerify.smsHint": "Введите 6-значный код из СМС",
239
+ "hikari::mfaVerify.passwordPlaceholder": "Введите пароль",
240
+ "hikari::mfaVerify.verify": "Подтвердить и войти",
241
+ "hikari::mfaVerify.usePasskey": "Подтвердить ключом доступа",
242
+ "hikari::mfaVerify.resend": "Отправить код повторно",
243
+ "hikari::mfaVerify.back": "Вернуться к входу",
244
+ "hikari::mfaVerify.loading": "Загрузка..."
224
245
  }
@@ -224,5 +224,26 @@
224
224
  "hikari::tagInput.reorder": "拖动排序 — {label}",
225
225
  "hikari::modal.hiddenLayers": "隐藏层级",
226
226
  "hikari::theme.modeTabHint": "切换这里编辑的是对应模式的调色板;要切换应用整体深/浅,请用主题弹窗顶部的模式条。",
227
- "hikari::emptyState.loading": "加载中"
227
+ "hikari::emptyState.loading": "加载中",
228
+ "hikari::mfaVerify.title": "验证账户",
229
+ "hikari::mfaVerify.verifySubtitle": "正在验证 {name} 的身份,请完成验证以继续登录",
230
+ "hikari::mfaVerify.subtitle": "此账户已启用额外安全验证,请完成验证以继续",
231
+ "hikari::mfaVerify.step2Title": "还需再选一种验证",
232
+ "hikari::mfaVerify.step2Subtitle": "{proven} 已通过 — 请再选一种方式完成 {name} 的登录",
233
+ "hikari::mfaVerify.step2SubtitleGeneric": "已通过 {proven} — 请再选一种方式完成登录",
234
+ "hikari::mfaVerify.factorPasskey": "Passkey",
235
+ "hikari::mfaVerify.factorTotp": "TOTP 验证器应用",
236
+ "hikari::mfaVerify.factorSms": "短信验证码",
237
+ "hikari::mfaVerify.factorPassword": "账户密码",
238
+ "hikari::mfaVerify.provenTooltip": "已通过验证",
239
+ "hikari::mfaVerify.codeFields": "验证码",
240
+ "hikari::mfaVerify.codeCell": "第 {index} 位",
241
+ "hikari::mfaVerify.totpHint": "输入 TOTP 验证器显示的 6 位动态验证码",
242
+ "hikari::mfaVerify.smsHint": "输入 6 位短信验证码",
243
+ "hikari::mfaVerify.passwordPlaceholder": "输入账户密码",
244
+ "hikari::mfaVerify.verify": "验证并登录",
245
+ "hikari::mfaVerify.usePasskey": "使用 Passkey 验证",
246
+ "hikari::mfaVerify.resend": "重新发送验证码",
247
+ "hikari::mfaVerify.back": "返回重新登录",
248
+ "hikari::mfaVerify.loading": "加载中..."
228
249
  }
@@ -224,5 +224,26 @@
224
224
  "hikari::tagInput.reorder": "拖曳排序 — {label}",
225
225
  "hikari::modal.hiddenLayers": "隱藏層級",
226
226
  "hikari::theme.modeTabHint": "切換這裡編輯的是對應模式的調色盤;要切換應用整體深/淺,請用主題彈窗頂部的模式條。",
227
- "hikari::emptyState.loading": "載入中"
227
+ "hikari::emptyState.loading": "載入中",
228
+ "hikari::mfaVerify.title": "驗證帳戶",
229
+ "hikari::mfaVerify.verifySubtitle": "正在驗證 {name} 的身分,請完成驗證以繼續登入",
230
+ "hikari::mfaVerify.subtitle": "此帳戶已啟用額外安全驗證,請完成驗證以繼續",
231
+ "hikari::mfaVerify.step2Title": "還需再選一種驗證",
232
+ "hikari::mfaVerify.step2Subtitle": "{proven} 已通過 — 請再選一種方式完成 {name} 的登入",
233
+ "hikari::mfaVerify.step2SubtitleGeneric": "已通過 {proven} — 請再選一種方式完成登入",
234
+ "hikari::mfaVerify.factorPasskey": "Passkey",
235
+ "hikari::mfaVerify.factorTotp": "TOTP 驗證器應用程式",
236
+ "hikari::mfaVerify.factorSms": "簡訊驗證碼",
237
+ "hikari::mfaVerify.factorPassword": "帳戶密碼",
238
+ "hikari::mfaVerify.provenTooltip": "已通過驗證",
239
+ "hikari::mfaVerify.codeFields": "驗證碼",
240
+ "hikari::mfaVerify.codeCell": "第 {index} 位",
241
+ "hikari::mfaVerify.totpHint": "輸入 TOTP 驗證器顯示的 6 位動態驗證碼",
242
+ "hikari::mfaVerify.smsHint": "輸入 6 位簡訊驗證碼",
243
+ "hikari::mfaVerify.passwordPlaceholder": "輸入帳戶密碼",
244
+ "hikari::mfaVerify.verify": "驗證並登入",
245
+ "hikari::mfaVerify.usePasskey": "使用 Passkey 驗證",
246
+ "hikari::mfaVerify.resend": "重新傳送驗證碼",
247
+ "hikari::mfaVerify.back": "返回重新登入",
248
+ "hikari::mfaVerify.loading": "加載中..."
228
249
  }
package/src/index.ts CHANGED
@@ -404,6 +404,12 @@ export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
404
404
  export { default as HAuthMethodList } from "./components/HkAuthMethodList";
405
405
  export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
406
406
  export { default as HAuthSubmitButton } from "./components/HkAuthSubmitButton";
407
+ export {
408
+ HkMfaVerifyCard as HMfaVerifyCard,
409
+ hkIsMfaFactor as isMfaFactor,
410
+ hkPreferredMfaFactor as preferredMfaFactor,
411
+ type HkMfaFactor as MfaFactor,
412
+ } from "./components/HkMfaVerifyCard";
407
413
  export { usePageTitle, useRouteTitle } from "./composables/usePageTitle";
408
414
  export { provideActionBar, useActionBar } from "./composables/useActionBar";
409
415
  export type { ActionBarRenderer } from "./composables/useActionBar";