@nuxtagentic/identity 0.0.0-stage → 0.1.1
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/LICENSE.md +9 -0
- package/README.md +52 -2
- package/module.ts +61 -0
- package/package.json +47 -3
- package/runtime/components/BrandMark.vue +8 -0
- package/runtime/components/CloudMascot.vue +34 -0
- package/runtime/components/CloudconfigLogo.vue +34 -0
- package/runtime/components/GithubAuthPanel.vue +158 -0
- package/runtime/components/ProfileSettings.vue +130 -0
- package/runtime/components/SettingsSection.vue +17 -0
- package/runtime/components/SocialAuthButton.vue +48 -0
- package/runtime/components/UserAvatar.vue +37 -0
- package/runtime/components/WorkspaceSwitcher.vue +118 -0
- package/runtime/components/settings/ImageField.vue +106 -0
- package/runtime/components/settings/OrganizationLogoEditor.vue +149 -0
- package/runtime/components/settings/Provisioning.vue +170 -0
- package/runtime/components/settings/Security.vue +211 -0
- package/runtime/components/settings/SettingsOrganizations.vue +305 -0
- package/runtime/components/settings/SingleSignOn.vue +396 -0
- package/runtime/components/settings/TwoFactor.vue +256 -0
- package/runtime/composables/useCurrentUser.ts +69 -0
- package/runtime/composables/useIdentityImageUpload.ts +31 -0
- package/runtime/composables/useOrganizationAccess.ts +46 -0
- package/runtime/composables/useProfileNamePreview.ts +4 -0
- package/runtime/composables/useWorkspaceScope.ts +12 -0
- package/runtime/composables/useWorkspaces.ts +108 -0
- package/runtime/layouts/default.vue +20 -0
- package/runtime/middleware/auth.ts +22 -0
- package/runtime/pages/organizations.vue +3 -0
- package/runtime/pages/sign-in.vue +7 -0
- package/runtime/pages/sign-up.vue +18 -0
- package/runtime/pages/two-factor.vue +148 -0
- package/runtime/settings-ui.ts +22 -0
- package/runtime/shared/agent.ts +1 -0
- package/runtime/utils/format.ts +18 -0
- package/runtime/workspace.ts +8 -0
- package/schema.ts +29 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRuntimeConfig } from '#imports'
|
|
3
|
+
import { computed, onMounted, ref } from 'vue'
|
|
4
|
+
import type { SsoProvider } from '@cloudconfig/core/client'
|
|
5
|
+
import { useOrganizationAdmin, useSsoClient } from '../../composables/useOrganizationAccess'
|
|
6
|
+
import { fieldUi, formFieldUi } from '../../settings-ui'
|
|
7
|
+
|
|
8
|
+
const sso = useSsoClient()
|
|
9
|
+
const { organization, isAdmin } = useOrganizationAdmin()
|
|
10
|
+
const authBase = String(
|
|
11
|
+
(useRuntimeConfig().public.cloudconfig as { authBasePath?: string } | undefined)?.authBasePath ??
|
|
12
|
+
'/api/auth',
|
|
13
|
+
)
|
|
14
|
+
const providers = ref<SsoProvider[]>([])
|
|
15
|
+
const available = ref(true)
|
|
16
|
+
const isBusy = ref(false)
|
|
17
|
+
const error = ref('')
|
|
18
|
+
const adding = ref(false)
|
|
19
|
+
const protocol = ref<'oidc' | 'saml'>('oidc')
|
|
20
|
+
const form = ref({
|
|
21
|
+
providerId: '',
|
|
22
|
+
issuer: '',
|
|
23
|
+
domain: '',
|
|
24
|
+
clientId: '',
|
|
25
|
+
clientSecret: '',
|
|
26
|
+
entryPoint: '',
|
|
27
|
+
cert: '',
|
|
28
|
+
metadata: '',
|
|
29
|
+
})
|
|
30
|
+
const verification = ref<{ providerId: string; name: string; value: string }>()
|
|
31
|
+
const origin = computed(() => (import.meta.client ? location.origin : ''))
|
|
32
|
+
const own = computed(() =>
|
|
33
|
+
providers.value.filter(
|
|
34
|
+
(provider) => String(provider.organizationId) === String(organization.value?.id),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async function load() {
|
|
39
|
+
try {
|
|
40
|
+
providers.value = await sso.providers()
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
const status = (cause as { status?: number }).status
|
|
43
|
+
// Without the SSO plugin the routes do not exist; hide the section.
|
|
44
|
+
if (status === 404) {
|
|
45
|
+
available.value = false
|
|
46
|
+
} else {
|
|
47
|
+
error.value = cause instanceof Error ? cause.message : 'Could not load identity providers.'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
onMounted(load)
|
|
52
|
+
|
|
53
|
+
async function run(work: () => Promise<unknown>) {
|
|
54
|
+
if (isBusy.value) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
isBusy.value = true
|
|
58
|
+
error.value = ''
|
|
59
|
+
try {
|
|
60
|
+
await work()
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
error.value = cause instanceof Error ? cause.message : 'Something went wrong.'
|
|
63
|
+
} finally {
|
|
64
|
+
isBusy.value = false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function slugify(value: string) {
|
|
69
|
+
return value
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
72
|
+
.replace(/(^-|-$)/g, '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function register() {
|
|
76
|
+
const organizationId = Number(organization.value?.id)
|
|
77
|
+
if (!organizationId) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
await run(async () => {
|
|
81
|
+
const providerId = slugify(
|
|
82
|
+
form.value.providerId || `${organization.value?.slug}-${protocol.value}`,
|
|
83
|
+
)
|
|
84
|
+
if (protocol.value === 'oidc') {
|
|
85
|
+
await sso.registerOidc({
|
|
86
|
+
providerId,
|
|
87
|
+
issuer: form.value.issuer.trim(),
|
|
88
|
+
domain: form.value.domain.trim().toLowerCase(),
|
|
89
|
+
clientId: form.value.clientId.trim(),
|
|
90
|
+
clientSecret: form.value.clientSecret.trim(),
|
|
91
|
+
organizationId,
|
|
92
|
+
})
|
|
93
|
+
} else {
|
|
94
|
+
await sso.registerSaml({
|
|
95
|
+
providerId,
|
|
96
|
+
issuer: form.value.issuer.trim(),
|
|
97
|
+
domain: form.value.domain.trim().toLowerCase(),
|
|
98
|
+
entryPoint: form.value.entryPoint.trim(),
|
|
99
|
+
organizationId,
|
|
100
|
+
...(form.value.metadata.trim() ? { idpMetadata: form.value.metadata.trim() } : {}),
|
|
101
|
+
...(form.value.cert.trim() ? { cert: form.value.cert.trim() } : {}),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
adding.value = false
|
|
105
|
+
form.value = {
|
|
106
|
+
providerId: '',
|
|
107
|
+
issuer: '',
|
|
108
|
+
domain: '',
|
|
109
|
+
clientId: '',
|
|
110
|
+
clientSecret: '',
|
|
111
|
+
entryPoint: '',
|
|
112
|
+
cert: '',
|
|
113
|
+
metadata: '',
|
|
114
|
+
}
|
|
115
|
+
await load()
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function requestVerification(provider: SsoProvider) {
|
|
120
|
+
await run(async () => {
|
|
121
|
+
const domain = provider.domain.split(',')[0]!.trim()
|
|
122
|
+
verification.value = {
|
|
123
|
+
providerId: provider.providerId,
|
|
124
|
+
...(await sso.requestDomainVerification(provider.providerId, domain)),
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function verify(provider: SsoProvider) {
|
|
130
|
+
await run(async () => {
|
|
131
|
+
await sso.verifyDomain(provider.providerId)
|
|
132
|
+
verification.value = undefined
|
|
133
|
+
await load()
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function remove(provider: SsoProvider) {
|
|
138
|
+
await run(async () => {
|
|
139
|
+
await sso.remove(provider.providerId)
|
|
140
|
+
await load()
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function copy(value: string) {
|
|
145
|
+
error.value = ''
|
|
146
|
+
try {
|
|
147
|
+
await navigator.clipboard.writeText(value)
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
error.value = cause instanceof Error ? cause.message : 'Could not copy to the clipboard.'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<template>
|
|
155
|
+
<SettingsSection
|
|
156
|
+
v-if="available && organization"
|
|
157
|
+
title="Single sign-on"
|
|
158
|
+
description="People on your verified email domain sign in through your identity provider and join this organization."
|
|
159
|
+
>
|
|
160
|
+
<p
|
|
161
|
+
v-if="error"
|
|
162
|
+
role="alert"
|
|
163
|
+
class="text-[13px] text-error"
|
|
164
|
+
>
|
|
165
|
+
{{ error }}
|
|
166
|
+
</p>
|
|
167
|
+
<p
|
|
168
|
+
v-if="!isAdmin"
|
|
169
|
+
class="text-sm text-muted"
|
|
170
|
+
>
|
|
171
|
+
Organization owners and admins set up single sign-on.
|
|
172
|
+
</p>
|
|
173
|
+
<template v-else>
|
|
174
|
+
<div
|
|
175
|
+
v-for="provider in own"
|
|
176
|
+
:key="provider.providerId"
|
|
177
|
+
class="grid gap-2 rounded-md border border-default p-3"
|
|
178
|
+
>
|
|
179
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
180
|
+
<div class="min-w-0">
|
|
181
|
+
<p class="truncate text-sm text-highlighted">{{ provider.issuer }}</p>
|
|
182
|
+
<p class="mt-0.5 truncate text-[13px] text-muted">
|
|
183
|
+
{{ provider.domain }} ·
|
|
184
|
+
{{ provider.domainVerified ? 'domain verified' : 'domain not verified yet' }}
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="flex shrink-0 gap-2">
|
|
188
|
+
<UButton
|
|
189
|
+
v-if="!provider.domainVerified"
|
|
190
|
+
label="Verify domain"
|
|
191
|
+
color="neutral"
|
|
192
|
+
variant="outline"
|
|
193
|
+
:disabled="isBusy"
|
|
194
|
+
class="h-9 rounded-md px-3 text-sm font-normal"
|
|
195
|
+
@click="requestVerification(provider)"
|
|
196
|
+
/>
|
|
197
|
+
<UButton
|
|
198
|
+
label="Remove"
|
|
199
|
+
color="neutral"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
:disabled="isBusy"
|
|
202
|
+
class="h-9 rounded-md px-3 text-sm font-normal"
|
|
203
|
+
@click="remove(provider)"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div
|
|
208
|
+
v-if="verification?.providerId === provider.providerId"
|
|
209
|
+
class="grid gap-2"
|
|
210
|
+
>
|
|
211
|
+
<p class="text-[13px] text-muted">
|
|
212
|
+
Add this TXT record at your DNS host, then verify. Sign-in through this provider starts
|
|
213
|
+
working once the domain is verified.
|
|
214
|
+
</p>
|
|
215
|
+
<UInput
|
|
216
|
+
:model-value="verification.name"
|
|
217
|
+
readonly
|
|
218
|
+
aria-label="TXT record name"
|
|
219
|
+
:ui="fieldUi"
|
|
220
|
+
/>
|
|
221
|
+
<UInput
|
|
222
|
+
:model-value="verification.value"
|
|
223
|
+
readonly
|
|
224
|
+
aria-label="TXT record value"
|
|
225
|
+
:ui="fieldUi"
|
|
226
|
+
/>
|
|
227
|
+
<div class="flex gap-2">
|
|
228
|
+
<UButton
|
|
229
|
+
label="Copy value"
|
|
230
|
+
color="neutral"
|
|
231
|
+
variant="outline"
|
|
232
|
+
size="sm"
|
|
233
|
+
@click="copy(verification.value)"
|
|
234
|
+
/>
|
|
235
|
+
<UButton
|
|
236
|
+
label="Verify"
|
|
237
|
+
color="neutral"
|
|
238
|
+
size="sm"
|
|
239
|
+
:loading="isBusy"
|
|
240
|
+
@click="verify(provider)"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
<form
|
|
246
|
+
v-if="adding"
|
|
247
|
+
class="grid gap-4"
|
|
248
|
+
@submit.prevent="register"
|
|
249
|
+
>
|
|
250
|
+
<div
|
|
251
|
+
class="grid grid-cols-2 gap-1 rounded-lg border border-default p-0.5"
|
|
252
|
+
role="radiogroup"
|
|
253
|
+
aria-label="Protocol"
|
|
254
|
+
>
|
|
255
|
+
<UButton
|
|
256
|
+
v-for="value in ['oidc', 'saml'] as const"
|
|
257
|
+
:key="value"
|
|
258
|
+
:label="value === 'oidc' ? 'OpenID Connect' : 'SAML 2.0'"
|
|
259
|
+
color="neutral"
|
|
260
|
+
:variant="protocol === value ? 'soft' : 'ghost'"
|
|
261
|
+
role="radio"
|
|
262
|
+
:aria-checked="protocol === value"
|
|
263
|
+
class="h-8 justify-center rounded-md text-[13px]"
|
|
264
|
+
@click="protocol = value"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
<UFormField
|
|
268
|
+
label="Email domain"
|
|
269
|
+
description="Comma-separate several domains served by the same provider."
|
|
270
|
+
:ui="formFieldUi"
|
|
271
|
+
>
|
|
272
|
+
<UInput
|
|
273
|
+
v-model="form.domain"
|
|
274
|
+
required
|
|
275
|
+
placeholder="acme.com"
|
|
276
|
+
:ui="fieldUi"
|
|
277
|
+
/>
|
|
278
|
+
</UFormField>
|
|
279
|
+
<UFormField
|
|
280
|
+
:label="protocol === 'oidc' ? 'Issuer URL' : 'IdP entity ID'"
|
|
281
|
+
:ui="formFieldUi"
|
|
282
|
+
>
|
|
283
|
+
<UInput
|
|
284
|
+
v-model="form.issuer"
|
|
285
|
+
required
|
|
286
|
+
:placeholder="
|
|
287
|
+
protocol === 'oidc' ? 'https://acme.okta.com' : 'http://www.okta.com/exk…'
|
|
288
|
+
"
|
|
289
|
+
:ui="fieldUi"
|
|
290
|
+
/>
|
|
291
|
+
</UFormField>
|
|
292
|
+
<template v-if="protocol === 'oidc'">
|
|
293
|
+
<UFormField
|
|
294
|
+
label="Client ID"
|
|
295
|
+
:ui="formFieldUi"
|
|
296
|
+
>
|
|
297
|
+
<UInput
|
|
298
|
+
v-model="form.clientId"
|
|
299
|
+
required
|
|
300
|
+
:ui="fieldUi"
|
|
301
|
+
/>
|
|
302
|
+
</UFormField>
|
|
303
|
+
<UFormField
|
|
304
|
+
label="Client secret"
|
|
305
|
+
:ui="formFieldUi"
|
|
306
|
+
>
|
|
307
|
+
<UInput
|
|
308
|
+
v-model="form.clientSecret"
|
|
309
|
+
required
|
|
310
|
+
type="password"
|
|
311
|
+
autocomplete="off"
|
|
312
|
+
:ui="fieldUi"
|
|
313
|
+
/>
|
|
314
|
+
</UFormField>
|
|
315
|
+
<p class="text-[13px] text-muted">
|
|
316
|
+
Redirect URI for your identity provider:
|
|
317
|
+
<code class="break-all text-highlighted">
|
|
318
|
+
{{ origin }}{{ authBase }}/sso/callback/{{
|
|
319
|
+
slugify(form.providerId || `${organization.slug}-oidc`)
|
|
320
|
+
}}
|
|
321
|
+
</code>
|
|
322
|
+
</p>
|
|
323
|
+
</template>
|
|
324
|
+
<template v-else>
|
|
325
|
+
<UFormField
|
|
326
|
+
label="Single sign-on URL"
|
|
327
|
+
:ui="formFieldUi"
|
|
328
|
+
>
|
|
329
|
+
<UInput
|
|
330
|
+
v-model="form.entryPoint"
|
|
331
|
+
required
|
|
332
|
+
type="url"
|
|
333
|
+
:ui="fieldUi"
|
|
334
|
+
/>
|
|
335
|
+
</UFormField>
|
|
336
|
+
<UFormField
|
|
337
|
+
label="IdP metadata XML"
|
|
338
|
+
description="Or paste the signing certificate below."
|
|
339
|
+
:ui="formFieldUi"
|
|
340
|
+
>
|
|
341
|
+
<UTextarea
|
|
342
|
+
v-model="form.metadata"
|
|
343
|
+
:rows="3"
|
|
344
|
+
class="w-full"
|
|
345
|
+
/>
|
|
346
|
+
</UFormField>
|
|
347
|
+
<UFormField
|
|
348
|
+
label="Signing certificate (PEM)"
|
|
349
|
+
:ui="formFieldUi"
|
|
350
|
+
>
|
|
351
|
+
<UTextarea
|
|
352
|
+
v-model="form.cert"
|
|
353
|
+
:rows="3"
|
|
354
|
+
class="w-full"
|
|
355
|
+
/>
|
|
356
|
+
</UFormField>
|
|
357
|
+
<p class="text-[13px] text-muted">
|
|
358
|
+
ACS URL for your identity provider:
|
|
359
|
+
<code class="break-all text-highlighted">
|
|
360
|
+
{{ origin }}{{ authBase }}/sso/saml2/sp/acs/{{
|
|
361
|
+
slugify(form.providerId || `${organization.slug}-saml`)
|
|
362
|
+
}}
|
|
363
|
+
</code>
|
|
364
|
+
</p>
|
|
365
|
+
</template>
|
|
366
|
+
<div class="flex flex-wrap gap-2">
|
|
367
|
+
<UButton
|
|
368
|
+
type="submit"
|
|
369
|
+
label="Add identity provider"
|
|
370
|
+
:loading="isBusy"
|
|
371
|
+
class="h-10 rounded-md px-4 text-sm font-medium"
|
|
372
|
+
/>
|
|
373
|
+
<UButton
|
|
374
|
+
type="button"
|
|
375
|
+
color="neutral"
|
|
376
|
+
variant="outline"
|
|
377
|
+
label="Cancel"
|
|
378
|
+
:disabled="isBusy"
|
|
379
|
+
class="h-10 rounded-md px-4 text-sm font-normal"
|
|
380
|
+
@click="adding = false"
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
</form>
|
|
384
|
+
<UButton
|
|
385
|
+
v-else
|
|
386
|
+
type="button"
|
|
387
|
+
color="neutral"
|
|
388
|
+
variant="outline"
|
|
389
|
+
icon="i-lucide-plus"
|
|
390
|
+
label="Add identity provider"
|
|
391
|
+
class="h-10 w-fit rounded-md px-4 text-sm font-normal"
|
|
392
|
+
@click="adding = true"
|
|
393
|
+
/>
|
|
394
|
+
</template>
|
|
395
|
+
</SettingsSection>
|
|
396
|
+
</template>
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useCloudConfigAccount, useRuntimeConfig } from '#imports'
|
|
3
|
+
import { useCloudConfigTwoFactor } from '@cloudconfig/nuxt/two-factor'
|
|
4
|
+
import { useCurrentUser } from '../../composables/useCurrentUser'
|
|
5
|
+
import { ref, onMounted } from 'vue'
|
|
6
|
+
|
|
7
|
+
import type { TwoFactorStatus } from '@cloudconfig/core/client'
|
|
8
|
+
|
|
9
|
+
const { hasPassword } = defineProps<{ hasPassword?: boolean }>()
|
|
10
|
+
const auth = useCloudConfigTwoFactor()
|
|
11
|
+
const account = useCloudConfigAccount()
|
|
12
|
+
const user = useCurrentUser()
|
|
13
|
+
const config = useRuntimeConfig().public.cloudconfig as { twoFactor?: { passkey: boolean } }
|
|
14
|
+
const passkeyEnabled = config.twoFactor?.passkey !== false
|
|
15
|
+
const status = ref<TwoFactorStatus | null>(null)
|
|
16
|
+
const keys = ref<{ id: string; name?: string | undefined }[]>([])
|
|
17
|
+
const isBusy = ref(false)
|
|
18
|
+
const isEnrollingEmail = ref(false)
|
|
19
|
+
const code = ref('')
|
|
20
|
+
const password = ref('')
|
|
21
|
+
const error = ref('')
|
|
22
|
+
const message = ref('')
|
|
23
|
+
|
|
24
|
+
async function load() {
|
|
25
|
+
status.value = await account.getTwoFactorStatus()
|
|
26
|
+
if (passkeyEnabled) {
|
|
27
|
+
const result = await auth.passkey.listUserPasskeys()
|
|
28
|
+
if (result.error) {
|
|
29
|
+
throw new Error(result.error.message ?? 'Could not load passkeys.')
|
|
30
|
+
}
|
|
31
|
+
keys.value = result.data ?? []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function run(action: () => Promise<void>) {
|
|
36
|
+
if (isBusy.value) {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
isBusy.value = true
|
|
40
|
+
error.value = ''
|
|
41
|
+
message.value = ''
|
|
42
|
+
try {
|
|
43
|
+
await action()
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
error.value =
|
|
46
|
+
cause instanceof Error ? cause.message : 'Could not update two-factor authentication.'
|
|
47
|
+
} finally {
|
|
48
|
+
isBusy.value = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onMounted(() => run(load))
|
|
53
|
+
|
|
54
|
+
function sendCode() {
|
|
55
|
+
return run(async () => {
|
|
56
|
+
const result = await auth.twoFactor.sendOtp({})
|
|
57
|
+
if (result.error) {
|
|
58
|
+
throw new Error(result.error.message ?? 'Could not send the verification code.')
|
|
59
|
+
}
|
|
60
|
+
isEnrollingEmail.value = true
|
|
61
|
+
code.value = ''
|
|
62
|
+
message.value = `We sent a verification code to ${user.value?.email}.`
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function verifyCode() {
|
|
67
|
+
return run(async () => {
|
|
68
|
+
const result = await auth.twoFactor.verifyOtp({ code: code.value })
|
|
69
|
+
if (result.error) {
|
|
70
|
+
throw new Error(result.error.message ?? 'The code is invalid or expired.')
|
|
71
|
+
}
|
|
72
|
+
isEnrollingEmail.value = false
|
|
73
|
+
code.value = ''
|
|
74
|
+
await load()
|
|
75
|
+
message.value = 'Email two-factor authentication is enabled.'
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function usePasskey(add = false) {
|
|
80
|
+
return run(async () => {
|
|
81
|
+
const result =
|
|
82
|
+
add || !keys.value.length ? await auth.passkey.addPasskey({}) : await auth.signIn.passkey()
|
|
83
|
+
if (result.error) {
|
|
84
|
+
throw new Error(result.error.message ?? 'Could not verify your passkey.')
|
|
85
|
+
}
|
|
86
|
+
isEnrollingEmail.value = false
|
|
87
|
+
await load()
|
|
88
|
+
message.value = 'Passkey two-factor authentication is enabled.'
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function removePasskey(id: string) {
|
|
93
|
+
return run(async () => {
|
|
94
|
+
const result = await auth.passkey.deletePasskey({ id })
|
|
95
|
+
if (result.error) {
|
|
96
|
+
throw new Error(result.error.message ?? 'Could not remove the passkey.')
|
|
97
|
+
}
|
|
98
|
+
await load()
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function disable() {
|
|
103
|
+
return run(async () => {
|
|
104
|
+
const result = await auth.twoFactor.disable({ password: password.value })
|
|
105
|
+
if (result.error) {
|
|
106
|
+
throw new Error(result.error.message ?? 'Could not turn off two-factor authentication.')
|
|
107
|
+
}
|
|
108
|
+
isEnrollingEmail.value = false
|
|
109
|
+
password.value = ''
|
|
110
|
+
await load()
|
|
111
|
+
message.value = 'Two-factor authentication is turned off.'
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<SettingsSection
|
|
118
|
+
title="Two-factor authentication"
|
|
119
|
+
description="Choose an email code or a passkey to verify after signing in."
|
|
120
|
+
>
|
|
121
|
+
<p
|
|
122
|
+
v-if="error"
|
|
123
|
+
role="alert"
|
|
124
|
+
class="text-sm text-error"
|
|
125
|
+
>
|
|
126
|
+
{{ error }}
|
|
127
|
+
</p>
|
|
128
|
+
<p
|
|
129
|
+
v-if="message"
|
|
130
|
+
role="status"
|
|
131
|
+
class="text-sm text-muted"
|
|
132
|
+
>
|
|
133
|
+
{{ message }}
|
|
134
|
+
</p>
|
|
135
|
+
<p
|
|
136
|
+
v-if="!status && !error"
|
|
137
|
+
role="status"
|
|
138
|
+
class="text-sm text-muted"
|
|
139
|
+
>
|
|
140
|
+
Loading two-factor settings…
|
|
141
|
+
</p>
|
|
142
|
+
<template v-if="status">
|
|
143
|
+
<p class="text-sm text-muted">
|
|
144
|
+
{{
|
|
145
|
+
status.enabled
|
|
146
|
+
? `Enabled · ${status.method === 'passkey' ? 'Passkey' : 'Email code'}`
|
|
147
|
+
: 'Two-factor authentication is off.'
|
|
148
|
+
}}
|
|
149
|
+
</p>
|
|
150
|
+
<div class="flex flex-wrap gap-2">
|
|
151
|
+
<UButton
|
|
152
|
+
label="Use email code"
|
|
153
|
+
color="neutral"
|
|
154
|
+
variant="outline"
|
|
155
|
+
:disabled="isBusy"
|
|
156
|
+
@click="sendCode"
|
|
157
|
+
/>
|
|
158
|
+
<UButton
|
|
159
|
+
v-if="passkeyEnabled"
|
|
160
|
+
label="Use passkey"
|
|
161
|
+
color="neutral"
|
|
162
|
+
variant="outline"
|
|
163
|
+
:disabled="isBusy"
|
|
164
|
+
@click="usePasskey()"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<form
|
|
168
|
+
v-if="isEnrollingEmail"
|
|
169
|
+
class="flex flex-wrap items-end gap-2"
|
|
170
|
+
@submit.prevent="verifyCode"
|
|
171
|
+
>
|
|
172
|
+
<UFormField label="Verification code">
|
|
173
|
+
<UInput
|
|
174
|
+
v-model="code"
|
|
175
|
+
autocomplete="one-time-code"
|
|
176
|
+
inputmode="numeric"
|
|
177
|
+
pattern="[0-9]{6}"
|
|
178
|
+
maxlength="6"
|
|
179
|
+
required
|
|
180
|
+
:disabled="isBusy"
|
|
181
|
+
autofocus
|
|
182
|
+
/>
|
|
183
|
+
</UFormField>
|
|
184
|
+
<UButton
|
|
185
|
+
type="submit"
|
|
186
|
+
label="Verify and enable"
|
|
187
|
+
:loading="isBusy"
|
|
188
|
+
:disabled="code.length !== 6"
|
|
189
|
+
/>
|
|
190
|
+
<UButton
|
|
191
|
+
label="Resend code"
|
|
192
|
+
color="neutral"
|
|
193
|
+
variant="link"
|
|
194
|
+
:disabled="isBusy"
|
|
195
|
+
@click="sendCode"
|
|
196
|
+
/>
|
|
197
|
+
<UButton
|
|
198
|
+
label="Cancel"
|
|
199
|
+
color="neutral"
|
|
200
|
+
variant="link"
|
|
201
|
+
:disabled="isBusy"
|
|
202
|
+
@click="isEnrollingEmail = false"
|
|
203
|
+
/>
|
|
204
|
+
</form>
|
|
205
|
+
<div
|
|
206
|
+
v-if="keys.length"
|
|
207
|
+
class="space-y-2"
|
|
208
|
+
>
|
|
209
|
+
<div
|
|
210
|
+
v-for="key in keys"
|
|
211
|
+
:key="key.id"
|
|
212
|
+
class="flex items-center justify-between gap-3 text-sm"
|
|
213
|
+
>
|
|
214
|
+
<span class="text-muted">{{ key.name || 'Passkey' }}</span>
|
|
215
|
+
<UButton
|
|
216
|
+
label="Remove"
|
|
217
|
+
color="neutral"
|
|
218
|
+
variant="link"
|
|
219
|
+
:disabled="isBusy"
|
|
220
|
+
@click="removePasskey(key.id)"
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
<UButton
|
|
224
|
+
label="Add passkey"
|
|
225
|
+
color="neutral"
|
|
226
|
+
variant="link"
|
|
227
|
+
:disabled="isBusy"
|
|
228
|
+
@click="usePasskey(true)"
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
<div
|
|
232
|
+
v-if="status.enabled"
|
|
233
|
+
class="flex flex-wrap items-end gap-2"
|
|
234
|
+
>
|
|
235
|
+
<UFormField
|
|
236
|
+
v-if="hasPassword"
|
|
237
|
+
label="Current password"
|
|
238
|
+
>
|
|
239
|
+
<UInput
|
|
240
|
+
v-model="password"
|
|
241
|
+
type="password"
|
|
242
|
+
autocomplete="current-password"
|
|
243
|
+
:disabled="isBusy"
|
|
244
|
+
/>
|
|
245
|
+
</UFormField>
|
|
246
|
+
<UButton
|
|
247
|
+
label="Turn off 2FA"
|
|
248
|
+
color="neutral"
|
|
249
|
+
variant="link"
|
|
250
|
+
:disabled="isBusy || (hasPassword && !password)"
|
|
251
|
+
@click="disable"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
255
|
+
</SettingsSection>
|
|
256
|
+
</template>
|