wip(ui-server-auth): basic ui, not yet finalized
This commit is contained in:
@@ -69,6 +69,7 @@ watch(settings.themeColorsHueDynamic, () => {
|
||||
// Initialize first-time setup check when app mounts
|
||||
onMounted(async () => {
|
||||
analyticsStore.initialize()
|
||||
await displayModelsStore.initialize()
|
||||
cardStore.initialize()
|
||||
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
|
||||
@@ -122,6 +122,7 @@ context.value.on(electronSettingsNavigate, (event) => {
|
||||
|
||||
onMounted(async () => {
|
||||
analyticsStore.initialize()
|
||||
await displayModelsStore.initialize()
|
||||
cardStore.initialize()
|
||||
|
||||
await chatSessionStore.initialize()
|
||||
|
||||
@@ -37,9 +37,7 @@ import ResourceStatusIsland from '../components/stage-islands/resource-status-is
|
||||
import StatusIsland from '../components/stage-islands/status-island/index.vue'
|
||||
|
||||
import { electronOpenOnboarding } from '../../shared/eventa'
|
||||
import {
|
||||
modelSettingsRuntimeSnapshotChannelName,
|
||||
} from '../../shared/model-settings-runtime'
|
||||
import { modelSettingsRuntimeSnapshotChannelName } from '../../shared/model-settings-runtime'
|
||||
import { useChatSyncStore } from '../stores/chat-sync'
|
||||
import { useControlsIslandStore } from '../stores/controls-island'
|
||||
import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
|
||||
|
||||
@@ -81,6 +81,7 @@ watch(settings.themeColorsHueDynamic, () => {
|
||||
// Initialize first-time setup check when app mounts
|
||||
onMounted(async () => {
|
||||
analyticsStore.initialize()
|
||||
await displayModelsStore.initialize()
|
||||
cardStore.initialize()
|
||||
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
|
||||
|
||||
import { LoginDrawer } from '@proj-airi/stage-ui/components/auth'
|
||||
import { defaultSignInProviders, LoginDrawer, SignInPanel } from '@proj-airi/stage-ui/components/auth'
|
||||
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
|
||||
import { fetchSession, signInOIDC } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from '@proj-airi/stage-ui/libs/auth-config'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { onMounted, shallowRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const loading = ref<Record<OAuthProvider, boolean>>({
|
||||
google: false,
|
||||
github: false,
|
||||
})
|
||||
const pendingProvider = shallowRef<OAuthProvider | null>(null)
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
|
||||
async function handleSignIn(provider: OAuthProvider) {
|
||||
loading.value[provider] = true
|
||||
errorMessage.value = null
|
||||
pendingProvider.value = provider
|
||||
|
||||
try {
|
||||
await signInOIDC({
|
||||
clientId: OIDC_CLIENT_ID,
|
||||
@@ -29,10 +27,10 @@ async function handleSignIn(provider: OAuthProvider) {
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
|
||||
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
|
||||
}
|
||||
finally {
|
||||
loading.value[provider] = false
|
||||
pendingProvider.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +39,7 @@ onMounted(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const error = url.searchParams.get('error')
|
||||
if (error) {
|
||||
toast.error(error === 'auth_failed' ? 'Authentication failed. Please try again.' : error)
|
||||
errorMessage.value = error === 'auth_failed' ? 'Authentication failed. Please try again.' : error
|
||||
url.searchParams.delete('error')
|
||||
window.history.replaceState(null, '', url.pathname)
|
||||
}
|
||||
@@ -63,32 +61,31 @@ watch(isDesktop, (val) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isDesktop" class="min-h-screen flex flex-col items-center justify-center">
|
||||
<div class="mb-8 text-3xl font-bold">
|
||||
Sign in
|
||||
<main
|
||||
v-if="isDesktop"
|
||||
:class="[
|
||||
'relative min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(115,190,255,0.18),_transparent_45%),linear-gradient(180deg,_rgba(255,255,255,0.98),_rgba(243,244,246,0.96))] px-6 py-10',
|
||||
'dark:bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_38%),linear-gradient(180deg,_rgba(3,7,18,0.98),_rgba(10,15,28,0.98))]',
|
||||
'flex items-center justify-center',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'pointer-events-none absolute left-1/2 top-0 h-72 w-72 -translate-x-1/2 rounded-full bg-primary-300/20 blur-3xl',
|
||||
'dark:bg-primary-500/10',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div :class="['relative w-full max-w-md']">
|
||||
<SignInPanel
|
||||
:providers="defaultSignInProviders"
|
||||
:pending-provider="pendingProvider"
|
||||
:error="errorMessage"
|
||||
subtitle="Choose a provider to sign in and return to AIRI."
|
||||
@select="handleSignIn"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-w-xs w-full flex flex-col gap-3">
|
||||
<Button
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
icon="i-simple-icons-google"
|
||||
:loading="loading.google"
|
||||
@click="handleSignIn('google')"
|
||||
>
|
||||
<span>Google</span>
|
||||
</Button>
|
||||
<Button
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
icon="i-simple-icons-github"
|
||||
:loading="loading.github"
|
||||
@click="handleSignIn('github')"
|
||||
>
|
||||
<span>GitHub</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-8 text-xs text-gray-400">
|
||||
By continuing, you agree to our <a href="https://airi.moeru.ai/docs/en/about/terms" class="underline">Terms</a> and <a href="https://airi.moeru.ai/docs/en/about/privacy" class="underline">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div v-else class="min-h-screen flex flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-950">
|
||||
<div class="mb-12 flex flex-col items-center gap-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"DOM",
|
||||
@@ -8,6 +9,7 @@
|
||||
"DOM.Iterable",
|
||||
"DOM.AsyncIterable"
|
||||
],
|
||||
"useDefineForClassFields": true,
|
||||
"paths": {
|
||||
"@proj-airi/stage-ui/*": [
|
||||
"../../packages/stage-ui/src/*"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSettingsGeneral, useSettingsTheme } from '@proj-airi/stage-ui/stores
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { toast, Toaster } from 'vue-sonner'
|
||||
|
||||
const i18n = useI18n()
|
||||
@@ -27,6 +28,7 @@ watch(themeSettings.themeColorsHueDynamic, () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<RouterView />
|
||||
<ToasterRoot @close="id => toast.dismiss(id)">
|
||||
<Toaster />
|
||||
</ToasterRoot>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type AuthNoticeStatus = 'loading' | 'success' | 'fallback' | 'error'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
status: AuthNoticeStatus
|
||||
title: string
|
||||
description: string
|
||||
detail?: string
|
||||
primaryActionLabel?: string
|
||||
secondaryActionLabel?: string
|
||||
primaryActionDisabled?: boolean
|
||||
}>(), {
|
||||
detail: '',
|
||||
primaryActionDisabled: false,
|
||||
primaryActionLabel: undefined,
|
||||
secondaryActionLabel: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
primaryAction: []
|
||||
secondaryAction: []
|
||||
}>()
|
||||
|
||||
const iconClass = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'loading':
|
||||
return 'i-svg-spinners:3-dots-fade'
|
||||
case 'success':
|
||||
return 'i-solar:check-circle-bold-duotone'
|
||||
case 'fallback':
|
||||
return 'i-solar:danger-triangle-bold-duotone'
|
||||
case 'error':
|
||||
return 'i-solar:close-circle-bold-duotone'
|
||||
}
|
||||
|
||||
return 'i-solar:info-circle-bold-duotone'
|
||||
})
|
||||
|
||||
const accentClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'loading':
|
||||
return {
|
||||
halo: 'from-primary-300/35 via-sky-300/18 to-transparent dark:from-primary-500/25 dark:via-sky-400/12',
|
||||
icon: 'text-primary-500 dark:text-primary-300',
|
||||
}
|
||||
case 'success':
|
||||
return {
|
||||
halo: 'from-emerald-300/35 via-lime-300/18 to-transparent dark:from-emerald-500/25 dark:via-lime-400/12',
|
||||
icon: 'text-emerald-500 dark:text-emerald-300',
|
||||
}
|
||||
case 'fallback':
|
||||
return {
|
||||
halo: 'from-amber-300/35 via-orange-300/18 to-transparent dark:from-amber-500/25 dark:via-orange-400/12',
|
||||
icon: 'text-amber-500 dark:text-amber-300',
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
halo: 'from-rose-300/35 via-red-300/18 to-transparent dark:from-rose-500/25 dark:via-red-400/12',
|
||||
icon: 'text-rose-500 dark:text-rose-300',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
halo: 'from-primary-300/35 via-sky-300/18 to-transparent dark:from-primary-500/25 dark:via-sky-400/12',
|
||||
icon: 'text-primary-500 dark:text-primary-300',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'relative w-full max-w-[30rem] overflow-hidden rounded-[2rem] px-6 py-7 sm:px-8 sm:py-9',
|
||||
'border border-white/35 bg-white/55 shadow-[0_24px_80px_-36px_rgba(15,23,42,0.45)] backdrop-blur-xl',
|
||||
'dark:border-white/8 dark:bg-black/25 dark:shadow-[0_24px_90px_-42px_rgba(0,0,0,0.78)]',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'pointer-events-none absolute inset-x-0 top-0 h-40 bg-gradient-to-b blur-2xl',
|
||||
accentClasses.halo,
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="relative flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
:class="[
|
||||
'flex size-18 items-center justify-center rounded-[1.5rem] border border-white/45 bg-white/65 shadow-[inset_0_1px_0_rgba(255,255,255,0.65)] backdrop-blur-md',
|
||||
'dark:border-white/10 dark:bg-white/6 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]',
|
||||
]"
|
||||
>
|
||||
<div :class="[iconClass, accentClasses.icon, 'size-9']" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-[0.7rem] text-neutral-500 font-500 tracking-[0.26em] uppercase dark:text-neutral-400">
|
||||
AIRI account
|
||||
</p>
|
||||
<h1 class="text-balance text-2xl text-neutral-950 tracking-[-0.03em] font-[Nunito_Variable] sm:text-[2rem] dark:text-white">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="mx-auto max-w-[24rem] text-balance text-sm text-neutral-700 leading-6 dark:text-neutral-300">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="detail"
|
||||
:class="[
|
||||
'rounded-[1.25rem] px-4 py-3 text-sm leading-6 text-balance',
|
||||
'bg-white/55 text-neutral-600 dark:bg-white/6 dark:text-neutral-300',
|
||||
]"
|
||||
>
|
||||
{{ detail }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="primaryActionLabel || secondaryActionLabel || $slots.default"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<Button
|
||||
v-if="primaryActionLabel"
|
||||
block
|
||||
:disabled="primaryActionDisabled"
|
||||
@click="emit('primaryAction')"
|
||||
>
|
||||
{{ primaryActionLabel }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="secondaryActionLabel"
|
||||
block
|
||||
variant="secondary"
|
||||
@click="emit('secondaryAction')"
|
||||
>
|
||||
{{ secondaryActionLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
export type ElectronCallbackParseResult
|
||||
= | {
|
||||
status: 'ready'
|
||||
code: string
|
||||
port: string
|
||||
state: string
|
||||
relayUrl: string
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export function buildElectronLoopbackUrl(params: {
|
||||
code: string
|
||||
port: string
|
||||
state: string
|
||||
}) {
|
||||
const code = encodeURIComponent(params.code)
|
||||
const state = encodeURIComponent(params.state)
|
||||
|
||||
return `http://127.0.0.1:${params.port}/callback?code=${code}&state=${state}`
|
||||
}
|
||||
|
||||
export function parseElectronCallbackQuery(searchParams: URLSearchParams): ElectronCallbackParseResult {
|
||||
const error = searchParams.get('error') ?? ''
|
||||
const errorDescription = searchParams.get('error_description') ?? ''
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
message: errorDescription || error,
|
||||
status: 'error',
|
||||
}
|
||||
}
|
||||
|
||||
const code = searchParams.get('code') ?? ''
|
||||
const fullState = searchParams.get('state') ?? ''
|
||||
const separatorIndex = fullState.indexOf(':')
|
||||
|
||||
if (!code || separatorIndex === -1) {
|
||||
return {
|
||||
message: 'Invalid state parameter',
|
||||
status: 'error',
|
||||
}
|
||||
}
|
||||
|
||||
const port = fullState.slice(0, separatorIndex)
|
||||
const state = fullState.slice(separatorIndex + 1)
|
||||
|
||||
if (!port || !state) {
|
||||
return {
|
||||
message: 'Invalid state parameter',
|
||||
status: 'error',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
port,
|
||||
relayUrl: buildElectronLoopbackUrl({ code, port, state }),
|
||||
state,
|
||||
status: 'ready',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildElectronLoopbackUrl, parseElectronCallbackQuery } from './electron-callback.shared'
|
||||
|
||||
describe('parseElectronCallbackQuery', () => {
|
||||
it('extracts the loopback port and original state from the OIDC callback query', () => {
|
||||
const result = parseElectronCallbackQuery(new URLSearchParams({
|
||||
code: 'sample-code',
|
||||
state: '43123:opaque-original-state',
|
||||
}))
|
||||
|
||||
expect(result).toEqual({
|
||||
code: 'sample-code',
|
||||
port: '43123',
|
||||
relayUrl: 'http://127.0.0.1:43123/callback?code=sample-code&state=opaque-original-state',
|
||||
state: 'opaque-original-state',
|
||||
status: 'ready',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an error state when the provider callback includes an explicit error', () => {
|
||||
const result = parseElectronCallbackQuery(new URLSearchParams({
|
||||
error: 'access_denied',
|
||||
error_description: 'The request was rejected.',
|
||||
}))
|
||||
|
||||
expect(result).toEqual({
|
||||
message: 'The request was rejected.',
|
||||
status: 'error',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an error state when the state parameter does not contain a loopback port', () => {
|
||||
const result = parseElectronCallbackQuery(new URLSearchParams({
|
||||
code: 'sample-code',
|
||||
state: 'opaque-original-state',
|
||||
}))
|
||||
|
||||
expect(result).toEqual({
|
||||
message: 'Invalid state parameter',
|
||||
status: 'error',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildElectronLoopbackUrl', () => {
|
||||
it('encodes callback parameters into the localhost relay URL', () => {
|
||||
expect(buildElectronLoopbackUrl({
|
||||
code: 'code with spaces',
|
||||
port: '43123',
|
||||
state: 'state/with?chars',
|
||||
})).toBe('http://127.0.0.1:43123/callback?code=code%20with%20spaces&state=state%2Fwith%3Fchars')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createServerSignInContext, requestSocialSignInRedirect } from './sign-in'
|
||||
|
||||
describe('ui-server-auth sign-in flow helpers', () => {
|
||||
it('rebuilds the OIDC callback URL without provider and prompt query params', () => {
|
||||
expect(createServerSignInContext(
|
||||
'https://auth.airi.test/sign-in?client_id=airi-stage-web&provider=github&prompt=login&response_type=code&scope=openid',
|
||||
'https://api.airi.test',
|
||||
)).toEqual({
|
||||
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web&response_type=code&scope=openid',
|
||||
requestedProvider: 'github',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the root path when no OIDC parameters are present', () => {
|
||||
expect(createServerSignInContext(
|
||||
'https://auth.airi.test/sign-in',
|
||||
'https://api.airi.test',
|
||||
)).toEqual({
|
||||
callbackURL: '/',
|
||||
requestedProvider: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('posts the selected provider and callback URL to the social sign-in endpoint', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
||||
return new Response(JSON.stringify({ url: 'https://accounts.example.test/oauth/google' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(requestSocialSignInRedirect({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
provider: 'google',
|
||||
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
|
||||
fetchImpl,
|
||||
})).resolves.toBe('https://accounts.example.test/oauth/google')
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://api.airi.test/api/auth/sign-in/social',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
}),
|
||||
)
|
||||
|
||||
const init = fetchImpl.mock.calls[0]?.[1]
|
||||
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
provider: 'google',
|
||||
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces server-provided sign-in errors', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: 'Provider is temporarily unavailable',
|
||||
},
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(requestSocialSignInRedirect({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
provider: 'github',
|
||||
callbackURL: '/',
|
||||
fetchImpl,
|
||||
})).rejects.toThrow('Provider is temporarily unavailable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
|
||||
|
||||
export interface ServerSignInContext {
|
||||
callbackURL: string
|
||||
requestedProvider: string | null
|
||||
}
|
||||
|
||||
export interface SocialSignInRedirectParams {
|
||||
apiServerUrl: string
|
||||
provider: OAuthProvider
|
||||
callbackURL: string
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createServerSignInContext(currentUrl: string, apiServerUrl: string): ServerSignInContext {
|
||||
const url = new URL(currentUrl)
|
||||
const oidcParams = new URLSearchParams(url.searchParams)
|
||||
const requestedProvider = oidcParams.get('provider')
|
||||
|
||||
oidcParams.delete('provider')
|
||||
oidcParams.delete('prompt')
|
||||
|
||||
if (!oidcParams.size) {
|
||||
return {
|
||||
callbackURL: '/',
|
||||
requestedProvider,
|
||||
}
|
||||
}
|
||||
|
||||
const authorizeUrl = new URL('/api/auth/oauth2/authorize', apiServerUrl)
|
||||
authorizeUrl.search = oidcParams.toString()
|
||||
|
||||
return {
|
||||
callbackURL: authorizeUrl.toString(),
|
||||
requestedProvider,
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise<string> {
|
||||
const fetchImpl = params.fetchImpl ?? fetch
|
||||
const endpoint = new URL('/api/auth/sign-in/social', params.apiServerUrl)
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: params.provider,
|
||||
callbackURL: params.callbackURL,
|
||||
}),
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
if (response.type === 'opaqueredirect' || response.status === 302) {
|
||||
return response.headers.get('location') || '/'
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
url?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
if (typeof data.url === 'string')
|
||||
return data.url
|
||||
|
||||
throw new Error(getSignInErrorMessage(data.error))
|
||||
}
|
||||
|
||||
function getSignInErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string')
|
||||
return error
|
||||
|
||||
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string')
|
||||
return error.message
|
||||
|
||||
return 'Unexpected response'
|
||||
}
|
||||
@@ -1,7 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { BackgroundGradientOverlay } from '@proj-airi/stage-ui/components'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
|
||||
import AuthNotice from '../components/auth-notice.vue'
|
||||
|
||||
import { parseElectronCallbackQuery } from '../composables/electron-callback.shared'
|
||||
|
||||
type CallbackStatus = 'loading' | 'success' | 'fallback' | 'error'
|
||||
|
||||
interface CallbackViewModel {
|
||||
status: CallbackStatus
|
||||
title: string
|
||||
description: string
|
||||
detail?: string
|
||||
primaryActionLabel?: string
|
||||
secondaryActionLabel?: string
|
||||
primaryActionDisabled?: boolean
|
||||
relayUrl?: string
|
||||
}
|
||||
|
||||
const viewModel = shallowRef<CallbackViewModel>({
|
||||
description: 'Checking your sign-in response and preparing the handoff to AIRI.',
|
||||
primaryActionDisabled: true,
|
||||
status: 'loading',
|
||||
title: 'Completing sign-in',
|
||||
})
|
||||
|
||||
function setViewModel(next: CallbackViewModel) {
|
||||
viewModel.value = next
|
||||
}
|
||||
|
||||
function openRelayUrl() {
|
||||
if (!viewModel.value.relayUrl)
|
||||
return
|
||||
|
||||
window.location.assign(viewModel.value.relayUrl)
|
||||
}
|
||||
|
||||
function copyRelayUrl() {
|
||||
if (!viewModel.value.relayUrl)
|
||||
return
|
||||
|
||||
void navigator.clipboard?.writeText(viewModel.value.relayUrl)
|
||||
}
|
||||
|
||||
async function runRelayFlow() {
|
||||
const parsed = parseElectronCallbackQuery(new URLSearchParams(window.location.search))
|
||||
|
||||
if (parsed.status === 'error') {
|
||||
setViewModel({
|
||||
description: 'We could not use this sign-in response.',
|
||||
detail: parsed.message,
|
||||
status: 'error',
|
||||
title: 'Sign-in failed',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setViewModel({
|
||||
description: 'Passing your sign-in back to AIRI now. This page should close in a moment.',
|
||||
primaryActionDisabled: true,
|
||||
relayUrl: parsed.relayUrl,
|
||||
status: 'loading',
|
||||
title: 'Opening AIRI',
|
||||
})
|
||||
|
||||
try {
|
||||
await fetch(parsed.relayUrl)
|
||||
|
||||
setViewModel({
|
||||
description: 'AIRI accepted the sign-in response. This tab will try to close itself now.',
|
||||
detail: 'If nothing happens, you can close this tab manually and return to AIRI.',
|
||||
relayUrl: parsed.relayUrl,
|
||||
status: 'success',
|
||||
title: 'You are signed in',
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.close()
|
||||
}, 480)
|
||||
|
||||
window.setTimeout(() => {
|
||||
setViewModel({
|
||||
description: 'AIRI accepted the sign-in response. You can close this tab and continue in the app.',
|
||||
detail: 'Some browsers do not allow this page to close itself automatically.',
|
||||
relayUrl: parsed.relayUrl,
|
||||
secondaryActionLabel: 'Copy callback link',
|
||||
status: 'success',
|
||||
title: 'You are signed in',
|
||||
})
|
||||
}, 1200)
|
||||
}
|
||||
catch {
|
||||
setViewModel({
|
||||
description: 'The browser could not reach AIRI through the local callback port.',
|
||||
detail: 'We will try opening the local handoff directly. If that still fails, use the button below.',
|
||||
primaryActionDisabled: false,
|
||||
primaryActionLabel: 'Open AIRI manually',
|
||||
relayUrl: parsed.relayUrl,
|
||||
status: 'fallback',
|
||||
title: 'Finish sign-in in AIRI',
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.location.replace(parsed.relayUrl)
|
||||
}, 180)
|
||||
|
||||
window.setTimeout(() => {
|
||||
setViewModel({
|
||||
description: 'Automatic handoff did not finish in this browser session.',
|
||||
detail: parsed.relayUrl,
|
||||
primaryActionDisabled: false,
|
||||
primaryActionLabel: 'Open AIRI manually',
|
||||
relayUrl: parsed.relayUrl,
|
||||
secondaryActionLabel: 'Copy callback link',
|
||||
status: 'fallback',
|
||||
title: 'Open AIRI to continue',
|
||||
})
|
||||
}, 960)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void runRelayFlow()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<main
|
||||
:class="[
|
||||
'relative min-h-screen overflow-hidden px-4 py-10 sm:px-6',
|
||||
'flex items-center justify-center',
|
||||
'bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.95),rgba(245,247,255,0.72)_42%,rgba(238,243,255,0.42)_100%)]',
|
||||
'dark:bg-[radial-gradient(circle_at_top,rgba(27,31,45,0.96),rgba(12,15,24,0.92)_46%,rgba(5,7,12,1)_100%)]',
|
||||
]"
|
||||
>
|
||||
<BackgroundGradientOverlay color="color-mix(in srgb, rgb(83 122 255 / 28%) 55%, transparent)" />
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'pointer-events-none absolute left-1/2 top-[16%] size-[22rem] -translate-x-1/2 rounded-full blur-3xl',
|
||||
'bg-[radial-gradient(circle,rgba(118,156,255,0.28),transparent_68%)]',
|
||||
'dark:bg-[radial-gradient(circle,rgba(118,156,255,0.16),transparent_72%)]',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="relative z-1 max-w-3xl w-full flex flex-col items-center gap-5">
|
||||
<AuthNotice
|
||||
:description="viewModel.description"
|
||||
:detail="viewModel.detail"
|
||||
:primary-action-disabled="viewModel.primaryActionDisabled"
|
||||
:primary-action-label="viewModel.primaryActionLabel"
|
||||
:secondary-action-label="viewModel.secondaryActionLabel"
|
||||
:status="viewModel.status"
|
||||
:title="viewModel.title"
|
||||
@primary-action="openRelayUrl"
|
||||
@secondary-action="copyRelayUrl"
|
||||
>
|
||||
<a
|
||||
v-if="viewModel.relayUrl && viewModel.status === 'fallback'"
|
||||
:class="[
|
||||
'break-all text-center text-xs leading-6 text-neutral-500 underline decoration-dotted underline-offset-4',
|
||||
'hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200',
|
||||
]"
|
||||
:href="viewModel.relayUrl"
|
||||
>
|
||||
{{ viewModel.relayUrl }}
|
||||
</a>
|
||||
</AuthNotice>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
|
||||
@@ -1,7 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
|
||||
|
||||
import { defaultSignInProviders, SignInPanel } from '@proj-airi/stage-ui/components/auth'
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { createServerSignInContext, requestSocialSignInRedirect } from '../modules/sign-in'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const pendingProvider = shallowRef<OAuthProvider | null>(null)
|
||||
const autoStartedProvider = shallowRef<OAuthProvider | null>(null)
|
||||
|
||||
const providerLookup = new Set<OAuthProvider>(defaultSignInProviders.map(provider => provider.id))
|
||||
|
||||
const signInContext = computed(() => createServerSignInContext(window.location.href, SERVER_URL))
|
||||
|
||||
const requestedProvider = computed<OAuthProvider | null>(() => {
|
||||
const provider = signInContext.value.requestedProvider
|
||||
|
||||
if (!provider || !providerLookup.has(provider as OAuthProvider))
|
||||
return null
|
||||
|
||||
return provider as OAuthProvider
|
||||
})
|
||||
|
||||
watch(() => route.query.error, (value) => {
|
||||
errorMessage.value = typeof value === 'string' ? value : null
|
||||
}, { immediate: true })
|
||||
|
||||
watch(requestedProvider, async (provider) => {
|
||||
if (!provider || autoStartedProvider.value === provider)
|
||||
return
|
||||
|
||||
autoStartedProvider.value = provider
|
||||
await handleProviderSelect(provider)
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleProviderSelect(provider: OAuthProvider) {
|
||||
errorMessage.value = null
|
||||
pendingProvider.value = provider
|
||||
|
||||
try {
|
||||
const redirectUrl = await requestSocialSignInRedirect({
|
||||
apiServerUrl: SERVER_URL,
|
||||
provider,
|
||||
callbackURL: signInContext.value.callbackURL,
|
||||
})
|
||||
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Sign in failed'
|
||||
pendingProvider.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<main
|
||||
:class="[
|
||||
'relative min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(115,190,255,0.18),_transparent_45%),linear-gradient(180deg,_rgba(255,255,255,0.98),_rgba(243,244,246,0.96))] px-6 py-10',
|
||||
'dark:bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_38%),linear-gradient(180deg,_rgba(3,7,18,0.98),_rgba(10,15,28,0.98))]',
|
||||
'flex items-center justify-center',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'pointer-events-none absolute left-1/2 top-0 h-72 w-72 -translate-x-1/2 rounded-full bg-primary-300/20 blur-3xl',
|
||||
'dark:bg-primary-500/10',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div :class="['relative w-full max-w-md']">
|
||||
<SignInPanel
|
||||
:providers="defaultSignInProviders"
|
||||
:pending-provider="pendingProvider"
|
||||
:error="errorMessage"
|
||||
subtitle="Pick a provider to resume the AIRI authorization flow."
|
||||
@select="handleProviderSelect"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { join } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
|
||||
import { loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
env: loadEnv(mode, join(cwd(), 'apps', 'ui-server-auth'), ''),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { toast } from 'vue-sonner'
|
||||
|
||||
import { signInOIDC } from '../../libs/auth'
|
||||
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from '../../libs/auth-config'
|
||||
import { defaultSignInProviders } from './providers'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
@@ -53,20 +54,14 @@ async function handleSignIn(provider: OAuthProvider) {
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Button
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
:class="['w-full', 'py-4', 'flex', 'items-center', 'justify-center', 'gap-3', 'text-lg', 'rounded-2xl']"
|
||||
icon="i-simple-icons-google"
|
||||
:loading="loading.google"
|
||||
@click="handleSignIn('google')"
|
||||
:icon="provider.icon"
|
||||
:loading="loading[provider.id]"
|
||||
@click="handleSignIn(provider.id)"
|
||||
>
|
||||
<span>Sign in with Google</span>
|
||||
</Button>
|
||||
<Button
|
||||
:class="['w-full', 'py-4', 'flex', 'items-center', 'justify-center', 'gap-3', 'text-lg', 'rounded-2xl']"
|
||||
icon="i-simple-icons-github"
|
||||
:loading="loading.github"
|
||||
@click="handleSignIn('github')"
|
||||
>
|
||||
<span>Sign in with GitHub</span>
|
||||
<span>Sign in with {{ provider.name }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-10 pb-2 text-center text-xs text-gray-400">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import type { OAuthProvider } from '../../libs/auth'
|
||||
import type { SignInProviderDefinition } from './providers'
|
||||
|
||||
import { Button, Callout } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Alert from '../misc/alert.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
providers: readonly SignInProviderDefinition[]
|
||||
pendingProvider?: OAuthProvider | null
|
||||
error?: string | null
|
||||
}>(), {
|
||||
title: 'Sign in to AIRI',
|
||||
subtitle: 'Choose a provider to continue your authorization flow.',
|
||||
pendingProvider: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [provider: OAuthProvider]
|
||||
}>()
|
||||
|
||||
const termsHref = 'https://airi.moeru.ai/docs/en/about/terms'
|
||||
const privacyHref = 'https://airi.moeru.ai/docs/en/about/privacy'
|
||||
|
||||
const hasProviders = computed(() => props.providers.length > 0)
|
||||
|
||||
function handleSelect(provider: OAuthProvider) {
|
||||
if (props.pendingProvider)
|
||||
return
|
||||
|
||||
emit('select', provider)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'relative overflow-hidden rounded-[2rem] border border-white/60 bg-white/82 p-6 shadow-[0_24px_80px_-40px_rgba(15,23,42,0.45)] backdrop-blur-xl',
|
||||
'dark:border-white/10 dark:bg-neutral-950/82',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'pointer-events-none absolute inset-x-0 top-0 h-36 bg-gradient-to-br from-primary-300/35 via-sky-200/20 to-transparent blur-2xl',
|
||||
'dark:from-primary-500/15 dark:via-cyan-500/12',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div :class="['relative flex flex-col gap-6']">
|
||||
<div :class="['flex flex-col gap-4']">
|
||||
<Callout theme="primary" label="Server sign-in">
|
||||
Continue with one of your connected identity providers to complete access.
|
||||
</Callout>
|
||||
|
||||
<div :class="['space-y-3']">
|
||||
<div :class="['inline-flex h-13 w-13 items-center justify-center rounded-2xl bg-neutral-950 text-xl text-white shadow-lg', 'dark:bg-white dark:text-neutral-950']">
|
||||
Ai
|
||||
</div>
|
||||
|
||||
<div :class="['space-y-2']">
|
||||
<h1 :class="['text-balance text-3xl font-semibold tracking-tight text-neutral-950 dark:text-neutral-25']">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p :class="['max-w-sm text-sm leading-6 text-neutral-600 dark:text-neutral-300']">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" type="error">
|
||||
<template #title>
|
||||
Sign-in failed
|
||||
</template>
|
||||
<template #content>
|
||||
{{ error }}
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<div v-if="hasProviders" :class="['flex flex-col gap-3']">
|
||||
<Button
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
block
|
||||
:icon="provider.icon"
|
||||
:loading="pendingProvider === provider.id"
|
||||
:disabled="Boolean(pendingProvider)"
|
||||
:class="[
|
||||
'!justify-start rounded-2xl px-5 py-4 text-base font-medium',
|
||||
'shadow-[0_12px_30px_-24px_rgba(15,23,42,0.6)]',
|
||||
]"
|
||||
@click="handleSelect(provider.id)"
|
||||
>
|
||||
<span :class="['truncate']">Continue with {{ provider.name }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert v-else type="warning">
|
||||
<template #title>
|
||||
No providers available
|
||||
</template>
|
||||
<template #content>
|
||||
The sign-in page is not configured with any providers yet.
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<footer :class="['text-xs leading-5 text-neutral-500 dark:text-neutral-400']">
|
||||
By continuing, you agree to our
|
||||
<a :href="termsHref" :class="['font-medium text-neutral-700 underline-offset-4 hover:underline dark:text-neutral-200']">
|
||||
Terms
|
||||
</a>
|
||||
and
|
||||
<a :href="privacyHref" :class="['font-medium text-neutral-700 underline-offset-4 hover:underline dark:text-neutral-200']">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1 +1,3 @@
|
||||
export { default as LoginDrawer } from './LoginDrawer.vue'
|
||||
export * from './providers'
|
||||
export { default as SignInPanel } from './SignInPanel.vue'
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OAuthProvider } from '../../libs/auth'
|
||||
|
||||
export interface SignInProviderDefinition {
|
||||
id: OAuthProvider
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const defaultSignInProviders = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'i-simple-icons-google',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
icon: 'i-simple-icons-github',
|
||||
},
|
||||
] satisfies SignInProviderDefinition[]
|
||||
@@ -1,15 +1,10 @@
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { loadLive2DModelPreview as generateLive2DPreview } from '@proj-airi/stage-ui-live2d/utils/live2d-preview'
|
||||
import { loadVrmModelPreview as generateVrmPreview } from '@proj-airi/stage-ui-three/utils/vrm-preview'
|
||||
import { until } from '@vueuse/core'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import '@proj-airi/stage-ui-live2d/utils/live2d-zip-loader'
|
||||
import '@proj-airi/stage-ui-live2d/utils/live2d-opfs-registration'
|
||||
|
||||
export enum DisplayModelFormat {
|
||||
Live2dZip = 'live2d-zip',
|
||||
Live2dDirectory = 'live2d-directory',
|
||||
@@ -61,6 +56,9 @@ const displayModelsPresets: DisplayModel[] = [
|
||||
export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
const displayModels = ref<DisplayModel[]>([])
|
||||
|
||||
let generateLive2DPreview: (file: File) => Promise<string | undefined>
|
||||
let generateVrmPreview: (file: File) => Promise<string | undefined>
|
||||
|
||||
const displayModelsFromIndexedDBLoading = ref(false)
|
||||
|
||||
async function loadDisplayModelsFromIndexedDB() {
|
||||
@@ -96,10 +94,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
}
|
||||
|
||||
const loadLive2DModelPreview = (file: File) => generateLive2DPreview(file)
|
||||
|
||||
async function loadVrmModelPreview(file: File) {
|
||||
return generateVrmPreview(file)
|
||||
}
|
||||
const loadVrmModelPreview = (file: File) => generateVrmPreview(file)
|
||||
|
||||
async function addDisplayModel(format: DisplayModelFormat, file: File) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
@@ -145,10 +140,22 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
displayModels.value = [...displayModelsPresets].sort((a, b) => b.importedAt - a.importedAt)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await import('@proj-airi/stage-ui-live2d/utils/live2d-zip-loader')
|
||||
await import('@proj-airi/stage-ui-live2d/utils/live2d-opfs-registration')
|
||||
|
||||
const { loadLive2DModelPreview } = await import('@proj-airi/stage-ui-live2d/utils/live2d-preview')
|
||||
const { loadVrmModelPreview } = await import('@proj-airi/stage-ui-three/utils/vrm-preview')
|
||||
|
||||
generateLive2DPreview = loadLive2DModelPreview
|
||||
generateVrmPreview = loadVrmModelPreview
|
||||
}
|
||||
|
||||
return {
|
||||
displayModels,
|
||||
displayModelsFromIndexedDBLoading,
|
||||
|
||||
initialize,
|
||||
loadDisplayModelsFromIndexedDB,
|
||||
getDisplayModel,
|
||||
addDisplayModel,
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'apps/server',
|
||||
'apps/ui-server-auth',
|
||||
'apps/stage-tamagotchi',
|
||||
'packages/audio-pipelines-transcribe',
|
||||
'packages/cap-vite',
|
||||
|
||||
Reference in New Issue
Block a user