@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.
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,211 @@
1
+ <script setup lang="ts">
2
+ import { useCloudConfigAccount, navigateTo, useRuntimeConfig } from '#imports'
3
+ import { useCurrentUser } from '../../composables/useCurrentUser'
4
+ import { ref, computed, onMounted } from 'vue'
5
+
6
+ import type { AuthAccount } from '@cloudconfig/core/client'
7
+
8
+ defineOptions({ name: 'SettingsSecurity' })
9
+
10
+ const account = useCloudConfigAccount()
11
+ const authConfig = useRuntimeConfig().public.cloudconfig as { twoFactor?: unknown }
12
+ const isTwoFactorEnabled = Boolean(authConfig.twoFactor)
13
+ const user = useCurrentUser()
14
+ const currentPassword = ref('')
15
+ const newPassword = ref('')
16
+ const deletePassword = ref('')
17
+ const isBusy = ref(false)
18
+ const isLoaded = ref(false)
19
+ const message = ref('')
20
+ const error = ref('')
21
+ const accounts = ref<AuthAccount[]>([])
22
+ const hasPassword = computed(() => accounts.value.some((item) => item.providerId === 'credential'))
23
+ const fieldUi = {
24
+ root: 'w-full',
25
+ 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',
26
+ }
27
+ const formFieldUi = {
28
+ label: 'text-[13px] font-normal text-muted',
29
+ container: 'mt-1.5',
30
+ }
31
+
32
+ onMounted(async () => {
33
+ try {
34
+ accounts.value = await account.listAccounts()
35
+ isLoaded.value = true
36
+ } catch (cause) {
37
+ error.value =
38
+ cause instanceof Error
39
+ ? cause.message
40
+ : 'Could not load security settings. Refresh to try again.'
41
+ }
42
+ })
43
+
44
+ async function changePassword() {
45
+ if (isBusy.value || !currentPassword.value || newPassword.value.length < 8) {
46
+ return
47
+ }
48
+ isBusy.value = true
49
+ error.value = ''
50
+ message.value = ''
51
+ try {
52
+ await account.changePassword({
53
+ currentPassword: currentPassword.value,
54
+ newPassword: newPassword.value,
55
+ revokeOtherSessions: true,
56
+ })
57
+ currentPassword.value = ''
58
+ newPassword.value = ''
59
+ message.value = 'Password changed and other sessions revoked.'
60
+ } catch (cause) {
61
+ error.value = cause instanceof Error ? cause.message : 'Could not change your password.'
62
+ } finally {
63
+ isBusy.value = false
64
+ }
65
+ }
66
+
67
+ async function removeAccount() {
68
+ if (
69
+ isBusy.value ||
70
+ !isLoaded.value ||
71
+ !window.confirm(
72
+ 'Permanently delete your account, sessions, and linked sign-in methods? This cannot be undone.',
73
+ )
74
+ ) {
75
+ return
76
+ }
77
+ isBusy.value = true
78
+ error.value = ''
79
+ message.value = ''
80
+ try {
81
+ await account.deleteUser(deletePassword.value ? { password: deletePassword.value } : {})
82
+ user.value = null
83
+ await navigateTo('/sign-in')
84
+ } catch (cause) {
85
+ error.value =
86
+ cause instanceof Error
87
+ ? cause.message
88
+ : 'Could not delete your account. Sign in again and retry.'
89
+ isBusy.value = false
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <div
96
+ id="security"
97
+ class="scroll-mt-6"
98
+ >
99
+ <SettingsTwoFactor
100
+ v-if="isTwoFactorEnabled"
101
+ :has-password="hasPassword"
102
+ />
103
+ <SettingsSection
104
+ title="Security"
105
+ description="Manage how you sign in."
106
+ >
107
+ <p
108
+ v-if="error"
109
+ role="alert"
110
+ class="text-[13px] text-error"
111
+ >
112
+ {{ error }}
113
+ </p>
114
+ <p
115
+ v-if="message"
116
+ role="status"
117
+ class="text-[13px] text-muted"
118
+ >
119
+ {{ message }}
120
+ </p>
121
+ <form
122
+ v-if="hasPassword"
123
+ class="grid gap-4"
124
+ @submit.prevent="changePassword"
125
+ >
126
+ <UFormField
127
+ label="Current password"
128
+ :ui="formFieldUi"
129
+ >
130
+ <UInput
131
+ v-model="currentPassword"
132
+ type="password"
133
+ autocomplete="current-password"
134
+ required
135
+ :disabled="isBusy"
136
+ :ui="fieldUi"
137
+ />
138
+ </UFormField>
139
+ <UFormField
140
+ label="New password"
141
+ :ui="formFieldUi"
142
+ >
143
+ <UInput
144
+ v-model="newPassword"
145
+ type="password"
146
+ autocomplete="new-password"
147
+ required
148
+ minlength="8"
149
+ :disabled="isBusy"
150
+ :ui="fieldUi"
151
+ />
152
+ </UFormField>
153
+ <p class="text-[13px] text-muted">Changing your password signs out your other sessions.</p>
154
+ <UButton
155
+ type="submit"
156
+ label="Change password"
157
+ :loading="isBusy"
158
+ :disabled="!currentPassword || newPassword.length < 8"
159
+ class="h-10 w-fit rounded-md px-4 text-sm font-medium"
160
+ />
161
+ </form>
162
+ <p
163
+ v-else-if="isLoaded"
164
+ class="text-sm text-muted"
165
+ >
166
+ Your sign-in provider manages your password.
167
+ </p>
168
+ <p
169
+ v-else-if="!error"
170
+ role="status"
171
+ class="text-[13px] text-muted"
172
+ >
173
+ Loading security settings…
174
+ </p>
175
+ </SettingsSection>
176
+ <SettingsSection
177
+ title="Delete account"
178
+ description="Permanently remove your profile, sessions, and linked sign-in accounts."
179
+ >
180
+ <UFormField
181
+ v-if="hasPassword"
182
+ label="Password"
183
+ :ui="formFieldUi"
184
+ >
185
+ <UInput
186
+ v-model="deletePassword"
187
+ type="password"
188
+ autocomplete="current-password"
189
+ :disabled="isBusy"
190
+ :ui="fieldUi"
191
+ />
192
+ </UFormField>
193
+ <p
194
+ v-else-if="isLoaded"
195
+ class="text-[13px] text-muted"
196
+ >
197
+ Sign in again before deleting your account.
198
+ </p>
199
+ <UButton
200
+ type="button"
201
+ color="error"
202
+ variant="outline"
203
+ label="Delete my account"
204
+ :loading="isBusy"
205
+ :disabled="!isLoaded"
206
+ class="h-10 w-fit rounded-md px-4 text-sm font-normal"
207
+ @click="removeAccount"
208
+ />
209
+ </SettingsSection>
210
+ </div>
211
+ </template>
@@ -0,0 +1,305 @@
1
+ <script setup lang="ts">
2
+ import { useCloudConfigOrganizations, useState, useRoute, navigateTo } from '#imports'
3
+ import { ref, shallowRef, computed, useTemplateRef, watch, onMounted, nextTick } from 'vue'
4
+
5
+ import type { Organization } from '@cloudconfig/core/client'
6
+ import { useWorkspaceOrganizations } from '../../composables/useWorkspaces'
7
+ import { useIdentityImageUpload } from '../../composables/useIdentityImageUpload'
8
+ import OrganizationLogoEditor from './OrganizationLogoEditor.vue'
9
+
10
+ const { active = true } = defineProps<{ active?: boolean }>()
11
+ const organizations = useCloudConfigOrganizations()
12
+ const activeOrganization = useState<Organization | null>('active-organization', () => null)
13
+ const route = useRoute()
14
+ const { data: items, pending, error: loadError, refresh } = useWorkspaceOrganizations()
15
+ const isSaving = ref(false)
16
+ const saveError = ref('')
17
+ const error = computed(
18
+ () => saveError.value || (loadError.value ? 'Could not load organizations.' : ''),
19
+ )
20
+ const name = ref('')
21
+ const slug = ref('')
22
+ const logo = ref<string | null>(null)
23
+ const logoFile = shallowRef<File | null>(null)
24
+ const imageUpload = useIdentityImageUpload()
25
+ const creating = computed(() => route.query.create === 'true')
26
+ const editingOrganization = computed(() =>
27
+ creating.value
28
+ ? null
29
+ : items.value.find((item) => String(item.id) === route.query.organizationLogo),
30
+ )
31
+ const nameInput = useTemplateRef('nameInput')
32
+ const fieldUi = {
33
+ root: 'w-full',
34
+ 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',
35
+ }
36
+ const formFieldUi = {
37
+ label: 'text-[13px] font-normal text-muted',
38
+ container: 'mt-1.5',
39
+ }
40
+
41
+ function updateName(value: string) {
42
+ name.value = value
43
+ slug.value = value
44
+ .toLowerCase()
45
+ .trim()
46
+ .replace(/[^a-z0-9]+/g, '-')
47
+ .replace(/(^-|-$)/g, '')
48
+ }
49
+
50
+ function showEditor() {
51
+ const query = { ...route.query }
52
+ delete query.organizationLogo
53
+ query.create = 'true'
54
+ return navigateTo({
55
+ path: '/dashboard/settings',
56
+ query,
57
+ hash: '#organizations',
58
+ })
59
+ }
60
+
61
+ function editLogo(item: Organization) {
62
+ const query = { ...route.query }
63
+ delete query.create
64
+ query.organizationLogo = String(item.id)
65
+ return navigateTo({ path: '/dashboard/settings', query, hash: '#organizations' })
66
+ }
67
+
68
+ function closeEditor() {
69
+ const query = { ...route.query }
70
+ delete query.create
71
+ delete query.organizationLogo
72
+ name.value = ''
73
+ slug.value = ''
74
+ logo.value = null
75
+ logoFile.value = null
76
+ imageUpload.clear()
77
+ saveError.value = ''
78
+ return navigateTo({ path: '/dashboard/settings', query, hash: '#organizations' })
79
+ }
80
+
81
+ function finishLogoEdit(updatedOrganization: Organization) {
82
+ items.value = items.value.map((item) =>
83
+ item.id === updatedOrganization.id ? updatedOrganization : item,
84
+ )
85
+ if (activeOrganization.value?.id === updatedOrganization.id) {
86
+ activeOrganization.value = updatedOrganization
87
+ }
88
+ return closeEditor()
89
+ }
90
+
91
+ async function createOrganization() {
92
+ if (isSaving.value || !name.value.trim() || !slug.value.trim()) {
93
+ return
94
+ }
95
+ isSaving.value = true
96
+ saveError.value = ''
97
+ try {
98
+ const image = logoFile.value ? await imageUpload.upload(logoFile.value) : logo.value
99
+ activeOrganization.value = await organizations.create({
100
+ name: name.value.trim(),
101
+ slug: slug.value.trim(),
102
+ logo: image,
103
+ })
104
+ await closeEditor()
105
+ await refresh()
106
+ } catch (cause) {
107
+ saveError.value = cause instanceof Error ? cause.message : 'Could not create the organization.'
108
+ } finally {
109
+ isSaving.value = false
110
+ }
111
+ }
112
+
113
+ async function activate(item: Organization) {
114
+ if (isSaving.value) {
115
+ return
116
+ }
117
+ isSaving.value = true
118
+ saveError.value = ''
119
+ try {
120
+ activeOrganization.value = await organizations.setActive(item.id)
121
+ } catch (cause) {
122
+ saveError.value = cause instanceof Error ? cause.message : 'Could not select the organization.'
123
+ } finally {
124
+ isSaving.value = false
125
+ }
126
+ }
127
+
128
+ watch(
129
+ () => active && creating.value,
130
+ async (value) => {
131
+ if (!value) {
132
+ return
133
+ }
134
+ await nextTick()
135
+ nameInput.value?.inputRef?.focus()
136
+ },
137
+ { flush: 'post' },
138
+ )
139
+
140
+ onMounted(() => {
141
+ if (active && creating.value) {
142
+ nameInput.value?.inputRef?.focus()
143
+ }
144
+ })
145
+ </script>
146
+
147
+ <template>
148
+ <SettingsSection
149
+ id="organizations"
150
+ title="Organization"
151
+ description="Manage your shared workspaces."
152
+ class="scroll-mt-6"
153
+ >
154
+ <p
155
+ v-if="error"
156
+ role="alert"
157
+ class="text-[13px] text-error"
158
+ >
159
+ {{ error }}
160
+ </p>
161
+ <div
162
+ :aria-busy="pending"
163
+ class="grid gap-4"
164
+ >
165
+ <div
166
+ v-for="item in items"
167
+ :key="item.id"
168
+ class="flex flex-wrap items-center justify-between gap-3"
169
+ >
170
+ <div class="flex min-w-0 flex-1 items-center gap-3">
171
+ <UAvatar
172
+ :src="item.logo ?? undefined"
173
+ :alt="item.name"
174
+ :text="item.name.charAt(0).toUpperCase()"
175
+ size="md"
176
+ :ui="{ root: 'rounded-md bg-(--gray-500)', image: 'object-contain' }"
177
+ />
178
+ <div class="min-w-0">
179
+ <p class="truncate text-sm text-highlighted">{{ item.name }}</p>
180
+ <p class="mt-0.5 truncate text-[13px] text-muted">{{ item.slug }}</p>
181
+ </div>
182
+ </div>
183
+ <div class="flex shrink-0 items-center gap-2">
184
+ <UButton
185
+ type="button"
186
+ color="neutral"
187
+ variant="outline"
188
+ label="Edit logo"
189
+ :aria-label="`Edit logo for ${item.name}`"
190
+ :disabled="isSaving"
191
+ class="h-9 rounded-md px-3 text-sm font-normal"
192
+ @click="editLogo(item)"
193
+ />
194
+ <UButton
195
+ type="button"
196
+ color="neutral"
197
+ variant="outline"
198
+ :label="activeOrganization?.id === item.id ? 'Active' : 'Use organization'"
199
+ :disabled="isSaving || activeOrganization?.id === item.id"
200
+ class="h-9 rounded-md px-3 text-sm font-normal"
201
+ @click="activate(item)"
202
+ />
203
+ </div>
204
+ </div>
205
+ <p
206
+ v-if="pending"
207
+ role="status"
208
+ class="text-[13px] text-muted"
209
+ >
210
+ Loading organizations…
211
+ </p>
212
+ <p
213
+ v-else-if="!items.length && !error"
214
+ class="text-sm text-muted"
215
+ >
216
+ No organizations yet. Create one to start a shared workspace.
217
+ </p>
218
+ </div>
219
+ <OrganizationLogoEditor
220
+ v-if="editingOrganization"
221
+ :key="editingOrganization.id"
222
+ :organization="editingOrganization"
223
+ @saved="finishLogoEdit"
224
+ @cancel="closeEditor"
225
+ @update:saving="isSaving = $event"
226
+ />
227
+ <form
228
+ v-if="creating"
229
+ class="grid gap-4"
230
+ @submit.prevent="createOrganization"
231
+ @keydown.esc.stop.prevent="!isSaving && closeEditor()"
232
+ >
233
+ <UFormField
234
+ label="Logo"
235
+ :ui="formFieldUi"
236
+ >
237
+ <SettingsImageField
238
+ v-model:file="logoFile"
239
+ v-model:image="logo"
240
+ kind="logo"
241
+ :name="name"
242
+ :disabled="isSaving"
243
+ />
244
+ </UFormField>
245
+ <UFormField
246
+ label="Name"
247
+ :ui="formFieldUi"
248
+ >
249
+ <UInput
250
+ ref="nameInput"
251
+ :model-value="name"
252
+ required
253
+ :disabled="isSaving"
254
+ :ui="fieldUi"
255
+ @update:model-value="updateName"
256
+ />
257
+ </UFormField>
258
+ <UFormField
259
+ label="Slug"
260
+ :ui="formFieldUi"
261
+ >
262
+ <UInput
263
+ v-model="slug"
264
+ required
265
+ pattern="[a-z0-9-]+"
266
+ :disabled="isSaving"
267
+ :ui="fieldUi"
268
+ />
269
+ </UFormField>
270
+ <div class="flex flex-wrap gap-2">
271
+ <UButton
272
+ type="submit"
273
+ label="Create organization"
274
+ :loading="isSaving"
275
+ :disabled="!name.trim() || !slug.trim()"
276
+ class="h-10 rounded-md px-4 text-sm font-medium"
277
+ />
278
+ <UButton
279
+ type="button"
280
+ color="neutral"
281
+ variant="outline"
282
+ label="Cancel"
283
+ :disabled="isSaving"
284
+ class="h-10 rounded-md px-4 text-sm font-normal"
285
+ @click="closeEditor"
286
+ />
287
+ </div>
288
+ </form>
289
+ <UButton
290
+ v-else
291
+ type="button"
292
+ color="neutral"
293
+ variant="outline"
294
+ icon="i-lucide-plus"
295
+ label="New organization"
296
+ :disabled="isSaving"
297
+ class="h-10 w-fit rounded-md px-4 text-sm font-normal"
298
+ @click="showEditor"
299
+ />
300
+ </SettingsSection>
301
+ <template v-if="active">
302
+ <SettingsSingleSignOn />
303
+ <SettingsProvisioning />
304
+ </template>
305
+ </template>