@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.55",
3
+ "version": "0.55.57",
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
+ });