@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.
- 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
|
@@ -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,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,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
|
+
}
|
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 }
|