@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.
- package/LICENSE.md +9 -0
- package/README.md +52 -2
- package/module.ts +61 -0
- package/package.json +47 -3
- package/runtime/components/BrandMark.vue +8 -0
- package/runtime/components/CloudMascot.vue +34 -0
- package/runtime/components/CloudconfigLogo.vue +34 -0
- package/runtime/components/GithubAuthPanel.vue +158 -0
- package/runtime/components/ProfileSettings.vue +130 -0
- package/runtime/components/SettingsSection.vue +17 -0
- package/runtime/components/SocialAuthButton.vue +48 -0
- package/runtime/components/UserAvatar.vue +37 -0
- package/runtime/components/WorkspaceSwitcher.vue +118 -0
- package/runtime/components/settings/ImageField.vue +106 -0
- package/runtime/components/settings/OrganizationLogoEditor.vue +149 -0
- package/runtime/components/settings/Provisioning.vue +170 -0
- package/runtime/components/settings/Security.vue +211 -0
- package/runtime/components/settings/SettingsOrganizations.vue +305 -0
- package/runtime/components/settings/SingleSignOn.vue +396 -0
- package/runtime/components/settings/TwoFactor.vue +256 -0
- package/runtime/composables/useCurrentUser.ts +69 -0
- package/runtime/composables/useIdentityImageUpload.ts +31 -0
- package/runtime/composables/useOrganizationAccess.ts +46 -0
- package/runtime/composables/useProfileNamePreview.ts +4 -0
- package/runtime/composables/useWorkspaceScope.ts +12 -0
- package/runtime/composables/useWorkspaces.ts +108 -0
- package/runtime/layouts/default.vue +20 -0
- package/runtime/middleware/auth.ts +22 -0
- package/runtime/pages/organizations.vue +3 -0
- package/runtime/pages/sign-in.vue +7 -0
- package/runtime/pages/sign-up.vue +18 -0
- package/runtime/pages/two-factor.vue +148 -0
- package/runtime/settings-ui.ts +22 -0
- package/runtime/shared/agent.ts +1 -0
- package/runtime/utils/format.ts +18 -0
- package/runtime/workspace.ts +8 -0
- 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
|
-
#
|
|
1
|
+
# Identity
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
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.1"
|
|
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,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>
|