refactor(ui-server-auth,server): use better design of signin page
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
</head>
|
||||
<body class="font-sans">
|
||||
<div id="app"></div>
|
||||
<script id="airi-server-auth-context" type="application/json">__AIRI_SERVER_AUTH_CONTEXT__</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
|
||||
</body>
|
||||
|
||||
@@ -30,9 +30,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
|
||||
|
||||
let router: Router
|
||||
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
|
||||
router = createRouter({ routes: routeRecords, history: createWebHashHistory() })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHashHistory('/_ui/server-auth/') })
|
||||
else
|
||||
router = createRouter({ routes: routeRecords, history: createWebHistory() })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHistory('/_ui/server-auth/') })
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.path !== from.path)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
|
||||
export interface ServerAuthBootstrapContext {
|
||||
apiServerUrl: string
|
||||
currentUrl: string
|
||||
oidcCallback?: {
|
||||
code: string
|
||||
error: string
|
||||
errorDescription: string
|
||||
state: string
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_ID = 'airi-server-auth-context'
|
||||
|
||||
let cachedContext: ServerAuthBootstrapContext | null | undefined
|
||||
|
||||
export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | null {
|
||||
if (cachedContext !== undefined)
|
||||
return cachedContext
|
||||
|
||||
const element = document.getElementById(SCRIPT_ID)
|
||||
if (!element) {
|
||||
cachedContext = null
|
||||
return cachedContext
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(element.textContent ?? '') as Partial<ServerAuthBootstrapContext>
|
||||
cachedContext = {
|
||||
apiServerUrl: parsed.apiServerUrl ?? SERVER_URL,
|
||||
currentUrl: parsed.currentUrl ?? window.location.href,
|
||||
oidcCallback: parsed.oidcCallback,
|
||||
}
|
||||
return cachedContext
|
||||
}
|
||||
catch {
|
||||
cachedContext = null
|
||||
return cachedContext
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Callout } from '@proj-airi/ui'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { parseElectronCallbackQuery } from '../composables/electron-callback.shared'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
type CallbackStatus = 'loading' | 'success' | 'fallback' | 'error'
|
||||
|
||||
@@ -11,16 +14,18 @@ interface CallbackViewModel {
|
||||
description: string
|
||||
detail?: string
|
||||
primaryActionLabel?: string
|
||||
secondaryActionLabel?: string
|
||||
primaryActionDisabled?: boolean
|
||||
relayUrl?: string
|
||||
showCloseTabHint?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const viewModel = shallowRef<CallbackViewModel>({
|
||||
description: 'Checking your sign-in response and preparing the handoff to AIRI.',
|
||||
description: t('server.auth.electronCallback.message.checkingResponse'),
|
||||
primaryActionDisabled: true,
|
||||
status: 'loading',
|
||||
title: 'Completing sign-in',
|
||||
title: t('server.auth.electronCallback.title.loading'),
|
||||
})
|
||||
|
||||
function setViewModel(next: CallbackViewModel) {
|
||||
@@ -34,43 +39,48 @@ function openRelayUrl() {
|
||||
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))
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const callbackContext = bootstrapContext?.oidcCallback
|
||||
|
||||
const query = callbackContext
|
||||
? new URLSearchParams({
|
||||
code: callbackContext.code,
|
||||
error: callbackContext.error,
|
||||
error_description: callbackContext.errorDescription,
|
||||
state: callbackContext.state,
|
||||
})
|
||||
: new URLSearchParams(window.location.search)
|
||||
|
||||
const parsed = parseElectronCallbackQuery(query)
|
||||
|
||||
if (parsed.status === 'error') {
|
||||
setViewModel({
|
||||
description: 'We could not use this sign-in response.',
|
||||
description: t('server.auth.electronCallback.message.invalidResponse'),
|
||||
detail: parsed.message,
|
||||
status: 'error',
|
||||
title: 'Sign-in failed',
|
||||
title: t('server.auth.electronCallback.title.signInFailed'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setViewModel({
|
||||
description: 'Passing your sign-in back to AIRI now. This page should close in a moment.',
|
||||
description: t('server.auth.electronCallback.message.passingToAiri'),
|
||||
primaryActionDisabled: true,
|
||||
relayUrl: parsed.relayUrl,
|
||||
status: 'loading',
|
||||
title: 'Opening AIRI',
|
||||
title: t('server.auth.electronCallback.title.openingAiri'),
|
||||
})
|
||||
|
||||
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.',
|
||||
description: t('server.auth.electronCallback.message.syncedAndSafeToClose'),
|
||||
relayUrl: parsed.relayUrl,
|
||||
showCloseTabHint: false,
|
||||
status: 'success',
|
||||
title: 'You are signed in',
|
||||
title: t('server.auth.electronCallback.title.signedIn'),
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
@@ -79,24 +89,23 @@ async function runRelayFlow() {
|
||||
|
||||
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.',
|
||||
description: t('server.auth.electronCallback.message.syncedAndSafeToClose'),
|
||||
relayUrl: parsed.relayUrl,
|
||||
secondaryActionLabel: 'Copy callback link',
|
||||
showCloseTabHint: true,
|
||||
status: 'success',
|
||||
title: 'You are signed in',
|
||||
title: t('server.auth.electronCallback.title.signedIn'),
|
||||
})
|
||||
}, 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.',
|
||||
description: t('server.auth.electronCallback.message.loopbackUnreachable'),
|
||||
detail: t('server.auth.electronCallback.message.tryOpenDirectly'),
|
||||
primaryActionDisabled: false,
|
||||
primaryActionLabel: 'Open AIRI manually',
|
||||
primaryActionLabel: t('server.auth.electronCallback.action.openAiriManually'),
|
||||
relayUrl: parsed.relayUrl,
|
||||
status: 'fallback',
|
||||
title: 'Finish sign-in in AIRI',
|
||||
title: t('server.auth.electronCallback.title.finishSignInInAiri'),
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
@@ -105,14 +114,13 @@ async function runRelayFlow() {
|
||||
|
||||
window.setTimeout(() => {
|
||||
setViewModel({
|
||||
description: 'Automatic handoff did not finish in this browser session.',
|
||||
description: t('server.auth.electronCallback.message.automaticHandoffFailed'),
|
||||
detail: parsed.relayUrl,
|
||||
primaryActionDisabled: false,
|
||||
primaryActionLabel: 'Open AIRI manually',
|
||||
primaryActionLabel: t('server.auth.electronCallback.action.openAiriManually'),
|
||||
relayUrl: parsed.relayUrl,
|
||||
secondaryActionLabel: 'Copy callback link',
|
||||
status: 'fallback',
|
||||
title: 'Open AIRI to continue',
|
||||
title: t('server.auth.electronCallback.title.openAiriToContinue'),
|
||||
})
|
||||
}, 960)
|
||||
}
|
||||
@@ -124,72 +132,123 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex items-center justify-center px-6 py-10',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'max-w-xl w-full rounded-xl border border-neutral-200 bg-white p-6',
|
||||
'dark:border-neutral-800 dark:bg-neutral-900',
|
||||
]"
|
||||
>
|
||||
<main :class="['min-h-screen', 'flex flex-col items-center justify-center', 'px-6 py-10', 'font-cuteen']">
|
||||
<div v-if="viewModel.status === 'loading'" :class="['text-center']">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'text-2xl font-bold',
|
||||
'mx-auto mb-3',
|
||||
'h-15 w-15',
|
||||
'i-svg-spinners:ring-resize',
|
||||
'text-primary-500',
|
||||
]"
|
||||
>
|
||||
/>
|
||||
<div :class="['text-lg']">
|
||||
{{ viewModel.title }}
|
||||
</div>
|
||||
|
||||
<p
|
||||
:class="[
|
||||
'mt-3 text-sm text-neutral-600 dark:text-neutral-300',
|
||||
]"
|
||||
>
|
||||
<p :class="['mt-2 text-sm text-neutral-600 dark:text-neutral-300']">
|
||||
{{ viewModel.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="viewModel.detail"
|
||||
:class="[
|
||||
'mt-3 break-all text-xs text-neutral-500 dark:text-neutral-400',
|
||||
]"
|
||||
>
|
||||
{{ viewModel.detail }}
|
||||
</p>
|
||||
<div v-else :class="['sm:max-w-md md:max-w-lg', 'flex w-full flex-col items-center']">
|
||||
<div :class="['mb-8 text-3xl font-bold']">
|
||||
{{ t('server.auth.electronCallback.label.signIn') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewModel.status === 'success'"
|
||||
:class="[
|
||||
'mt-6 flex flex-wrap items-center gap-2',
|
||||
'w-full rounded-xl border-2 border-green-200/45 bg-green-50/70 p-4',
|
||||
'relative', 'overflow-hidden',
|
||||
'dark:border-green-800/30 dark:bg-green-950/30',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
<div :class="['flex items-start gap-3']">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'absolute',
|
||||
'size-30 flex-shrink-0',
|
||||
'right-0 top-0 translate-x-[calc(25%)] translate-y-[-25%]',
|
||||
'i-solar:check-circle-line-duotone text-green-500 dark:text-green-400 op-25',
|
||||
]"
|
||||
/>
|
||||
<div :class="['min-w-0']">
|
||||
<div :class="['text-lg font-semibold text-green-800 dark:text-green-200', 'mb-4']">
|
||||
{{ viewModel.title }}
|
||||
</div>
|
||||
<div :class="['mt-1 text-sm text-green-700 dark:text-green-300']">
|
||||
{{ viewModel.description }}
|
||||
</div>
|
||||
<div :class="['mt-2 text-xs text-green-700/90 dark:text-green-300/90']">
|
||||
{{ t('server.auth.electronCallback.label.safeToClose') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="viewModel.detail"
|
||||
:class="['mt-2 break-all text-xs text-green-700/85 dark:text-green-300/85']"
|
||||
>
|
||||
{{ viewModel.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'w-full rounded-xl border-2 border-orange-200/45 bg-orange-50/70 p-4',
|
||||
'relative', 'overflow-hidden',
|
||||
'dark:border-orange-800/30 dark:bg-orange-950/30',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex items-start gap-3']">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'absolute',
|
||||
'size-30 flex-shrink-0',
|
||||
'right-0 top-0 translate-x-[calc(25%)] translate-y-[-25%]',
|
||||
'i-solar:danger-circle-line-duotone text-orange-500 dark:text-orange-400 op-25',
|
||||
]"
|
||||
/>
|
||||
<div :class="['min-w-0']">
|
||||
<div :class="['text-lg font-semibold text-orange-800 dark:text-orange-200', 'mb-4']">
|
||||
{{ viewModel.title }}
|
||||
</div>
|
||||
<div :class="['mt-1 text-sm text-orange-700 dark:text-orange-300']">
|
||||
{{ viewModel.description }}
|
||||
</div>
|
||||
<div
|
||||
v-if="viewModel.detail"
|
||||
:class="['mt-2 break-all text-xs text-orange-700/90 dark:text-orange-300/90']"
|
||||
>
|
||||
{{ viewModel.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
v-if="viewModel.status === 'success' && viewModel.showCloseTabHint"
|
||||
theme="primary"
|
||||
:class="['mt-3 w-full']"
|
||||
:label="t('server.auth.electronCallback.label.whyStillHere')"
|
||||
>
|
||||
<div :class="['mt-1 text-sm']">
|
||||
{{ t('server.auth.electronCallback.hint.closeTabManually') }}
|
||||
</div>
|
||||
</Callout>
|
||||
|
||||
<div :class="['mt-3 flex flex-wrap items-center justify-center gap-2']">
|
||||
<Button
|
||||
v-if="viewModel.primaryActionLabel"
|
||||
type="button"
|
||||
:disabled="viewModel.primaryActionDisabled"
|
||||
:class="[
|
||||
'rounded-md border border-neutral-300 px-3 py-2 text-sm',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'dark:border-neutral-700',
|
||||
]"
|
||||
:class="['inline-flex']"
|
||||
@click="openRelayUrl"
|
||||
>
|
||||
{{ viewModel.primaryActionLabel }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="viewModel.secondaryActionLabel"
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-md border border-neutral-300 px-3 py-2 text-sm',
|
||||
'dark:border-neutral-700',
|
||||
]"
|
||||
@click="copyRelayUrl"
|
||||
>
|
||||
{{ viewModel.secondaryActionLabel }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
@@ -207,6 +266,9 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
alias:
|
||||
- /electron-callback
|
||||
meta:
|
||||
layout: plain
|
||||
path: /api/auth/oidc/electron-callback
|
||||
</route>
|
||||
|
||||
@@ -5,11 +5,17 @@ import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
import { createServerSignInContext, requestSocialSignInRedirect } from '../modules/sign-in'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
const currentUrl = bootstrapContext?.currentUrl ?? window.location.href
|
||||
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const pendingProvider = shallowRef<OAuthProvider | null>(null)
|
||||
@@ -17,7 +23,7 @@ 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 signInContext = computed(() => createServerSignInContext(currentUrl, apiServerUrl))
|
||||
|
||||
const requestedProvider = computed<OAuthProvider | null>(() => {
|
||||
const provider = signInContext.value.requestedProvider
|
||||
@@ -46,7 +52,7 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
|
||||
try {
|
||||
const redirectUrl = await requestSocialSignInRedirect({
|
||||
apiServerUrl: SERVER_URL,
|
||||
apiServerUrl,
|
||||
provider,
|
||||
callbackURL: signInContext.value.callbackURL,
|
||||
})
|
||||
@@ -54,7 +60,7 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Sign in failed'
|
||||
errorMessage.value = error instanceof Error ? error.message : t('server.auth.signIn.error.fallback')
|
||||
pendingProvider.value = null
|
||||
}
|
||||
}
|
||||
@@ -63,7 +69,7 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10',
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
@@ -71,7 +77,7 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
'mb-8 text-3xl font-bold',
|
||||
]"
|
||||
>
|
||||
Sign in
|
||||
{{ t('server.auth.signIn.title') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -99,6 +105,31 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'mt-8 text-center text-xs text-gray-400',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.footer.prefix') }}
|
||||
<a
|
||||
href="https://airi.moeru.ai/docs/en/about/terms"
|
||||
:class="[
|
||||
'underline',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.footer.terms') }}
|
||||
</a>
|
||||
{{ t('server.auth.signIn.footer.and') }}
|
||||
<a
|
||||
href="https://airi.moeru.ai/docs/en/about/privacy"
|
||||
:class="[
|
||||
'underline',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.footer.privacy') }}
|
||||
</a>.
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import VueMacros from 'vue-macros/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/_ui/server-auth/',
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
// Internal Packages
|
||||
@@ -43,6 +44,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
outDir: resolve(join(import.meta.dirname, '..', 'server', 'public', 'ui-server-auth')),
|
||||
sourcemap: true,
|
||||
},
|
||||
worker: {
|
||||
|
||||
Reference in New Issue
Block a user