wip(ui-server-auth): basic ui, not yet finalized

This commit is contained in:
Neko Ayaka
2026-04-03 15:43:28 +08:00
parent 881877e1e0
commit 248e28412a
21 changed files with 909 additions and 65 deletions
+1
View File
@@ -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'
+1
View File
@@ -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) {
+34 -37
View File
@@ -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">
+2
View File
@@ -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/*"
+2
View File
@@ -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>
+87 -2
View File
@@ -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>
+14
View File
@@ -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[]
+16 -9
View File
@@ -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,
+1
View File
@@ -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',