@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
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ Proprietary software. All rights reserved.
2
+
3
+ Use of Nuxt Agentic packages and starter materials is governed by the applicable
4
+ commercial agreement supplied with a purchase. This repository does not grant
5
+ an open-source license or permission to redistribute the packages as a competing
6
+ template or module library.
7
+
8
+ Third-party dependencies retain their respective licenses. This notice does not
9
+ replace or modify those licenses.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # Identity
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@nuxtagentic/identity` owns sign-in, the current account, organization selection,
4
+ profile and avatar editing, organization logos, and security controls. It installs
5
+ Nuxt UI and the CloudConfig Nuxt module. It can be installed independently of the
6
+ workspace shell.
7
+
8
+ Add `@nuxtagentic/identity` to Nuxt's `modules` and include `identityModels` from
9
+ `@nuxtagentic/identity/schema` in the host's `cloud.config.ts`. The schema owns
10
+ identity image storage; CloudConfig's `auth` configuration owns users and sessions.
11
+ Cloudflare avatar and logo uploads require an R2 bucket with the `FILES` binding.
12
+ This starter declares it in `deploy.r2` and `deploy.r2Binding`; local uploads use
13
+ CloudConfig's development storage.
14
+
15
+ Configure GitHub and Google with the CloudConfig auth CLI for the supplied
16
+ sign-in screens. Set `auth.organizations: true` for workspace management and
17
+ `auth.twoFactor: true` for the supplied security controls. Account administration
18
+ is a separate optional capability in `auth.admin`.
19
+
20
+ The package registers `/sign-in`, `/sign-up`, `/two-factor`, the default layout,
21
+ and the `auth` route middleware. Identity also registers `/dashboard/organizations`.
22
+ The root app consumes its optional profile-settings
23
+ contribution. `useCurrentUser`, `useAccountSession`, `useWorkspaceScope`, and
24
+ `useWorkspaces` expose session and organization state to dependent modules.
25
+
26
+ In **Settings → Organization**, choose **Edit logo** to upload, replace, or remove
27
+ an organization's logo, then **Save logo**. Owners and admins can update logos;
28
+ the auth API enforces these permissions. New organizations can include a logo
29
+ when created. Supported formats are JPG, PNG, WebP, and GIF, up to 2 MB. Logos
30
+ appear in the organization list and workspace switcher. Logo editing uses the
31
+ `organizationLogo` URL parameter so direct links and browser Back/Forward work.
32
+
33
+ Identity image uploads share the existing `avatar` storage model and retain their
34
+ uploaded asset when saving fails, so retries do not upload the same file again.
35
+ `useProfileNamePreview` keeps live name previews within Identity's state ownership.
36
+
37
+ ## Single sign-on and provisioning
38
+
39
+ With `auth.sso: true` (and `@better-auth/sso` installed), **Settings → Organization →
40
+ Single sign-on** lets owners and admins register an OIDC or SAML identity provider and
41
+ verify their email domain with a DNS TXT record. People on that domain choose **Use single
42
+ sign-on** on the sign-in page and join the organization.
43
+
44
+ With `auth.scim: true`, **Directory provisioning** mints a SCIM token and shows the base
45
+ URL to paste into Okta, Entra ID or Google Workspace. Provisioned people join the
46
+ organization; deprovisioning removes the membership, which ends their access on the next
47
+ request and revokes their personal integration connections. Owners are never removed by
48
+ a directory.
49
+
50
+ Access rules see the active organization and role on `user.organization`; use
51
+ `orgRows`, `ownOrgRows` and `hasOrgRole` from `@cloudconfig/core`.
52
+
53
+ See [module installation](../README.md) for local package setup and schema composition.
package/module.ts ADDED
@@ -0,0 +1,61 @@
1
+ import {
2
+ createResolver,
3
+ defineNuxtModule,
4
+ addComponentsDir,
5
+ addLayout,
6
+ addRouteMiddleware,
7
+ } from '@nuxt/kit'
8
+ import { registerWorkspaceModule } from '@nuxtagentic/kit'
9
+
10
+ export default defineNuxtModule({
11
+ meta: { name: '@nuxtagentic/identity' },
12
+ moduleDependencies: {
13
+ '@nuxt/ui': {},
14
+ '@cloudconfig/nuxt': {},
15
+ },
16
+ setup(_options, nuxt) {
17
+ const resolver = createResolver(import.meta.url)
18
+ nuxt.hook('prepare:types', ({ tsConfig }) => {
19
+ tsConfig.compilerOptions ??= {}
20
+ tsConfig.compilerOptions.paths ??= {}
21
+ tsConfig.compilerOptions.paths['#cloudconfig/server'] = [
22
+ nuxt.options.nitro.alias!['#cloudconfig/server']!,
23
+ ]
24
+ })
25
+ registerWorkspaceModule(
26
+ {
27
+ id: 'identity',
28
+ runtime: resolver.resolve('./runtime'),
29
+ feature: { from: resolver.resolve('./runtime/workspace.ts'), name: 'identityContribution' },
30
+ metadata: resolver.resolve('./runtime/shared/agent.ts'),
31
+ },
32
+ nuxt,
33
+ )
34
+ addComponentsDir({ path: resolver.resolve('./runtime/components'), pathPrefix: true })
35
+ addLayout({ src: resolver.resolve('./runtime/layouts/default.vue') }, 'default')
36
+ addRouteMiddleware({ name: 'auth', path: resolver.resolve('./runtime/middleware/auth.ts') })
37
+ nuxt.hook('pages:extend', (pages) => {
38
+ const authConfig = nuxt.options.runtimeConfig.public.cloudconfig as
39
+ { twoFactor?: unknown } | undefined
40
+ const authPages = authConfig?.twoFactor
41
+ ? ['sign-in', 'sign-up', 'two-factor']
42
+ : ['sign-in', 'sign-up']
43
+ for (const name of authPages) {
44
+ if (!pages.some((page) => page.path === `/${name}`)) {
45
+ pages.push({
46
+ name,
47
+ path: `/${name}`,
48
+ file: resolver.resolve(`./runtime/pages/${name}.vue`),
49
+ })
50
+ }
51
+ }
52
+ if (!pages.some((page) => page.path === '/dashboard/organizations')) {
53
+ pages.push({
54
+ name: 'dashboard-organizations',
55
+ path: '/dashboard/organizations',
56
+ file: resolver.resolve('./runtime/pages/organizations.vue'),
57
+ })
58
+ }
59
+ })
60
+ },
61
+ })
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@nuxtagentic/identity",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "module.ts",
7
+ "runtime",
8
+ "schema.ts",
9
+ "README.md",
10
+ "LICENSE.md"
11
+ ],
12
+ "exports": {
13
+ ".": "./module.ts",
14
+ "./package.json": "./package.json",
15
+ "./schema": "./schema.ts",
16
+ "./runtime/*.vue": "./runtime/*.vue",
17
+ "./runtime/*.css": "./runtime/*.css",
18
+ "./runtime/*.json": "./runtime/*.json",
19
+ "./runtime/*.svg": "./runtime/*.svg",
20
+ "./runtime/*": "./runtime/*.ts"
21
+ },
22
+ "dependencies": {
23
+ "@better-auth/passkey": "^1.7.7",
24
+ "@cloudconfig/core": "0.2.0-beta.2",
25
+ "@cloudconfig/nuxt": "0.2.0-beta.2",
26
+ "@iconify-json/lucide": "^1.2.132",
27
+ "@iconify-json/simple-icons": "^1.2.96",
28
+ "@nuxt/kit": "4.6.1",
29
+ "@nuxt/ui": "^4.11.1",
30
+ "@vueuse/core": "^14.4.0",
31
+ "better-auth": "^1.7.7",
32
+ "@nuxtagentic/kit": "0.1.0"
33
+ },
34
+ "peerDependencies": {
35
+ "nuxt": "^4.6.1",
36
+ "vue": "^3.5.43"
37
+ },
38
+ "cloudconfig": {
39
+ "models": "identityModels"
40
+ },
41
+ "license": "SEE LICENSE IN LICENSE.md",
42
+ "description": "Sign-in, sessions and organizations for Nuxt Agentic.",
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "engines": {
48
+ "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
49
+ }
6
50
  }
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <span
3
+ class="shrink-0 font-mono text-[22px] font-normal leading-none"
4
+ aria-hidden="true"
5
+ >
6
+ [c]
7
+ </span>
8
+ </template>
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <svg
3
+ viewBox="0 0 80 72"
4
+ fill="none"
5
+ shape-rendering="crispEdges"
6
+ aria-hidden="true"
7
+ class="shrink-0"
8
+ >
9
+ <path
10
+ d="M26 8h24v4h8v8h4v8h8v4h4v20h-4v4H10v-4H6V36h4v-4h12V20h4Z"
11
+ class="fill-(--ui-text-highlighted)"
12
+ />
13
+ <path
14
+ d="M30 12h16v4h8v8h4v8h10v4h2v12h-4v4H14v-4h-4V38h4v-2h12V24h4Z"
15
+ class="fill-(--ui-bg)"
16
+ />
17
+ <path
18
+ d="M14 44h12v4h36v-4h8v4h-4v4H14Z"
19
+ class="fill-(--gray-300)"
20
+ />
21
+ <path
22
+ d="M32 30h4v8h-4zm16 0h4v8h-4zM38 42h10v4H38z"
23
+ class="fill-(--ui-text-highlighted)"
24
+ />
25
+ <path
26
+ d="M60 10h4v4h4v4h-4v4h-4v-4h-4v-4h4Z"
27
+ class="fill-[#f97316]"
28
+ />
29
+ <path
30
+ d="M28 64h24v4H28zM22 66h36v2H22z"
31
+ class="fill-(--gray-600)"
32
+ />
33
+ </svg>
34
+ </template>
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <svg
3
+ viewBox="0 0 80 72"
4
+ fill="none"
5
+ shape-rendering="crispEdges"
6
+ aria-hidden="true"
7
+ class="shrink-0"
8
+ >
9
+ <path
10
+ d="M26 8h24v4h8v8h4v8h8v4h4v20h-4v4H10v-4H6V36h4v-4h12V20h4Z"
11
+ class="fill-(--ui-text-highlighted)"
12
+ />
13
+ <path
14
+ d="M30 12h16v4h8v8h4v8h10v4h2v12h-4v4H14v-4h-4V38h4v-2h12V24h4Z"
15
+ class="fill-(--ui-bg)"
16
+ />
17
+ <path
18
+ d="M14 44h12v4h36v-4h8v4h-4v4H14Z"
19
+ class="fill-(--ui-bg-accented)"
20
+ />
21
+ <path
22
+ d="M32 30h4v8h-4zm16 0h4v8h-4zM38 42h10v4H38z"
23
+ class="fill-(--ui-text-highlighted)"
24
+ />
25
+ <path
26
+ d="M60 10h4v4h4v4h-4v4h-4v-4h-4v-4h4Z"
27
+ class="fill-orange-500"
28
+ />
29
+ <path
30
+ d="M28 64h24v4H28zM22 66h36v2H22z"
31
+ class="fill-(--ui-border-accented)"
32
+ />
33
+ </svg>
34
+ </template>
@@ -0,0 +1,158 @@
1
+ <script setup lang="ts">
2
+ import { useCloudConfigAuth, useRuntimeConfig } from '#imports'
3
+ import { ref } from 'vue'
4
+ import { useSsoClient } from '../composables/useOrganizationAccess'
5
+
6
+ const { title, description, label } = defineProps<{
7
+ title: string
8
+ description: string
9
+ label: string
10
+ }>()
11
+
12
+ const auth = useCloudConfigAuth()
13
+ const isGoogleEnabled = (useRuntimeConfig().public.authProviders ?? ['github', 'google']).includes(
14
+ 'google',
15
+ )
16
+ const isBusy = ref(false)
17
+ const provider = ref<'google' | 'github' | 'sso'>('google')
18
+ const error = ref('')
19
+ const sso = useSsoClient()
20
+ const showSso = ref(false)
21
+ const email = ref('')
22
+
23
+ /** Enterprise sign-in: the email domain picks the organization's identity provider. */
24
+ async function continueWithSso() {
25
+ if (isBusy.value || !email.value.includes('@')) {
26
+ return
27
+ }
28
+ isBusy.value = true
29
+ provider.value = 'sso'
30
+ error.value = ''
31
+ try {
32
+ window.location.assign(
33
+ await sso.signIn({
34
+ email: email.value.trim(),
35
+ callbackURL: '/dashboard',
36
+ errorCallbackURL: '/sign-in',
37
+ }),
38
+ )
39
+ } catch (cause) {
40
+ error.value =
41
+ cause instanceof Error && !/not found/i.test(cause.message)
42
+ ? cause.message
43
+ : 'No single sign-on is set up for that email domain.'
44
+ isBusy.value = false
45
+ }
46
+ }
47
+
48
+ async function continueWithProvider(value: 'google' | 'github') {
49
+ if (isBusy.value) {
50
+ return
51
+ }
52
+ isBusy.value = true
53
+ provider.value = value
54
+ error.value = ''
55
+ try {
56
+ const result = await auth.signIn.social({
57
+ provider: value,
58
+ callbackURL: '/dashboard',
59
+ })
60
+ if (result.error) {
61
+ throw new Error(result.error.message ?? `${label} failed.`)
62
+ }
63
+ } catch (cause) {
64
+ error.value = cause instanceof Error ? cause.message : `${label} failed.`
65
+ isBusy.value = false
66
+ }
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <section class="flex w-full flex-col items-center text-center">
72
+ <CloudMascot class="h-[90px] w-[100px]" />
73
+ <h1
74
+ class="mt-7 mb-2 text-[32px] leading-[1.15] font-normal tracking-[-0.03em] text-highlighted"
75
+ >
76
+ {{ title }}
77
+ </h1>
78
+ <p class="mb-8 text-[15px] leading-[22px] text-pretty text-muted">
79
+ {{ description }}
80
+ </p>
81
+ <SocialAuthButton
82
+ v-if="isGoogleEnabled"
83
+ label="Continue with Google"
84
+ busy-label="Redirecting to Google"
85
+ icon="i-simple-icons-google"
86
+ variant="solid"
87
+ :busy="isBusy && provider === 'google'"
88
+ :disabled="isBusy"
89
+ class="mb-3"
90
+ @click="continueWithProvider('google')"
91
+ />
92
+ <SocialAuthButton
93
+ :label="label"
94
+ busy-label="Redirecting to GitHub"
95
+ icon="i-simple-icons-github"
96
+ :variant="isGoogleEnabled ? 'outline' : 'solid'"
97
+ :busy="isBusy && provider === 'github'"
98
+ :disabled="isBusy"
99
+ @click="continueWithProvider('github')"
100
+ />
101
+ <form
102
+ v-if="showSso"
103
+ class="mt-3 flex w-full gap-2"
104
+ @submit.prevent="continueWithSso"
105
+ >
106
+ <UInput
107
+ v-model="email"
108
+ type="email"
109
+ required
110
+ autocomplete="email"
111
+ placeholder="you@company.com"
112
+ aria-label="Work email"
113
+ class="min-w-0 flex-1"
114
+ :ui="{ base: 'h-10' }"
115
+ />
116
+ <UButton
117
+ type="submit"
118
+ label="Continue"
119
+ color="neutral"
120
+ :loading="isBusy && provider === 'sso'"
121
+ :disabled="isBusy"
122
+ class="h-10 px-4"
123
+ />
124
+ </form>
125
+ <UButton
126
+ v-else
127
+ label="Use single sign-on"
128
+ color="neutral"
129
+ variant="link"
130
+ class="mt-3 text-[13px]"
131
+ @click="showSso = true"
132
+ />
133
+ <p
134
+ v-if="error"
135
+ role="alert"
136
+ class="mt-2 text-sm text-error"
137
+ >
138
+ {{ error }}
139
+ </p>
140
+ <p class="mt-6 text-[13px] leading-5 text-pretty text-muted">
141
+ By continuing, you agree to the
142
+ <a
143
+ href="#"
144
+ class="text-highlighted focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
145
+ >
146
+ Terms
147
+ </a>
148
+ and
149
+ <a
150
+ href="#"
151
+ class="text-highlighted focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
152
+ >
153
+ Privacy Policy
154
+ </a>
155
+ .
156
+ </p>
157
+ </section>
158
+ </template>
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ import { useCloudConfigAccount } from '#imports'
3
+ import { useCurrentUser } from '../composables/useCurrentUser'
4
+ import { ref, shallowRef, watch, onBeforeUnmount } from 'vue'
5
+ import { useProfileNamePreview } from '../composables/useProfileNamePreview'
6
+ import { useIdentityImageUpload } from '../composables/useIdentityImageUpload'
7
+
8
+ import { fieldUi, formFieldUi } from '../settings-ui'
9
+
10
+ const emit = defineEmits<{ finish: [] }>()
11
+ const account = useCloudConfigAccount()
12
+ const user = useCurrentUser()
13
+ const name = ref(user.value?.name ?? '')
14
+ const image = ref(user.value?.image ?? null)
15
+ const avatarFile = shallowRef<File | null>(null)
16
+ const imageUpload = useIdentityImageUpload()
17
+ const profilePreview = useProfileNamePreview()
18
+ watch(
19
+ name,
20
+ (value) => {
21
+ profilePreview.value = value
22
+ },
23
+ { immediate: true },
24
+ )
25
+ onBeforeUnmount(() => {
26
+ profilePreview.value = null
27
+ })
28
+ const isSaving = ref(false)
29
+ const error = ref('')
30
+ async function saveProfile() {
31
+ if (isSaving.value) {
32
+ return false
33
+ }
34
+ const value = name.value.trim()
35
+ if (!value) {
36
+ error.value = 'Enter your name.'
37
+ return false
38
+ }
39
+ if (
40
+ value === user.value?.name &&
41
+ image.value === (user.value?.image ?? null) &&
42
+ !avatarFile.value
43
+ ) {
44
+ return true
45
+ }
46
+
47
+ isSaving.value = true
48
+ error.value = ''
49
+ try {
50
+ let avatar = image.value
51
+ if (avatarFile.value) {
52
+ avatar = await imageUpload.upload(avatarFile.value)
53
+ }
54
+ await account.updateUser({ name: value, image: avatar })
55
+ if (user.value) {
56
+ user.value = { ...user.value, name: value, image: avatar }
57
+ }
58
+ name.value = value
59
+ image.value = avatar
60
+ avatarFile.value = null
61
+ imageUpload.clear()
62
+ return true
63
+ } catch (cause) {
64
+ error.value = cause instanceof Error ? cause.message : 'Could not update your profile.'
65
+ return false
66
+ } finally {
67
+ isSaving.value = false
68
+ }
69
+ }
70
+
71
+ defineExpose({ save: saveProfile })
72
+ </script>
73
+
74
+ <template>
75
+ <form
76
+ id="settings-profile"
77
+ @submit.prevent="emit('finish')"
78
+ >
79
+ <SettingsSection
80
+ title="Profile"
81
+ description="Shown in the sidebar."
82
+ >
83
+ <UFormField
84
+ label="Avatar"
85
+ :ui="formFieldUi"
86
+ >
87
+ <SettingsImageField
88
+ v-model:file="avatarFile"
89
+ v-model:image="image"
90
+ :name="name"
91
+ :identity="user?.id"
92
+ :disabled="isSaving"
93
+ />
94
+ </UFormField>
95
+ <UFormField
96
+ label="Name"
97
+ :ui="formFieldUi"
98
+ >
99
+ <UInput
100
+ v-model="name"
101
+ required
102
+ maxlength="200"
103
+ autocomplete="name"
104
+ :disabled="isSaving"
105
+ :ui="fieldUi"
106
+ />
107
+ </UFormField>
108
+ <UFormField
109
+ label="Email"
110
+ :ui="formFieldUi"
111
+ >
112
+ <UInput
113
+ :model-value="user?.email ?? ''"
114
+ type="email"
115
+ autocomplete="email"
116
+ readonly
117
+ title="Email is managed by your sign-in provider"
118
+ :ui="fieldUi"
119
+ />
120
+ </UFormField>
121
+ <p
122
+ v-if="error"
123
+ role="alert"
124
+ class="text-[13px] text-error"
125
+ >
126
+ {{ error }}
127
+ </p>
128
+ </SettingsSection>
129
+ </form>
130
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ const { title, description } = defineProps<{ title: string; description: string }>()
3
+ </script>
4
+
5
+ <template>
6
+ <section
7
+ class="grid gap-x-8 gap-y-4 border-t border-default py-6 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]"
8
+ >
9
+ <div>
10
+ <h2 class="text-[15px] leading-normal font-semibold text-highlighted">{{ title }}</h2>
11
+ <p class="mt-1 text-[13px] leading-normal text-muted">{{ description }}</p>
12
+ </div>
13
+ <div class="grid min-w-0 content-start gap-4">
14
+ <slot />
15
+ </div>
16
+ </section>
17
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ const { label, busyLabel, icon, variant, busy } = defineProps<{
3
+ label: string
4
+ busyLabel: string
5
+ icon: string
6
+ variant: 'solid' | 'outline'
7
+ busy: boolean
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <UButton
13
+ block
14
+ color="neutral"
15
+ :variant="variant"
16
+ :aria-busy="busy"
17
+ :aria-label="busy ? busyLabel : label"
18
+ class="relative h-11 w-full overflow-hidden rounded-[22px] border-0 p-0 text-[15px] font-medium ring-0 transition-[background-color,color,opacity,scale] duration-[240ms,240ms,160ms,120ms] ease-out motion-reduce:transition-none motion-reduce:active:scale-100"
19
+ :class="
20
+ busy &&
21
+ 'bg-primary text-inverted disabled:cursor-default disabled:bg-primary disabled:text-inverted disabled:opacity-100'
22
+ "
23
+ >
24
+ <span
25
+ v-if="busy"
26
+ aria-hidden="true"
27
+ class="absolute inset-y-0 left-0 w-1/2 animate-[carousel_1.8s_ease-in-out_infinite] bg-linear-to-r from-transparent via-(--ui-text-inverted)/20 to-transparent transition-opacity duration-500 starting:opacity-0 motion-reduce:hidden"
28
+ />
29
+ <span
30
+ :aria-hidden="busy"
31
+ class="absolute inset-0 flex items-center justify-center gap-2.5 transition-[opacity,translate] duration-[180ms,240ms] ease-out motion-reduce:transition-none"
32
+ :class="busy ? '-translate-y-2.5 opacity-0' : 'translate-y-0 opacity-100'"
33
+ >
34
+ <UIcon
35
+ :name="icon"
36
+ class="size-4 shrink-0"
37
+ />
38
+ <span>{{ label }}</span>
39
+ </span>
40
+ <span
41
+ :aria-hidden="!busy"
42
+ class="absolute inset-0 flex items-center justify-center transition-[opacity,translate] duration-[180ms,240ms] ease-out motion-reduce:transition-none"
43
+ :class="busy ? 'translate-y-0 opacity-100' : 'translate-y-2.5 opacity-0'"
44
+ >
45
+ {{ busyLabel }}
46
+ </span>
47
+ </UButton>
48
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ import type { AvatarProps } from '@nuxt/ui'
5
+ import { initialsOf } from '../utils/format'
6
+
7
+ const {
8
+ name,
9
+ identity: avatarIdentity,
10
+ src = null,
11
+ size = 'sm',
12
+ } = defineProps<{
13
+ name: string
14
+ identity?: string | number
15
+ src?: string | null
16
+ size?: AvatarProps['size']
17
+ }>()
18
+ const color = computed(() => {
19
+ const identity = avatarIdentity ?? name
20
+ const hash =
21
+ typeof identity === 'number'
22
+ ? identity
23
+ : [...identity].reduce((value, character) => (value * 31 + character.charCodeAt(0)) >>> 0, 0)
24
+ return (hash * 47 + 20) % 360
25
+ })
26
+ </script>
27
+
28
+ <template>
29
+ <UAvatar
30
+ :src="src ?? undefined"
31
+ :alt="name"
32
+ :text="initialsOf(name) || '?'"
33
+ :size="size"
34
+ :style="{ '--avatar-hue': color }"
35
+ :ui="{ root: 'bg-avatar text-black', fallback: 'font-semibold text-black' }"
36
+ />
37
+ </template>