@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.
- package/dist/composables.js +6 -0
- package/dist/index.js +1101 -0
- package/dist/useSession-CnIeOYpZ.js +136 -0
- package/package.json +48 -0
- package/src/components/ChangePasswordForm.test.ts +106 -0
- package/src/components/ChangePasswordForm.vue +131 -0
- package/src/components/ConsentScreen.vue +150 -0
- package/src/components/ForgotPasswordForm.vue +77 -0
- package/src/components/LoginForm.test.ts +163 -0
- package/src/components/LoginForm.vue +128 -0
- package/src/components/MagicLinkForm.vue +80 -0
- package/src/components/MfaChallenge.vue +77 -0
- package/src/components/MfaSetup.vue +155 -0
- package/src/components/OAuthButtons.vue +29 -0
- package/src/components/PasskeyButton.vue +97 -0
- package/src/components/ProfileSettings.vue +423 -0
- package/src/components/RegisterForm.test.ts +116 -0
- package/src/components/RegisterForm.vue +112 -0
- package/src/components/ResetPasswordForm.vue +81 -0
- package/src/components/VerifyEmail.vue +53 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useAuth.test.ts +154 -0
- package/src/composables/useAuth.ts +139 -0
- package/src/composables/useSession.ts +35 -0
- package/src/index.ts +22 -0
- package/src/provider.test.ts +180 -0
- package/src/provider.ts +66 -0
|
@@ -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
|
+
});
|