@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,118 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import { useWorkspaces } from '../composables/useWorkspaces'
4
+
5
+ import type { Organization } from '@cloudconfig/core/client'
6
+
7
+ const { collapsed = false } = defineProps<{ collapsed?: boolean }>()
8
+
9
+ const isOpen = ref(false)
10
+ const { activeOrganization, menu, switching, error, refresh } = await useWorkspaces(() => {
11
+ isOpen.value = false
12
+ })
13
+
14
+ function shouldShowDefaultLogo(organization: Organization | null) {
15
+ return (
16
+ !organization ||
17
+ (!organization.logo &&
18
+ (organization.name.toLowerCase() === 'cloudconfig' || organization.slug === 'cloudconfig'))
19
+ )
20
+ }
21
+
22
+ watch(isOpen, (value) => {
23
+ if (value) {
24
+ void refresh()
25
+ }
26
+ })
27
+ watch(
28
+ () => collapsed,
29
+ () => {
30
+ isOpen.value = false
31
+ },
32
+ )
33
+ </script>
34
+
35
+ <template>
36
+ <span class="inline-block w-fit max-w-full min-w-0 self-center align-middle leading-none">
37
+ <UDropdownMenu
38
+ v-model:open="isOpen"
39
+ :items="menu"
40
+ :modal="false"
41
+ :content="{ align: 'start', sideOffset: 4 }"
42
+ :ui="{
43
+ content:
44
+ 'z-30 box-border w-58 min-w-0 rounded-[10px] border border-default p-1.5 ring-0 origin-[24px_-16px] opacity-100 translate-y-0 scale-100 [clip-path:inset(0_0_0_0_round_10px)] transition-[clip-path,translate,scale,opacity] [transition-duration:240ms,240ms,240ms,160ms] ease-out starting:opacity-0 starting:-translate-y-1.5 starting:scale-[0.97] starting:[clip-path:inset(0_0_100%_0_round_10px)] data-[state=isOpen]:animate-none data-[state=closed]:animate-[fade-out_160ms_var(--ease-out)] data-[state=closed]:-translate-y-1.5 data-[state=closed]:scale-[0.97] data-[state=closed]:[clip-path:inset(0_0_100%_0_round_10px)] motion-reduce:animate-none motion-reduce:transition-none',
45
+ viewport: 'divide-y-0 scroll-py-0',
46
+ group: 'p-0',
47
+ label: 'gap-0 px-2.5 pt-1.5 pb-1 text-xs leading-[normal] font-normal text-muted',
48
+ item: 'h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-0 text-sm font-normal text-highlighted before:inset-0 before:rounded-md data-highlighted:before:bg-accented data-[state=isOpen]:before:bg-accented',
49
+ itemLeadingIcon: 'size-4 text-muted group-data-highlighted:text-muted',
50
+ itemTrailingIcon: 'size-4 text-highlighted',
51
+ separator: 'mx-1 my-1.5 h-px bg-(--ui-border)',
52
+ }"
53
+ >
54
+ <UButton
55
+ color="neutral"
56
+ variant="ghost"
57
+ size="md"
58
+ :square="collapsed"
59
+ :trailing-icon="collapsed ? undefined : 'i-lucide-chevron-down'"
60
+ :label="collapsed ? undefined : (activeOrganization?.name ?? 'Cloudconfig')"
61
+ class="h-10 w-auto max-w-full min-w-0 gap-2.5 rounded-lg py-0 pr-2.5 pl-2 text-[15px] leading-[normal] font-medium text-highlighted duration-160 hover:bg-(--gray-400) data-[state=isOpen]:bg-(--gray-400) motion-reduce:transition-none motion-reduce:active:scale-100"
62
+ :ui="{
63
+ label: 'min-w-0 truncate',
64
+ trailingIcon: 'size-4 text-muted',
65
+ }"
66
+ :aria-busy="switching"
67
+ aria-label="Open organization menu"
68
+ >
69
+ <template #leading>
70
+ <CloudconfigLogo
71
+ v-if="shouldShowDefaultLogo(activeOrganization)"
72
+ class="size-6"
73
+ />
74
+ <UAvatar
75
+ v-else
76
+ :src="activeOrganization?.logo ?? undefined"
77
+ :alt="activeOrganization?.name ?? 'Cloudconfig'"
78
+ :text="(activeOrganization?.name ?? 'Cloudconfig').charAt(0).toUpperCase()"
79
+ size="xs"
80
+ :ui="{
81
+ root: 'rounded-md bg-(--gray-500)',
82
+ image: 'object-contain',
83
+ fallback: 'text-xs font-semibold',
84
+ }"
85
+ />
86
+ </template>
87
+ </UButton>
88
+
89
+ <template #workspace-leading="{ item }">
90
+ <CloudconfigLogo
91
+ v-if="shouldShowDefaultLogo(item.workspace ?? null)"
92
+ class="size-5"
93
+ />
94
+ <UAvatar
95
+ v-else
96
+ :src="item.workspace?.logo ?? undefined"
97
+ :alt="item.label"
98
+ :text="item.label?.charAt(0).toUpperCase()"
99
+ :ui="{
100
+ root: 'size-5 rounded-[5px] bg-(--gray-500)',
101
+ image: 'object-contain',
102
+ fallback: 'text-[11px] font-semibold',
103
+ }"
104
+ />
105
+ </template>
106
+
107
+ <template #content-bottom>
108
+ <p
109
+ v-if="error"
110
+ role="alert"
111
+ class="px-2.5 py-1.5 text-xs leading-normal text-error"
112
+ >
113
+ Could not load organizations. Reopen the menu to try again.
114
+ </p>
115
+ </template>
116
+ </UDropdownMenu>
117
+ </span>
118
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { useTemplateRef, ref } from 'vue'
3
+
4
+ import { useObjectUrl } from '@vueuse/core'
5
+
6
+ const file = defineModel<File | null>('file', { required: true })
7
+ const image = defineModel<string | null>('image', { required: true })
8
+ const {
9
+ name,
10
+ identity,
11
+ disabled,
12
+ kind = 'avatar',
13
+ } = defineProps<{
14
+ name: string
15
+ identity?: string | number
16
+ disabled?: boolean
17
+ kind?: 'avatar' | 'logo'
18
+ }>()
19
+ const input = useTemplateRef<HTMLInputElement>('input')
20
+ const preview = useObjectUrl(file)
21
+ const error = ref('')
22
+
23
+ function select(event: Event) {
24
+ const target = event.target as HTMLInputElement
25
+ const selected = target.files?.[0]
26
+ target.value = ''
27
+ if (!selected) {
28
+ return
29
+ }
30
+ error.value = ''
31
+ if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(selected.type)) {
32
+ error.value = 'Choose a JPG, PNG, WebP, or GIF image.'
33
+ return
34
+ }
35
+ if (selected.size > 2 * 1024 * 1024) {
36
+ error.value = 'Choose an image smaller than 2 MB.'
37
+ return
38
+ }
39
+ file.value = selected
40
+ }
41
+
42
+ function remove() {
43
+ file.value = null
44
+ image.value = null
45
+ error.value = ''
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <div class="space-y-2">
51
+ <div class="flex items-center gap-4">
52
+ <UserAvatar
53
+ v-if="kind === 'avatar'"
54
+ :src="preview || image"
55
+ :name="name"
56
+ :identity="identity"
57
+ size="xl"
58
+ />
59
+ <UAvatar
60
+ v-else
61
+ :src="preview || image || undefined"
62
+ :alt="name || 'Organization logo'"
63
+ :text="name.charAt(0).toUpperCase()"
64
+ icon="i-lucide-building-2"
65
+ size="xl"
66
+ :ui="{ root: 'rounded-lg bg-(--gray-500)', image: 'object-contain' }"
67
+ />
68
+ <div class="flex min-w-0 flex-col gap-1.5">
69
+ <div class="flex items-center gap-2">
70
+ <UButton
71
+ type="button"
72
+ variant="outline"
73
+ :label="image || file ? `Change ${kind}` : `Upload ${kind}`"
74
+ :disabled="disabled"
75
+ @click="input?.click()"
76
+ />
77
+ <UButton
78
+ v-if="image || file"
79
+ type="button"
80
+ variant="ghost"
81
+ label="Remove"
82
+ :disabled="disabled"
83
+ @click="remove"
84
+ />
85
+ </div>
86
+ <p class="text-xs text-muted">JPG, PNG, WebP, or GIF. Up to 2 MB.</p>
87
+ </div>
88
+ <input
89
+ ref="input"
90
+ type="file"
91
+ accept="image/jpeg,image/png,image/webp,image/gif"
92
+ :aria-label="`Choose ${kind === 'logo' ? 'an organization logo' : 'an avatar image'}`"
93
+ class="hidden"
94
+ :disabled="disabled"
95
+ @change="select"
96
+ />
97
+ </div>
98
+ <p
99
+ v-if="error"
100
+ role="alert"
101
+ class="text-[13px] text-error"
102
+ >
103
+ {{ error }}
104
+ </p>
105
+ </div>
106
+ </template>
@@ -0,0 +1,149 @@
1
+ <script setup lang="ts">
2
+ import { useAsyncData, useCloudConfigOrganizations } from '#imports'
3
+ import { computed, ref, shallowRef } from 'vue'
4
+ import type { Organization } from '@cloudconfig/core/client'
5
+ import { useCurrentUser } from '../../composables/useCurrentUser'
6
+ import { useIdentityImageUpload } from '../../composables/useIdentityImageUpload'
7
+ import { formFieldUi } from '../../settings-ui'
8
+
9
+ const { organization } = defineProps<{ organization: Organization }>()
10
+ const emit = defineEmits<{
11
+ saved: [organization: Organization]
12
+ cancel: []
13
+ 'update:saving': [isSaving: boolean]
14
+ }>()
15
+ const organizations = useCloudConfigOrganizations()
16
+ const user = useCurrentUser()
17
+ const imageUpload = useIdentityImageUpload()
18
+ const logo = ref(organization.logo)
19
+ const logoFile = shallowRef<File | null>(null)
20
+ const isSaving = ref(false)
21
+ const saveError = ref('')
22
+ const {
23
+ data: details,
24
+ pending,
25
+ error: loadError,
26
+ refresh,
27
+ } = useAsyncData(
28
+ () => `organization-logo-${organization.id}`,
29
+ () => organizations.getFull(organization.id),
30
+ )
31
+ const canEditLogo = computed(() => {
32
+ const membership = details.value?.members.find(
33
+ (member) => String(member.userId) === String(user.value?.id),
34
+ )
35
+ return (
36
+ membership?.role.split(',').some((role) => ['owner', 'admin'].includes(role.trim())) ?? false
37
+ )
38
+ })
39
+ const hasChanges = computed(() => !!logoFile.value || logo.value !== organization.logo)
40
+
41
+ async function saveLogo() {
42
+ if (
43
+ isSaving.value ||
44
+ pending.value ||
45
+ loadError.value ||
46
+ !canEditLogo.value ||
47
+ !hasChanges.value
48
+ ) {
49
+ return
50
+ }
51
+
52
+ isSaving.value = true
53
+ emit('update:saving', true)
54
+ saveError.value = ''
55
+ try {
56
+ const image = logoFile.value ? await imageUpload.upload(logoFile.value) : logo.value
57
+ const updatedOrganization = await organizations.update(organization.id, { logo: image })
58
+ imageUpload.clear()
59
+ emit('saved', updatedOrganization)
60
+ } catch (cause) {
61
+ saveError.value =
62
+ cause instanceof Error ? cause.message : 'Could not save the organization logo.'
63
+ } finally {
64
+ isSaving.value = false
65
+ emit('update:saving', false)
66
+ }
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <form
72
+ class="grid gap-4 rounded-lg border border-default p-4"
73
+ :aria-busy="pending || isSaving"
74
+ @submit.prevent="saveLogo"
75
+ @keydown.esc.stop.prevent="!isSaving && emit('cancel')"
76
+ >
77
+ <p class="text-sm text-highlighted">{{ organization.name }} logo</p>
78
+ <p
79
+ v-if="pending"
80
+ role="status"
81
+ class="text-[13px] text-muted"
82
+ >
83
+ Loading organization permissions…
84
+ </p>
85
+ <div
86
+ v-else-if="loadError"
87
+ class="space-y-2"
88
+ >
89
+ <p
90
+ role="alert"
91
+ class="text-[13px] text-error"
92
+ >
93
+ {{ loadError.message || 'Could not load organization permissions.' }}
94
+ </p>
95
+ <UButton
96
+ type="button"
97
+ label="Try again"
98
+ color="neutral"
99
+ variant="outline"
100
+ @click="refresh()"
101
+ />
102
+ </div>
103
+ <UFormField
104
+ v-else-if="canEditLogo"
105
+ label="Logo"
106
+ :ui="formFieldUi"
107
+ >
108
+ <SettingsImageField
109
+ v-model:file="logoFile"
110
+ v-model:image="logo"
111
+ kind="logo"
112
+ :name="organization.name"
113
+ :disabled="isSaving"
114
+ />
115
+ </UFormField>
116
+ <p
117
+ v-else
118
+ class="text-[13px] text-muted"
119
+ >
120
+ Only organization owners and admins can change the logo.
121
+ </p>
122
+ <p
123
+ v-if="saveError"
124
+ role="alert"
125
+ class="text-[13px] text-error"
126
+ >
127
+ {{ saveError }}
128
+ </p>
129
+ <div class="flex flex-wrap gap-2">
130
+ <UButton
131
+ v-if="canEditLogo"
132
+ type="submit"
133
+ label="Save logo"
134
+ :loading="isSaving"
135
+ :disabled="pending || !hasChanges"
136
+ class="h-10 rounded-md px-4 text-sm font-medium"
137
+ />
138
+ <UButton
139
+ type="button"
140
+ color="neutral"
141
+ variant="outline"
142
+ label="Cancel"
143
+ :disabled="isSaving"
144
+ class="h-10 rounded-md px-4 text-sm font-normal"
145
+ @click="emit('cancel')"
146
+ />
147
+ </div>
148
+ </form>
149
+ </template>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref, watch } from 'vue'
3
+ import type { ScimToken } from '@cloudconfig/core/client'
4
+ import { useOrganizationAdmin, useSsoClient } from '../../composables/useOrganizationAccess'
5
+ import { fieldUi } from '../../settings-ui'
6
+
7
+ const sso = useSsoClient()
8
+ const { organization, isAdmin } = useOrganizationAdmin()
9
+ const tokens = ref<ScimToken[]>([])
10
+ const baseUrl = ref('')
11
+ const created = ref('')
12
+ const available = ref(true)
13
+ const isBusy = ref(false)
14
+ const error = ref('')
15
+
16
+ async function load() {
17
+ if (!isAdmin.value) {
18
+ return
19
+ }
20
+ try {
21
+ const result = await sso.scim.list()
22
+ tokens.value = result.tokens
23
+ baseUrl.value = result.baseUrl
24
+ } catch (cause) {
25
+ const status = (cause as { status?: number }).status
26
+ if (status === 404) {
27
+ available.value = false
28
+ } else {
29
+ error.value = cause instanceof Error ? cause.message : 'Could not load provisioning.'
30
+ }
31
+ }
32
+ }
33
+ onMounted(load)
34
+ watch(isAdmin, load)
35
+
36
+ async function run(work: () => Promise<unknown>) {
37
+ if (isBusy.value) {
38
+ return
39
+ }
40
+ isBusy.value = true
41
+ error.value = ''
42
+ try {
43
+ await work()
44
+ } catch (cause) {
45
+ error.value = cause instanceof Error ? cause.message : 'Something went wrong.'
46
+ } finally {
47
+ isBusy.value = false
48
+ }
49
+ }
50
+
51
+ const create = () =>
52
+ run(async () => {
53
+ const secret = await sso.scim.create('Directory')
54
+ created.value = secret.token
55
+ baseUrl.value = secret.baseUrl
56
+ await load()
57
+ })
58
+
59
+ const revoke = (token: ScimToken) =>
60
+ run(async () => {
61
+ await sso.scim.revoke(token.uid)
62
+ await load()
63
+ })
64
+
65
+ function date(value: number | null) {
66
+ return value ? new Date(value).toLocaleDateString() : 'never'
67
+ }
68
+
69
+ async function copy(value: string) {
70
+ error.value = ''
71
+ try {
72
+ await navigator.clipboard.writeText(value)
73
+ } catch (cause) {
74
+ error.value = cause instanceof Error ? cause.message : 'Could not copy to the clipboard.'
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <template>
80
+ <SettingsSection
81
+ v-if="available && organization && isAdmin"
82
+ title="Directory provisioning"
83
+ description="Okta, Entra ID or Google Workspace add and remove members over SCIM. Removing someone there ends their access here."
84
+ >
85
+ <p
86
+ v-if="error"
87
+ role="alert"
88
+ class="text-[13px] text-error"
89
+ >
90
+ {{ error }}
91
+ </p>
92
+ <div class="grid gap-2">
93
+ <span class="text-[13px] text-muted">SCIM base URL</span>
94
+ <div class="flex gap-2">
95
+ <UInput
96
+ :model-value="baseUrl"
97
+ readonly
98
+ aria-label="SCIM base URL"
99
+ :ui="fieldUi"
100
+ class="flex-1"
101
+ />
102
+ <UButton
103
+ label="Copy"
104
+ color="neutral"
105
+ variant="outline"
106
+ class="h-10 rounded-md px-3 text-sm font-normal"
107
+ @click="copy(baseUrl)"
108
+ />
109
+ </div>
110
+ </div>
111
+ <div
112
+ v-if="created"
113
+ class="grid gap-2 rounded-md border border-default p-3"
114
+ >
115
+ <p class="text-[13px] text-muted">
116
+ Paste this token into your identity provider now. It is shown once.
117
+ </p>
118
+ <div class="flex gap-2">
119
+ <UInput
120
+ :model-value="created"
121
+ readonly
122
+ aria-label="New SCIM token"
123
+ :ui="fieldUi"
124
+ class="flex-1"
125
+ />
126
+ <UButton
127
+ label="Copy"
128
+ color="neutral"
129
+ class="h-10 rounded-md px-3 text-sm font-normal"
130
+ @click="copy(created)"
131
+ />
132
+ </div>
133
+ </div>
134
+ <div
135
+ v-for="token in tokens"
136
+ :key="token.uid"
137
+ class="flex flex-wrap items-center justify-between gap-3"
138
+ >
139
+ <div class="min-w-0">
140
+ <p class="truncate text-sm text-highlighted">{{ token.label || 'Token' }}</p>
141
+ <p class="mt-0.5 truncate text-[13px] text-muted">
142
+ {{
143
+ token.revokedAt
144
+ ? `Revoked ${date(token.revokedAt)}`
145
+ : `Last used ${date(token.lastUsedAt)} · expires ${date(token.expiresAt)}`
146
+ }}
147
+ </p>
148
+ </div>
149
+ <UButton
150
+ v-if="!token.revokedAt"
151
+ label="Revoke"
152
+ color="neutral"
153
+ variant="outline"
154
+ :disabled="isBusy"
155
+ class="h-9 rounded-md px-3 text-sm font-normal"
156
+ @click="revoke(token)"
157
+ />
158
+ </div>
159
+ <UButton
160
+ type="button"
161
+ color="neutral"
162
+ variant="outline"
163
+ icon="i-lucide-key-round"
164
+ label="Create SCIM token"
165
+ :loading="isBusy"
166
+ class="h-10 w-fit rounded-md px-4 text-sm font-normal"
167
+ @click="create"
168
+ />
169
+ </SettingsSection>
170
+ </template>