@yackey-labs/yauth-ui-vue 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ startAuthentication,
4
+ startRegistration,
5
+ } from "@simplewebauthn/browser";
6
+ import type { AuthUser } from "@yackey-labs/yauth-shared";
7
+ import { ref } from "vue";
8
+ import { useYAuth } from "../provider";
9
+
10
+ const props = defineProps<{
11
+ mode: "login" | "register";
12
+ email?: string;
13
+ onSuccess?: (user: AuthUser) => void;
14
+ onError?: (error: Error) => void;
15
+ }>();
16
+
17
+ const { client } = useYAuth();
18
+ const error = ref<string | null>(null);
19
+ const loading = ref(false);
20
+
21
+ const handleLogin = async () => {
22
+ const beginResult = await client.passkey.loginBegin(props.email || undefined);
23
+ const rcr = beginResult.options as { publicKey: unknown };
24
+ const credential = await startAuthentication({
25
+ optionsJSON: rcr.publicKey as Parameters<
26
+ typeof startAuthentication
27
+ >[0]["optionsJSON"],
28
+ });
29
+ await client.passkey.loginFinish(beginResult.challenge_id, credential);
30
+ const session = await client.getSession();
31
+ props.onSuccess?.(session.user);
32
+ };
33
+
34
+ const handleRegister = async () => {
35
+ const ccr = (await client.passkey.registerBegin()) as {
36
+ publicKey: unknown;
37
+ };
38
+ const credential = await startRegistration({
39
+ optionsJSON: ccr.publicKey as Parameters<
40
+ typeof startRegistration
41
+ >[0]["optionsJSON"],
42
+ });
43
+ await client.passkey.registerFinish(credential, "Passkey");
44
+ props.onSuccess?.(undefined as unknown as AuthUser);
45
+ };
46
+
47
+ const handleClick = async () => {
48
+ error.value = null;
49
+ loading.value = true;
50
+
51
+ try {
52
+ if (props.mode === "login") {
53
+ await handleLogin();
54
+ } else {
55
+ await handleRegister();
56
+ }
57
+ } catch (err) {
58
+ const e = err instanceof Error ? err : new Error(String(err));
59
+ console.error("[yauth] Passkey error:", e);
60
+ const message =
61
+ e.name === "NotAllowedError"
62
+ ? "Passkey authentication was cancelled or not available on this device."
63
+ : e.message;
64
+ error.value = message;
65
+ props.onError?.(e);
66
+ } finally {
67
+ loading.value = false;
68
+ }
69
+ };
70
+
71
+ const buttonLabel = () => {
72
+ if (loading.value) {
73
+ return props.mode === "login" ? "Authenticating..." : "Registering...";
74
+ }
75
+ return props.mode === "login" ? "Sign in with passkey" : "Register passkey";
76
+ };
77
+ </script>
78
+
79
+ <template>
80
+ <div class="space-y-2">
81
+ <div
82
+ v-if="error"
83
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
84
+ >
85
+ {{ error }}
86
+ </div>
87
+
88
+ <button
89
+ class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
90
+ type="button"
91
+ :disabled="loading"
92
+ @click="handleClick"
93
+ >
94
+ {{ buttonLabel() }}
95
+ </button>
96
+ </div>
97
+ </template>
@@ -0,0 +1,423 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from "vue";
3
+ import { useYAuth } from "../provider";
4
+
5
+ const { client, user, loading: userLoading } = useYAuth();
6
+
7
+ // Passkeys
8
+ const passkeys = ref<
9
+ Array<{ id: string; name: string | null; created_at: string }>
10
+ >([]);
11
+ const passkeysLoading = ref(true);
12
+ const passkeyError = ref<string | null>(null);
13
+ const deletingPasskey = ref<string | null>(null);
14
+
15
+ // OAuth accounts
16
+ const oauthAccounts = ref<Array<{ provider: string; created_at: string }>>([]);
17
+ const oauthLoading = ref(true);
18
+ const oauthError = ref<string | null>(null);
19
+ const unlinkingOAuth = ref<string | null>(null);
20
+
21
+ // MFA state
22
+ const mfaUri = ref("");
23
+ const mfaSecret = ref("");
24
+ const mfaCode = ref("");
25
+ const mfaBackupCodes = ref<string[]>([]);
26
+ const mfaStep = ref<"idle" | "setup" | "confirm" | "done">("idle");
27
+ const mfaError = ref<string | null>(null);
28
+ const mfaLoading = ref(false);
29
+
30
+ const fetchPasskeys = async () => {
31
+ passkeysLoading.value = true;
32
+ try {
33
+ passkeys.value = await client.passkey.list();
34
+ } catch {
35
+ passkeys.value = [];
36
+ } finally {
37
+ passkeysLoading.value = false;
38
+ }
39
+ };
40
+
41
+ const fetchOAuthAccounts = async () => {
42
+ oauthLoading.value = true;
43
+ try {
44
+ oauthAccounts.value = await client.oauth.accounts();
45
+ } catch {
46
+ oauthAccounts.value = [];
47
+ } finally {
48
+ oauthLoading.value = false;
49
+ }
50
+ };
51
+
52
+ onMounted(() => {
53
+ fetchPasskeys();
54
+ fetchOAuthAccounts();
55
+ });
56
+
57
+ const handleDeletePasskey = async (id: string) => {
58
+ passkeyError.value = null;
59
+ deletingPasskey.value = id;
60
+
61
+ try {
62
+ await client.passkey.delete(id);
63
+ await fetchPasskeys();
64
+ } catch (err) {
65
+ const e = err instanceof Error ? err : new Error(String(err));
66
+ passkeyError.value = e.message;
67
+ } finally {
68
+ deletingPasskey.value = null;
69
+ }
70
+ };
71
+
72
+ const handleUnlinkOAuth = async (provider: string) => {
73
+ oauthError.value = null;
74
+ unlinkingOAuth.value = provider;
75
+
76
+ try {
77
+ await client.oauth.unlink(provider);
78
+ await fetchOAuthAccounts();
79
+ } catch (err) {
80
+ const e = err instanceof Error ? err : new Error(String(err));
81
+ oauthError.value = e.message;
82
+ } finally {
83
+ unlinkingOAuth.value = null;
84
+ }
85
+ };
86
+
87
+ const handleMfaBegin = async () => {
88
+ mfaError.value = null;
89
+ mfaLoading.value = true;
90
+
91
+ try {
92
+ const result = await client.mfa.setup();
93
+ mfaUri.value = result.otpauth_url;
94
+ mfaSecret.value = result.secret;
95
+ mfaBackupCodes.value = result.backup_codes;
96
+ mfaStep.value = "confirm";
97
+ } catch (err) {
98
+ const e = err instanceof Error ? err : new Error(String(err));
99
+ mfaError.value = e.message;
100
+ } finally {
101
+ mfaLoading.value = false;
102
+ }
103
+ };
104
+
105
+ const handleMfaConfirm = async (e: Event) => {
106
+ e.preventDefault();
107
+ mfaError.value = null;
108
+ mfaLoading.value = true;
109
+
110
+ try {
111
+ await client.mfa.confirm(mfaCode.value);
112
+ mfaStep.value = "done";
113
+ } catch (err) {
114
+ const e = err instanceof Error ? err : new Error(String(err));
115
+ mfaError.value = e.message;
116
+ } finally {
117
+ mfaLoading.value = false;
118
+ }
119
+ };
120
+
121
+ const handleMfaDisable = async () => {
122
+ mfaError.value = null;
123
+ mfaLoading.value = true;
124
+
125
+ try {
126
+ await client.mfa.disable();
127
+ mfaStep.value = "idle";
128
+ mfaUri.value = "";
129
+ mfaSecret.value = "";
130
+ mfaCode.value = "";
131
+ mfaBackupCodes.value = [];
132
+ } catch (err) {
133
+ const e = err instanceof Error ? err : new Error(String(err));
134
+ mfaError.value = e.message;
135
+ } finally {
136
+ mfaLoading.value = false;
137
+ }
138
+ };
139
+
140
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
141
+ </script>
142
+
143
+ <template>
144
+ <div class="space-y-8">
145
+ <div v-if="userLoading" class="text-sm text-muted-foreground">
146
+ Loading profile...
147
+ </div>
148
+
149
+ <template v-if="user">
150
+ <!-- User info -->
151
+ <section class="space-y-4">
152
+ <h2 class="text-lg font-semibold tracking-tight">Profile</h2>
153
+ <dl
154
+ class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm"
155
+ >
156
+ <dt class="font-medium text-muted-foreground">Email</dt>
157
+ <dd>{{ user.email }}</dd>
158
+
159
+ <template v-if="user.display_name">
160
+ <dt class="font-medium text-muted-foreground">
161
+ Display name
162
+ </dt>
163
+ <dd>{{ user.display_name }}</dd>
164
+ </template>
165
+
166
+ <dt class="font-medium text-muted-foreground">
167
+ Email verified
168
+ </dt>
169
+ <dd>{{ user.email_verified ? "Yes" : "No" }}</dd>
170
+
171
+ <dt class="font-medium text-muted-foreground">Role</dt>
172
+ <dd>{{ user.role }}</dd>
173
+ </dl>
174
+ </section>
175
+
176
+ <!-- Passkeys -->
177
+ <section class="space-y-4">
178
+ <h2 class="text-lg font-semibold tracking-tight">Passkeys</h2>
179
+
180
+ <div
181
+ v-if="passkeyError"
182
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
183
+ >
184
+ {{ passkeyError }}
185
+ </div>
186
+
187
+ <div
188
+ v-if="passkeysLoading"
189
+ class="text-sm text-muted-foreground"
190
+ >
191
+ Loading passkeys...
192
+ </div>
193
+
194
+ <template v-else>
195
+ <p
196
+ v-if="passkeys.length === 0"
197
+ class="text-sm text-muted-foreground"
198
+ >
199
+ No passkeys registered.
200
+ </p>
201
+
202
+ <ul v-else class="space-y-2">
203
+ <li
204
+ v-for="passkey in passkeys"
205
+ :key="passkey.id"
206
+ class="flex items-center justify-between rounded-md border border-input px-3 py-2"
207
+ >
208
+ <div class="space-y-0.5">
209
+ <span class="text-sm font-medium">
210
+ {{ passkey.name ?? "Unnamed passkey" }}
211
+ </span>
212
+ <span
213
+ class="block text-xs text-muted-foreground"
214
+ >
215
+ Added
216
+ {{
217
+ new Date(
218
+ passkey.created_at,
219
+ ).toLocaleDateString()
220
+ }}
221
+ </span>
222
+ </div>
223
+ <button
224
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium shadow-sm transition-colors hover:bg-destructive hover:text-destructive-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
225
+ type="button"
226
+ :disabled="deletingPasskey === passkey.id"
227
+ @click="handleDeletePasskey(passkey.id)"
228
+ >
229
+ {{
230
+ deletingPasskey === passkey.id
231
+ ? "Deleting..."
232
+ : "Delete"
233
+ }}
234
+ </button>
235
+ </li>
236
+ </ul>
237
+ </template>
238
+ </section>
239
+
240
+ <!-- OAuth accounts -->
241
+ <section class="space-y-4">
242
+ <h2 class="text-lg font-semibold tracking-tight">
243
+ Connected accounts
244
+ </h2>
245
+
246
+ <div
247
+ v-if="oauthError"
248
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
249
+ >
250
+ {{ oauthError }}
251
+ </div>
252
+
253
+ <div
254
+ v-if="oauthLoading"
255
+ class="text-sm text-muted-foreground"
256
+ >
257
+ Loading accounts...
258
+ </div>
259
+
260
+ <template v-else>
261
+ <p
262
+ v-if="oauthAccounts.length === 0"
263
+ class="text-sm text-muted-foreground"
264
+ >
265
+ No connected accounts.
266
+ </p>
267
+
268
+ <ul v-else class="space-y-2">
269
+ <li
270
+ v-for="account in oauthAccounts"
271
+ :key="account.provider"
272
+ class="flex items-center justify-between rounded-md border border-input px-3 py-2"
273
+ >
274
+ <div class="space-y-0.5">
275
+ <span class="text-sm font-medium">
276
+ {{ capitalize(account.provider) }}
277
+ </span>
278
+ <span
279
+ class="block text-xs text-muted-foreground"
280
+ >
281
+ Connected
282
+ {{
283
+ new Date(
284
+ account.created_at,
285
+ ).toLocaleDateString()
286
+ }}
287
+ </span>
288
+ </div>
289
+ <button
290
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium shadow-sm transition-colors hover:bg-destructive hover:text-destructive-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
291
+ type="button"
292
+ :disabled="unlinkingOAuth === account.provider"
293
+ @click="handleUnlinkOAuth(account.provider)"
294
+ >
295
+ {{
296
+ unlinkingOAuth === account.provider
297
+ ? "Unlinking..."
298
+ : "Unlink"
299
+ }}
300
+ </button>
301
+ </li>
302
+ </ul>
303
+ </template>
304
+ </section>
305
+
306
+ <!-- MFA setup -->
307
+ <section class="space-y-4">
308
+ <h2 class="text-lg font-semibold tracking-tight">
309
+ Two-factor authentication
310
+ </h2>
311
+
312
+ <div
313
+ v-if="mfaError"
314
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
315
+ >
316
+ {{ mfaError }}
317
+ </div>
318
+
319
+ <div v-if="mfaStep === 'idle'" class="flex gap-2">
320
+ <button
321
+ class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
322
+ type="button"
323
+ :disabled="mfaLoading"
324
+ @click="handleMfaBegin"
325
+ >
326
+ {{ mfaLoading ? "Setting up..." : "Set up 2FA" }}
327
+ </button>
328
+
329
+ <button
330
+ class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-destructive hover:text-destructive-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
331
+ type="button"
332
+ :disabled="mfaLoading"
333
+ @click="handleMfaDisable"
334
+ >
335
+ {{ mfaLoading ? "Disabling..." : "Disable 2FA" }}
336
+ </button>
337
+ </div>
338
+
339
+ <div v-if="mfaStep === 'confirm'" class="space-y-4">
340
+ <p class="text-sm text-muted-foreground">
341
+ Add this account to your authenticator app, then enter the
342
+ verification code.
343
+ </p>
344
+
345
+ <div class="space-y-1">
346
+ <span class="text-sm font-medium leading-none">
347
+ OTP Auth URI
348
+ </span>
349
+ <code
350
+ class="block w-full break-all rounded-md border border-input bg-muted px-3 py-2 text-xs"
351
+ >
352
+ {{ mfaUri }}
353
+ </code>
354
+ </div>
355
+
356
+ <div class="space-y-1">
357
+ <span class="text-sm font-medium leading-none">
358
+ Manual entry key
359
+ </span>
360
+ <code
361
+ class="block w-full break-all rounded-md border border-input bg-muted px-3 py-2 text-xs font-mono tracking-wider"
362
+ >
363
+ {{ mfaSecret }}
364
+ </code>
365
+ </div>
366
+
367
+ <form class="space-y-4" @submit="handleMfaConfirm">
368
+ <div class="space-y-2">
369
+ <label
370
+ class="text-sm font-medium leading-none"
371
+ for="yauth-profile-mfa-code"
372
+ >
373
+ Verification code
374
+ </label>
375
+ <input
376
+ id="yauth-profile-mfa-code"
377
+ v-model="mfaCode"
378
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
379
+ name="mfa_code"
380
+ type="text"
381
+ inputmode="numeric"
382
+ autocomplete="one-time-code"
383
+ required
384
+ :disabled="mfaLoading"
385
+ />
386
+ </div>
387
+
388
+ <button
389
+ class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
390
+ type="submit"
391
+ :disabled="mfaLoading"
392
+ >
393
+ {{
394
+ mfaLoading
395
+ ? "Verifying..."
396
+ : "Verify and enable"
397
+ }}
398
+ </button>
399
+ </form>
400
+ </div>
401
+
402
+ <div v-if="mfaStep === 'done'" class="space-y-4">
403
+ <div
404
+ class="rounded-md bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400"
405
+ >
406
+ Two-factor authentication is enabled. Save these backup
407
+ codes in a safe place.
408
+ </div>
409
+
410
+ <ul class="space-y-1">
411
+ <li
412
+ v-for="code in mfaBackupCodes"
413
+ :key="code"
414
+ class="rounded-md border border-input bg-muted px-3 py-1.5 text-center font-mono text-sm tracking-wider"
415
+ >
416
+ {{ code }}
417
+ </li>
418
+ </ul>
419
+ </div>
420
+ </section>
421
+ </template>
422
+ </div>
423
+ </template>
@@ -0,0 +1,116 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { type YAuthContext, YAuthKey } from "../provider";
4
+ import RegisterForm from "./RegisterForm.vue";
5
+
6
+ function createMockContext(
7
+ overrides: Partial<YAuthContext> = {},
8
+ ): YAuthContext {
9
+ return {
10
+ client: {
11
+ emailPassword: {
12
+ register: vi.fn().mockResolvedValue({ message: "Check your email" }),
13
+ },
14
+ } as never,
15
+ user: { value: null } as never,
16
+ loading: { value: false } as never,
17
+ refetch: vi.fn(),
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("RegisterForm", () => {
23
+ it("renders email, password, and display name inputs", () => {
24
+ const ctx = createMockContext();
25
+ const wrapper = mount(RegisterForm, {
26
+ global: {
27
+ provide: { [YAuthKey as symbol]: ctx },
28
+ },
29
+ });
30
+
31
+ expect(wrapper.find("input[type='email']").exists()).toBe(true);
32
+ expect(wrapper.find("input[type='password']").exists()).toBe(true);
33
+ expect(wrapper.find("input[name='display_name']").exists()).toBe(true);
34
+ expect(wrapper.find("button[type='submit']").text()).toBe("Create account");
35
+ });
36
+
37
+ it("calls register with form values on submit", async () => {
38
+ const registerMock = vi
39
+ .fn()
40
+ .mockResolvedValue({ message: "Check your email" });
41
+ const onSuccess = vi.fn();
42
+
43
+ const ctx = createMockContext({
44
+ client: {
45
+ emailPassword: { register: registerMock },
46
+ } as never,
47
+ });
48
+
49
+ const wrapper = mount(RegisterForm, {
50
+ props: { onSuccess },
51
+ global: {
52
+ provide: { [YAuthKey as symbol]: ctx },
53
+ },
54
+ });
55
+
56
+ await wrapper.find("input[type='email']").setValue("new@test.com");
57
+ await wrapper.find("input[type='password']").setValue("password123");
58
+ await wrapper.find("input[name='display_name']").setValue("Test User");
59
+ await wrapper.find("form").trigger("submit");
60
+
61
+ await vi.waitFor(() => {
62
+ expect(registerMock).toHaveBeenCalledWith({
63
+ email: "new@test.com",
64
+ password: "password123",
65
+ display_name: "Test User",
66
+ });
67
+ });
68
+ });
69
+
70
+ it("shows error on registration failure", async () => {
71
+ const registerMock = vi.fn().mockRejectedValue(new Error("Email taken"));
72
+ const onError = vi.fn();
73
+
74
+ const ctx = createMockContext({
75
+ client: {
76
+ emailPassword: { register: registerMock },
77
+ } as never,
78
+ });
79
+
80
+ const wrapper = mount(RegisterForm, {
81
+ props: { onError },
82
+ global: {
83
+ provide: { [YAuthKey as symbol]: ctx },
84
+ },
85
+ });
86
+
87
+ await wrapper.find("input[type='email']").setValue("taken@test.com");
88
+ await wrapper.find("input[type='password']").setValue("password123");
89
+ await wrapper.find("form").trigger("submit");
90
+
91
+ await vi.waitFor(() => {
92
+ expect(wrapper.text()).toContain("Email taken");
93
+ });
94
+ });
95
+
96
+ it("has proper accessibility attributes", () => {
97
+ const ctx = createMockContext();
98
+ const wrapper = mount(RegisterForm, {
99
+ global: {
100
+ provide: { [YAuthKey as symbol]: ctx },
101
+ },
102
+ });
103
+
104
+ const emailInput = wrapper.find("#yauth-register-email");
105
+ expect(emailInput.exists()).toBe(true);
106
+ expect(emailInput.attributes("autocomplete")).toBe("email");
107
+
108
+ const passwordInput = wrapper.find("#yauth-register-password");
109
+ expect(passwordInput.exists()).toBe(true);
110
+ expect(passwordInput.attributes("autocomplete")).toBe("new-password");
111
+
112
+ const displayNameInput = wrapper.find("#yauth-register-display-name");
113
+ expect(displayNameInput.exists()).toBe(true);
114
+ expect(displayNameInput.attributes("autocomplete")).toBe("name");
115
+ });
116
+ });