@nuxtagentic/identity 0.0.0-stage → 0.1.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.
Files changed (37) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +52 -2
  3. package/module.ts +61 -0
  4. package/package.json +47 -3
  5. package/runtime/components/BrandMark.vue +8 -0
  6. package/runtime/components/CloudMascot.vue +34 -0
  7. package/runtime/components/CloudconfigLogo.vue +34 -0
  8. package/runtime/components/GithubAuthPanel.vue +158 -0
  9. package/runtime/components/ProfileSettings.vue +130 -0
  10. package/runtime/components/SettingsSection.vue +17 -0
  11. package/runtime/components/SocialAuthButton.vue +48 -0
  12. package/runtime/components/UserAvatar.vue +37 -0
  13. package/runtime/components/WorkspaceSwitcher.vue +118 -0
  14. package/runtime/components/settings/ImageField.vue +106 -0
  15. package/runtime/components/settings/OrganizationLogoEditor.vue +149 -0
  16. package/runtime/components/settings/Provisioning.vue +170 -0
  17. package/runtime/components/settings/Security.vue +211 -0
  18. package/runtime/components/settings/SettingsOrganizations.vue +305 -0
  19. package/runtime/components/settings/SingleSignOn.vue +396 -0
  20. package/runtime/components/settings/TwoFactor.vue +256 -0
  21. package/runtime/composables/useCurrentUser.ts +69 -0
  22. package/runtime/composables/useIdentityImageUpload.ts +31 -0
  23. package/runtime/composables/useOrganizationAccess.ts +46 -0
  24. package/runtime/composables/useProfileNamePreview.ts +4 -0
  25. package/runtime/composables/useWorkspaceScope.ts +12 -0
  26. package/runtime/composables/useWorkspaces.ts +108 -0
  27. package/runtime/layouts/default.vue +20 -0
  28. package/runtime/middleware/auth.ts +22 -0
  29. package/runtime/pages/organizations.vue +3 -0
  30. package/runtime/pages/sign-in.vue +7 -0
  31. package/runtime/pages/sign-up.vue +18 -0
  32. package/runtime/pages/two-factor.vue +148 -0
  33. package/runtime/settings-ui.ts +22 -0
  34. package/runtime/shared/agent.ts +1 -0
  35. package/runtime/utils/format.ts +18 -0
  36. package/runtime/workspace.ts +8 -0
  37. 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>