@celestia-island/hikari 0.55.55 → 0.55.57
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/HkModal.stepflowswap.test.tsx +28 -37
- package/src/components/HkModal.tsx +7 -7
- package/src/components/HkOtpInput.scss +13 -0
- package/src/components/HkOtpInput.styles.test.ts +69 -0
- package/src/components/HkStepFlow.test.tsx +1 -1
- package/src/components/HkStepFlow.tsx +20 -12
- package/src/composables/useSizeMorph.test.ts +39 -0
- package/src/composables/useSizeMorph.ts +62 -1
- 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
|
+
});
|