+
toast.dismiss(id)">
diff --git a/apps/ui-server-auth/src/components/auth-notice.vue b/apps/ui-server-auth/src/components/auth-notice.vue
new file mode 100644
index 000000000..080d6f140
--- /dev/null
+++ b/apps/ui-server-auth/src/components/auth-notice.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ AIRI account
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
+ {{ detail }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ui-server-auth/src/composables/electron-callback.shared.ts b/apps/ui-server-auth/src/composables/electron-callback.shared.ts
new file mode 100644
index 000000000..30c9848fa
--- /dev/null
+++ b/apps/ui-server-auth/src/composables/electron-callback.shared.ts
@@ -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',
+ }
+}
diff --git a/apps/ui-server-auth/src/composables/electron-callback.test.ts b/apps/ui-server-auth/src/composables/electron-callback.test.ts
new file mode 100644
index 000000000..e6a3a8cf7
--- /dev/null
+++ b/apps/ui-server-auth/src/composables/electron-callback.test.ts
@@ -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')
+ })
+})
diff --git a/apps/ui-server-auth/src/modules/sign-in.test.ts b/apps/ui-server-auth/src/modules/sign-in.test.ts
new file mode 100644
index 000000000..d6c745d95
--- /dev/null
+++ b/apps/ui-server-auth/src/modules/sign-in.test.ts
@@ -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
(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(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')
+ })
+})
diff --git a/apps/ui-server-auth/src/modules/sign-in.ts b/apps/ui-server-auth/src/modules/sign-in.ts
new file mode 100644
index 000000000..3a4ccae37
--- /dev/null
+++ b/apps/ui-server-auth/src/modules/sign-in.ts
@@ -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 {
+ 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'
+}
diff --git a/apps/ui-server-auth/src/pages/electron-callback.vue b/apps/ui-server-auth/src/pages/electron-callback.vue
index a78fb6fd3..a1bfd0fb7 100644
--- a/apps/ui-server-auth/src/pages/electron-callback.vue
+++ b/apps/ui-server-auth/src/pages/electron-callback.vue
@@ -1,7 +1,179 @@
-
+
+
+
+
+
+
+
+
+
+meta:
+ layout: plain
+
diff --git a/apps/ui-server-auth/src/pages/sign-in.vue b/apps/ui-server-auth/src/pages/sign-in.vue
index a78fb6fd3..7360a0c98 100644
--- a/apps/ui-server-auth/src/pages/sign-in.vue
+++ b/apps/ui-server-auth/src/pages/sign-in.vue
@@ -1,7 +1,92 @@
-
+
+
+
+
+
+
+
+
+
+meta:
+ layout: plain
+
diff --git a/apps/ui-server-auth/vitest.config.ts b/apps/ui-server-auth/vitest.config.ts
new file mode 100644
index 000000000..655cf89b8
--- /dev/null
+++ b/apps/ui-server-auth/vitest.config.ts
@@ -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'), ''),
+ },
+ }
+})
diff --git a/packages/stage-ui/src/components/auth/LoginDrawer.vue b/packages/stage-ui/src/components/auth/LoginDrawer.vue
index ec4203f96..41a933c50 100644
--- a/packages/stage-ui/src/components/auth/LoginDrawer.vue
+++ b/packages/stage-ui/src/components/auth/LoginDrawer.vue
@@ -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('open', { required: true })
@@ -53,20 +54,14 @@ async function handleSignIn(provider: OAuthProvider) {
diff --git a/packages/stage-ui/src/components/auth/SignInPanel.vue b/packages/stage-ui/src/components/auth/SignInPanel.vue
new file mode 100644
index 000000000..75716e804
--- /dev/null
+++ b/packages/stage-ui/src/components/auth/SignInPanel.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+ Continue with one of your connected identity providers to complete access.
+
+
+
+
+ Ai
+
+
+
+
+ {{ title }}
+
+
+ {{ subtitle }}
+
+
+
+
+
+
+
+ Sign-in failed
+
+
+ {{ error }}
+
+
+
+
+
+
+
+
+
+ No providers available
+
+
+ The sign-in page is not configured with any providers yet.
+
+
+
+
+
+
+
diff --git a/packages/stage-ui/src/components/auth/index.ts b/packages/stage-ui/src/components/auth/index.ts
index 75d811dfe..f24b55819 100644
--- a/packages/stage-ui/src/components/auth/index.ts
+++ b/packages/stage-ui/src/components/auth/index.ts
@@ -1 +1,3 @@
export { default as LoginDrawer } from './LoginDrawer.vue'
+export * from './providers'
+export { default as SignInPanel } from './SignInPanel.vue'
diff --git a/packages/stage-ui/src/components/auth/providers.ts b/packages/stage-ui/src/components/auth/providers.ts
new file mode 100644
index 000000000..a84bf0b7e
--- /dev/null
+++ b/packages/stage-ui/src/components/auth/providers.ts
@@ -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[]
diff --git a/packages/stage-ui/src/stores/display-models.ts b/packages/stage-ui/src/stores/display-models.ts
index c3930c7bd..209b68bba 100644
--- a/packages/stage-ui/src/stores/display-models.ts
+++ b/packages/stage-ui/src/stores/display-models.ts
@@ -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
([])
+ let generateLive2DPreview: (file: File) => Promise
+ let generateVrmPreview: (file: File) => Promise
+
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,
diff --git a/vitest.config.ts b/vitest.config.ts
index 51bfff517..08bf0c4f5 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -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',