@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 +1 -1
- package/src/components/HkMfaVerifyCard.scss +21 -0
- package/src/components/HkMfaVerifyCard.test.ts +292 -0
- package/src/components/HkMfaVerifyCard.tsx +421 -0
- package/src/components/HkOtpInput.scss +13 -0
- package/src/components/HkOtpInput.styles.test.ts +69 -0
- package/src/i18n/locales/ar/components.json +22 -1
- package/src/i18n/locales/de/components.json +22 -1
- package/src/i18n/locales/en/components.json +22 -1
- package/src/i18n/locales/es/components.json +22 -1
- package/src/i18n/locales/fr/components.json +22 -1
- package/src/i18n/locales/ja/components.json +22 -1
- package/src/i18n/locales/ko/components.json +22 -1
- package/src/i18n/locales/pt/components.json +22 -1
- package/src/i18n/locales/ru/components.json +22 -1
- package/src/i18n/locales/zh-Hans/components.json +22 -1
- package/src/i18n/locales/zh-Hant/components.json +22 -1
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -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";
|