@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,69 @@
1
+ import {
2
+ useState,
3
+ useRuntimeConfig,
4
+ useRequestFetch,
5
+ useCloudConfigAuth,
6
+ navigateTo,
7
+ } from '#imports'
8
+ import { watch } from 'vue'
9
+ import type { Organization } from '@cloudconfig/core/client'
10
+
11
+ export interface CurrentUser {
12
+ id: string | number
13
+ name: string
14
+ email: string
15
+ image?: string | null
16
+ }
17
+
18
+ export function useCurrentUser() {
19
+ return useState<CurrentUser | null>('current-user', () => null)
20
+ }
21
+
22
+ export async function fetchCurrentUser(): Promise<CurrentUser | null> {
23
+ const config = useRuntimeConfig().public.cloudconfig as { authBasePath?: string } | undefined
24
+ const session = await useRequestFetch()<{ user: CurrentUser } | null>(
25
+ `${config?.authBasePath ?? '/api/auth'}/get-session`,
26
+ { query: { disableCookieCache: true } },
27
+ )
28
+ return session?.user ?? null
29
+ }
30
+
31
+ export function useAccountSession() {
32
+ const user = useCurrentUser()
33
+ const auth = useCloudConfigAuth()
34
+ const session = auth.useSession()
35
+ const organization = useState<Organization | null>('active-organization', () => null)
36
+
37
+ watch(
38
+ () => user.value?.id,
39
+ () => {
40
+ organization.value = null
41
+ },
42
+ )
43
+
44
+ if (import.meta.client) {
45
+ watch(
46
+ session,
47
+ ({ isPending, error, data }) => {
48
+ if (!isPending && !error) {
49
+ user.value = data?.user ?? null
50
+ if (!user.value) {
51
+ void navigateTo('/sign-in')
52
+ }
53
+ }
54
+ },
55
+ { immediate: true },
56
+ )
57
+ }
58
+
59
+ async function signOut() {
60
+ const { error } = await auth.signOut()
61
+ if (error) {
62
+ return
63
+ }
64
+ user.value = null
65
+ await navigateTo('/sign-in')
66
+ }
67
+
68
+ return { user, signOut }
69
+ }
@@ -0,0 +1,31 @@
1
+ import { useCloudConfig, useRuntimeConfig } from '#imports'
2
+ import type { IdentityConfig } from '@nuxtagentic/identity/schema'
3
+
4
+ /** Upload an identity image, retaining it for retries when the subsequent save fails. */
5
+ export function useIdentityImageUpload() {
6
+ const api = useCloudConfig<IdentityConfig>()
7
+ const storage = useRuntimeConfig().public.cloudconfig as {
8
+ basePath?: string
9
+ storagePaths?: Record<string, string>
10
+ }
11
+ let uploadedImage: { file: File; url: string } | null = null
12
+
13
+ async function upload(file: File) {
14
+ if (uploadedImage?.file === file) {
15
+ return uploadedImage.url
16
+ }
17
+
18
+ const saved = await api.avatar.upload(file)
19
+ const basePath = (storage.basePath ?? '/api').replace(/\/$/, '')
20
+ const contentPath = (storage.storagePaths?.avatar ?? '/avatar-content').replace(/^\//, '')
21
+ const url = new URL(`${basePath}/${contentPath}/${saved.id}`, window.location.origin).href
22
+ uploadedImage = { file, url }
23
+ return url
24
+ }
25
+
26
+ function clear() {
27
+ uploadedImage = null
28
+ }
29
+
30
+ return { upload, clear }
31
+ }
@@ -0,0 +1,46 @@
1
+ import { useCloudConfigOrganizations, useRequestEvent, useRuntimeConfig, useState } from '#imports'
2
+ import { computed, ref, watch } from 'vue'
3
+ import { createSsoClient, type Organization } from '@cloudconfig/core/client'
4
+ import { useCurrentUser } from './useCurrentUser'
5
+
6
+ /** The SSO and SCIM client, pointed at the auth mount and SSR-safe. */
7
+ export function useSsoClient() {
8
+ const config = useRuntimeConfig().public.cloudconfig as { authBasePath?: string } | undefined
9
+ const event = useRequestEvent()
10
+ return createSsoClient({
11
+ baseUrl: config?.authBasePath ?? '/api/auth',
12
+ // During SSR the request event's fetch keeps the caller's cookies.
13
+ fetch: (input, init) => (event ? event.fetch(String(input), init) : fetch(input, init)),
14
+ })
15
+ }
16
+
17
+ /** Whether the signed-in person owns or administers the active organization. */
18
+ export function useOrganizationAdmin() {
19
+ const organizations = useCloudConfigOrganizations()
20
+ const active = useState<Organization | null>('active-organization', () => null)
21
+ const user = useCurrentUser()
22
+ const role = ref('')
23
+ watch(
24
+ () => [active.value?.id, user.value?.id],
25
+ async ([organizationId, userId]) => {
26
+ role.value = ''
27
+ if (!organizationId || !userId) {
28
+ return
29
+ }
30
+ try {
31
+ const full = await organizations.getFull(Number(organizationId))
32
+ role.value =
33
+ full.members.find((member) => String(member.userId) === String(userId))?.role ?? ''
34
+ } catch {
35
+ role.value = ''
36
+ }
37
+ },
38
+ { immediate: true },
39
+ )
40
+ return {
41
+ organization: active,
42
+ isAdmin: computed(() =>
43
+ role.value.split(',').some((entry) => ['owner', 'admin'].includes(entry.trim())),
44
+ ),
45
+ }
46
+ }
@@ -0,0 +1,4 @@
1
+ import { useState } from '#imports'
2
+ export function useProfileNamePreview() {
3
+ return useState<string | null>('dashboard-profile-preview', () => null)
4
+ }
@@ -0,0 +1,12 @@
1
+ import { useCurrentUser } from './useCurrentUser'
2
+ import { useState } from '#imports'
3
+ import { computed } from 'vue'
4
+ import type { Organization } from '@cloudconfig/core/client'
5
+
6
+ export function useWorkspaceScope() {
7
+ const user = useCurrentUser()
8
+ const organization = useState<Organization | null>('active-organization', () => null)
9
+ return computed(() =>
10
+ user.value ? `${user.value.id}:${organization.value?.id ?? 'personal'}` : '',
11
+ )
12
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ useCloudConfigOrganizations,
3
+ useState,
4
+ useToast,
5
+ useRuntimeConfig,
6
+ useRequestFetch,
7
+ useAsyncData,
8
+ } from '#imports'
9
+ import { computed } from 'vue'
10
+ import type { DropdownMenuItem } from '@nuxt/ui'
11
+ import type { Organization } from '@cloudconfig/core/client'
12
+
13
+ export interface WorkspaceMenuItem extends DropdownMenuItem {
14
+ slot: 'workspace'
15
+ workspace: Organization | null
16
+ }
17
+
18
+ // This data loader also runs in route middleware, outside component setup.
19
+ export function useWorkspaceOrganizations() {
20
+ const organizations = useCloudConfigOrganizations()
21
+ const activeOrganization = useState<Organization | null>('active-organization', () => null)
22
+ const config = useRuntimeConfig().public.cloudconfig as { authBasePath?: string } | undefined
23
+ const requestFetch = useRequestFetch()
24
+ return useAsyncData(
25
+ 'workspace-organization',
26
+ async () => {
27
+ const [items, session] = await Promise.all([
28
+ organizations.list(),
29
+ requestFetch<{
30
+ session: { activeOrganizationId?: string | number | null }
31
+ } | null>(`${config?.authBasePath ?? '/api/auth'}/get-session`, {
32
+ query: { disableCookieCache: true },
33
+ }),
34
+ ])
35
+ activeOrganization.value =
36
+ items.find((item) => String(item.id) === String(session?.session.activeOrganizationId)) ??
37
+ null
38
+ return items
39
+ },
40
+ { default: () => [] },
41
+ )
42
+ }
43
+
44
+ export async function useWorkspaces(onSelected: () => void = () => {}) {
45
+ const organizations = useCloudConfigOrganizations()
46
+ const activeOrganization = useState<Organization | null>('active-organization', () => null)
47
+ const switching = useState('workspace-switching', () => false)
48
+ const toast = useToast()
49
+ const { data: items, error, status, refresh } = await useWorkspaceOrganizations()
50
+
51
+ async function selectWorkspace(id: number | null) {
52
+ if (switching.value) {
53
+ return
54
+ }
55
+ if (id === (activeOrganization.value?.id ?? null)) {
56
+ onSelected()
57
+ return
58
+ }
59
+
60
+ switching.value = true
61
+ try {
62
+ activeOrganization.value = await organizations.setActive(id)
63
+ onSelected()
64
+ } catch (cause) {
65
+ toast.add({
66
+ title: 'Could not switch workspace',
67
+ description: cause instanceof Error ? cause.message : 'Please try again.',
68
+ color: 'error',
69
+ })
70
+ } finally {
71
+ switching.value = false
72
+ }
73
+ }
74
+
75
+ function workspaceItem(workspace: Organization | null): WorkspaceMenuItem {
76
+ return {
77
+ label: workspace?.name ?? 'Cloudconfig',
78
+ workspace,
79
+ slot: 'workspace',
80
+ type: 'checkbox',
81
+ checked: (workspace?.id ?? null) === (activeOrganization.value?.id ?? null),
82
+ disabled: switching.value || status.value === 'pending' || !!error.value,
83
+ class: 'h-10',
84
+ onSelect: (event) => {
85
+ event.preventDefault()
86
+ void selectWorkspace(workspace?.id ?? null)
87
+ },
88
+ }
89
+ }
90
+
91
+ const menu = computed<(DropdownMenuItem | WorkspaceMenuItem)[]>(() => [
92
+ { label: 'Organizations', type: 'label' },
93
+ ...items.value.map(workspaceItem),
94
+ workspaceItem(null),
95
+ { type: 'separator' },
96
+ {
97
+ label: 'Create organization',
98
+ icon: 'i-lucide-plus',
99
+ to: '/dashboard/settings?create=true#organizations',
100
+ },
101
+ {
102
+ label: 'Organization settings',
103
+ icon: 'i-lucide-settings',
104
+ to: '/dashboard/settings#organizations',
105
+ },
106
+ ])
107
+ return { activeOrganization, menu, switching, error, refresh }
108
+ }
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <div
3
+ class="dark flex min-h-[var(--app-viewport-height,100dvh)] flex-col bg-default font-sans text-default scheme-dark"
4
+ >
5
+ <header class="flex h-14 shrink-0 items-center px-4">
6
+ <NuxtLink
7
+ to="/"
8
+ class="flex items-center gap-2 text-sm font-medium text-muted transition-colors duration-140 hover:text-highlighted focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary motion-reduce:transition-none"
9
+ >
10
+ <BrandMark />
11
+ <span class="mt-1">CloudConfig</span>
12
+ </NuxtLink>
13
+ </header>
14
+ <main class="flex flex-1 items-center justify-center px-6 pt-6 pb-24">
15
+ <div class="w-full max-w-[340px]">
16
+ <slot />
17
+ </div>
18
+ </main>
19
+ </div>
20
+ </template>
@@ -0,0 +1,22 @@
1
+ import { defineNuxtRouteMiddleware, useNuxtApp, navigateTo } from '#imports'
2
+ import { useCurrentUser, fetchCurrentUser } from '../composables/useCurrentUser'
3
+ function isHydratingServerRender(): boolean {
4
+ const app = useNuxtApp()
5
+ return import.meta.client && app.isHydrating === true && app.payload.serverRendered === true
6
+ }
7
+
8
+ export default defineNuxtRouteMiddleware(async (to, from) => {
9
+ const user = useCurrentUser()
10
+ if (isHydratingServerRender() && user.value) {
11
+ return
12
+ }
13
+ // Search and detail query changes stay on the already authenticated page.
14
+ if (import.meta.client && user.value && to.path === from.path) {
15
+ return
16
+ }
17
+
18
+ user.value = await fetchCurrentUser().catch(() => null)
19
+ if (!user.value) {
20
+ return navigateTo('/sign-in')
21
+ }
22
+ })
@@ -0,0 +1,3 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ redirect: '/dashboard/settings#organizations' })
3
+ </script>
@@ -0,0 +1,7 @@
1
+ <template>
2
+ <GithubAuthPanel
3
+ title="Sign in to CloudConfig"
4
+ description="Connect your work in one place."
5
+ label="Continue with GitHub"
6
+ />
7
+ </template>
@@ -0,0 +1,18 @@
1
+ <template>
2
+ <div>
3
+ <GithubAuthPanel
4
+ title="Create an account"
5
+ description="Connect your work in one place."
6
+ label="Sign up with GitHub"
7
+ />
8
+ <p class="mt-5 text-center text-sm text-muted">
9
+ Already have an account?
10
+ <NuxtLink
11
+ to="/sign-in"
12
+ class="font-medium text-highlighted underline-offset-4 hover:underline"
13
+ >
14
+ Sign in
15
+ </NuxtLink>
16
+ </p>
17
+ </div>
18
+ </template>
@@ -0,0 +1,148 @@
1
+ <script setup lang="ts">
2
+ import { useCloudConfigAccount, navigateTo } 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 auth = useCloudConfigTwoFactor()
10
+ const account = useCloudConfigAccount()
11
+ const user = useCurrentUser()
12
+ const status = ref<TwoFactorStatus | null>(null)
13
+ const code = ref('')
14
+ const isBusy = ref(false)
15
+ const error = ref('')
16
+ const message = ref('')
17
+
18
+ async function sendCode() {
19
+ const result = await auth.twoFactor.sendOtp({})
20
+ if (result.error) {
21
+ throw new Error(result.error.message ?? 'Could not send the verification code.')
22
+ }
23
+ message.value = 'We sent a six-digit code to the email address on your account.'
24
+ }
25
+
26
+ async function run(action: () => Promise<void>) {
27
+ if (isBusy.value) {
28
+ return
29
+ }
30
+ isBusy.value = true
31
+ error.value = ''
32
+ try {
33
+ await action()
34
+ } catch (cause) {
35
+ error.value =
36
+ cause instanceof Error ? cause.message : 'Could not complete verification. Try again.'
37
+ } finally {
38
+ isBusy.value = false
39
+ }
40
+ }
41
+
42
+ onMounted(() =>
43
+ run(async () => {
44
+ status.value = await account.getTwoFactorStatus()
45
+ if (!status.value.pending) {
46
+ await navigateTo('/dashboard')
47
+ return
48
+ }
49
+ if (status.value.method === 'email') {
50
+ await sendCode()
51
+ }
52
+ }),
53
+ )
54
+
55
+ function verify() {
56
+ return run(async () => {
57
+ const result =
58
+ status.value?.method === 'passkey'
59
+ ? await auth.signIn.passkey()
60
+ : await auth.twoFactor.verifyOtp({ code: code.value })
61
+ if (result.error) {
62
+ throw new Error(result.error.message ?? 'Verification failed. Try again.')
63
+ }
64
+ const session = await auth.getSession({ query: { disableCookieCache: true } })
65
+ if (session.error || !session.data) {
66
+ throw new Error('Could not read your verified session. Sign in again.')
67
+ }
68
+ user.value = session.data.user
69
+ await navigateTo('/dashboard')
70
+ })
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <section class="w-full space-y-5">
76
+ <div class="text-center">
77
+ <h1 class="text-2xl font-medium text-highlighted">Verify your sign-in</h1>
78
+ <p class="mt-2 text-sm text-muted">
79
+ {{
80
+ status?.method === 'passkey'
81
+ ? 'Use your passkey to finish signing in.'
82
+ : 'Enter the verification code sent to your email.'
83
+ }}
84
+ </p>
85
+ </div>
86
+ <p
87
+ v-if="error"
88
+ role="alert"
89
+ class="text-sm text-error"
90
+ >
91
+ {{ error }}
92
+ </p>
93
+ <p
94
+ v-if="message"
95
+ role="status"
96
+ class="text-sm text-muted"
97
+ >
98
+ {{ message }}
99
+ </p>
100
+ <form
101
+ v-if="status?.pending && status.method === 'email'"
102
+ class="space-y-4"
103
+ @submit.prevent="verify"
104
+ >
105
+ <UFormField label="Verification code">
106
+ <UInput
107
+ v-model="code"
108
+ autocomplete="one-time-code"
109
+ inputmode="numeric"
110
+ pattern="[0-9]{6}"
111
+ maxlength="6"
112
+ required
113
+ autofocus
114
+ :disabled="isBusy"
115
+ class="w-full"
116
+ />
117
+ </UFormField>
118
+ <UButton
119
+ block
120
+ type="submit"
121
+ label="Verify"
122
+ :loading="isBusy"
123
+ :disabled="code.length !== 6"
124
+ />
125
+ <UButton
126
+ block
127
+ label="Resend code"
128
+ color="neutral"
129
+ variant="link"
130
+ :disabled="isBusy"
131
+ @click="run(sendCode)"
132
+ />
133
+ </form>
134
+ <UButton
135
+ v-if="status?.pending && status.method === 'passkey'"
136
+ block
137
+ label="Verify with passkey"
138
+ :loading="isBusy"
139
+ @click="verify"
140
+ />
141
+ <NuxtLink
142
+ to="/sign-in"
143
+ class="block text-center text-sm text-muted underline-offset-4 hover:underline"
144
+ >
145
+ Sign in again
146
+ </NuxtLink>
147
+ </section>
148
+ </template>
@@ -0,0 +1,22 @@
1
+ export const fieldUi = {
2
+ root: 'w-full',
3
+ base: 'h-10 rounded-md bg-(--gray-100) px-3 py-0 text-sm font-normal text-highlighted ring-(--gray-500) focus-visible:ring-highlighted',
4
+ }
5
+ export const selectUi = {
6
+ ...fieldUi,
7
+ base: `${fieldUi.base} w-full ps-3 pe-9`,
8
+ }
9
+ export const formFieldUi = {
10
+ label: 'text-[13px] font-normal text-muted',
11
+ description: 'text-[13px] leading-normal text-muted',
12
+ container: 'mt-1.5',
13
+ }
14
+ export const switchUi = {
15
+ root: 'items-center justify-between gap-4',
16
+ container: 'order-last h-5 shrink-0',
17
+ wrapper: 'order-first ms-0 min-w-0 text-left',
18
+ label: 'cursor-pointer text-sm leading-normal font-normal text-highlighted',
19
+ description: 'mt-0.5 text-[13px] leading-normal text-muted',
20
+ base: 'h-5 w-9 cursor-pointer data-[state=unchecked]:bg-(--gray-500) motion-reduce:transition-none',
21
+ thumb: 'size-4 shadow-none data-[state=checked]:translate-x-4 motion-reduce:transition-none',
22
+ }
@@ -0,0 +1 @@
1
+ export default { resourceAliases: { avatar: ['avatars'] } }
@@ -0,0 +1,18 @@
1
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
2
+
3
+ /** `2024-06-24` → `24 Jun 2024`. */
4
+ export function formatJoined(iso: string): string {
5
+ const [year, month, day] = iso.split('-')
6
+ return year && month && day ? `${+day} ${MONTHS[+month - 1]} ${year}` : iso
7
+ }
8
+
9
+ /** Initials derived from the account name. */
10
+ export function initialsOf(name: string): string {
11
+ return name
12
+ .split(' ')
13
+ .filter(Boolean)
14
+ .map((word) => word[0])
15
+ .join('')
16
+ .slice(0, 2)
17
+ .toUpperCase()
18
+ }
@@ -0,0 +1,8 @@
1
+ import ProfileSettings from './components/ProfileSettings.vue'
2
+
3
+ export const identityContribution = {
4
+ id: 'identity',
5
+ label: 'Account',
6
+ icon: 'i-lucide-user-round',
7
+ generalPanels: [ProfileSettings],
8
+ } as const
package/schema.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { model, t } from '@cloudconfig/core'
2
+
3
+ export const identityModels = {
4
+ avatar: model(
5
+ {
6
+ name: t.text().required(),
7
+ mimeType: t.text().required(),
8
+ size: t.number().required(),
9
+ storageKey: t.text().required().unique().serverOnly(),
10
+ ownerId: t.number().required().serverOnly(),
11
+ },
12
+ {
13
+ access: {
14
+ list: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
15
+ read: ({ user }) => user != null,
16
+ create: ({ user }) => user != null,
17
+ delete: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
18
+ },
19
+ storage: {
20
+ binding: 'FILES',
21
+ path: '/avatar-content',
22
+ maxSize: 2 * 1024 * 1024,
23
+ mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
24
+ },
25
+ },
26
+ ),
27
+ }
28
+
29
+ export type IdentityConfig = { models: typeof identityModels }