diff --git a/.gitignore b/.gitignore
index 50d69d719..1999b4ac7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,6 +73,7 @@ components.d.ts
**/.netlify/*
**/.netlify/functions-serve/*
+**/server/public/*
**/public/assets/js/*
**/assets/live2d/models/*
**/assets/vrm/models/*
diff --git a/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json b/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json
index 8c338cc7e..dabd31281 100644
--- a/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json
+++ b/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json
@@ -668,7 +668,7 @@
"kind": "DataQuery",
"spec": {
"expr": "sum(rate(user_login_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))",
- "legendFormat": "logins/s"
+ "legendFormat": "sign-ins/s"
},
"version": "v0"
},
@@ -700,7 +700,7 @@
"description": "",
"id": 32,
"links": [],
- "title": "User Logins & Registrations",
+ "title": "User Sign-ins & Registrations",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
diff --git a/apps/server/src/libs/otel.ts b/apps/server/src/libs/otel.ts
index 675f9c21b..f845f28d1 100644
--- a/apps/server/src/libs/otel.ts
+++ b/apps/server/src/libs/otel.ts
@@ -207,7 +207,7 @@ export function initOtel(env: Env): OtelInstance | undefined {
description: 'Number of new user registrations',
}),
userLogin: meter.createCounter(METRIC_USER_LOGIN, {
- description: 'Number of user logins',
+ description: 'Number of user sign-ins',
}),
activeSessions: meter.createUpDownCounter(METRIC_USER_ACTIVE_SESSIONS, {
description: 'Number of active user sessions',
diff --git a/apps/server/src/routes/oidc/electron-callback.ts b/apps/server/src/routes/oidc/electron-callback.ts
index 8d95291cf..63b16f5c1 100644
--- a/apps/server/src/routes/oidc/electron-callback.ts
+++ b/apps/server/src/routes/oidc/electron-callback.ts
@@ -2,6 +2,8 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
+import { renderServerAuthUiHtml } from '../../utils/server-auth-ui'
+
/**
* Render an HTML relay page that forwards the OIDC authorization code
* to the Electron app's loopback server.
@@ -22,173 +24,15 @@ export function createElectronCallbackRelay() {
const error = c.req.query('error') ?? ''
const errorDescription = c.req.query('error_description') ?? ''
- return c.html(renderRelayPage({ code, state, error, errorDescription }))
+ return c.html(renderServerAuthUiHtml({
+ apiServerUrl: new URL(c.req.url).origin,
+ currentUrl: c.req.url,
+ oidcCallback: {
+ code,
+ error,
+ errorDescription,
+ state,
+ },
+ }))
})
}
-
-// Regex patterns for escaping values in HTML/JS context
-const RE_BACKSLASH = /\\/g
-const RE_SINGLE_QUOTE = /'/g
-const RE_LT = / s.replace(RE_BACKSLASH, '\\\\').replace(RE_SINGLE_QUOTE, '\\\'').replace(RE_LT, '\\x3c')
-
- return `
-
-
-
-
- Signing in — AIRI
-
-
-
-
-
-
-`
-}
diff --git a/apps/server/src/utils/server-auth-ui.ts b/apps/server/src/utils/server-auth-ui.ts
new file mode 100644
index 000000000..d506f0814
--- /dev/null
+++ b/apps/server/src/utils/server-auth-ui.ts
@@ -0,0 +1,55 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+export const SERVER_AUTH_UI_BASE_PATH = '/_ui/server-auth'
+
+const SERVER_AUTH_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-server-auth', import.meta.url))
+const SERVER_AUTH_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-server-auth/index.html', import.meta.url))
+const RE_HTML_LT = //g
+const RE_HTML_AMP = /&/g
+const RE_UNICODE_LINE_SEPARATOR = /\u2028/g
+const RE_UNICODE_PARAGRAPH_SEPARATOR = /\u2029/g
+
+let cachedIndexHtml: string | null = null
+
+export interface ServerAuthUiContext {
+ apiServerUrl: string
+ currentUrl: string
+ oidcCallback?: {
+ code: string
+ error: string
+ errorDescription: string
+ state: string
+ }
+}
+
+export function getServerAuthUiDistDir(): string {
+ return SERVER_AUTH_UI_DIST_DIR
+}
+
+export function renderServerAuthUiHtml(context: ServerAuthUiContext): string {
+ const indexHtml = getServerAuthUiIndexHtml()
+
+ if (!indexHtml.includes('__AIRI_SERVER_AUTH_CONTEXT__'))
+ throw new Error('ui-server-auth index.html is missing __AIRI_SERVER_AUTH_CONTEXT__ placeholder')
+
+ return indexHtml.replace('__AIRI_SERVER_AUTH_CONTEXT__', serializeInlineJson(context))
+}
+
+function getServerAuthUiIndexHtml(): string {
+ if (cachedIndexHtml !== null)
+ return cachedIndexHtml
+
+ cachedIndexHtml = readFileSync(SERVER_AUTH_UI_INDEX_HTML_PATH, 'utf8')
+ return cachedIndexHtml
+}
+
+function serializeInlineJson(value: unknown): string {
+ return JSON.stringify(value)
+ .replace(RE_HTML_LT, '\\u003c')
+ .replace(RE_HTML_GT, '\\u003e')
+ .replace(RE_HTML_AMP, '\\u0026')
+ .replace(RE_UNICODE_LINE_SEPARATOR, '\\u2028')
+ .replace(RE_UNICODE_PARAGRAPH_SEPARATOR, '\\u2029')
+}
diff --git a/apps/server/src/utils/sign-in-page.ts b/apps/server/src/utils/sign-in-page.ts
deleted file mode 100644
index 01fab8e81..000000000
--- a/apps/server/src/utils/sign-in-page.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-// Regex patterns for escaping values in HTML/JS context
-const RE_BACKSLASH = /\\/g
-const RE_SINGLE_QUOTE = /'/g
-const RE_LT = /` tags.
- */
-export function renderSignInPage(baseUrl: string, callbackURL: string = '/'): string {
- const signInEndpoint = `${baseUrl}/api/auth/sign-in/social`
- // Escape callbackURL for safe embedding in a JS string literal inside HTML
- const escapedCallbackURL = callbackURL
- .replace(RE_BACKSLASH, '\\\\')
- .replace(RE_SINGLE_QUOTE, '\\\'')
- .replace(RE_LT, '\\x3c')
-
- return `
-
-
-
-
- Sign in — AIRI
-
-
-
-
-
Ai
-
Sign in to AIRI
-
Choose a provider to continue
-
-
-
-
-
-
-`
-}
diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts
index ac2bf9a21..5e84606ac 100644
--- a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts
+++ b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts
@@ -37,7 +37,7 @@ export function initializeElectronAuthCallbackBridge() {
await fetchSession()
}
catch (error) {
- toast.error(errorMessageFrom(error) ?? 'Login failed')
+ toast.error(errorMessageFrom(error) ?? 'Sign-in failed')
}
})
diff --git a/apps/stage-web/src/pages/auth/callback.vue b/apps/stage-web/src/pages/auth/callback.vue
index 157425b1e..78fcf1ec7 100644
--- a/apps/stage-web/src/pages/auth/callback.vue
+++ b/apps/stage-web/src/pages/auth/callback.vue
@@ -1,11 +1,13 @@
-
-
-
- Sign in
+
+
+
+ {{ t('server.auth.webCallback.title.signIn') }}
-
-
- {{ error }}
+
+
+
+
+
+
+ {{ t('server.auth.webCallback.title.errorLabel') }}
+
+
+ {{ error }}
+
+
-
+
+
+
- Signing in...
+ {{ t('server.auth.webCallback.title.loading') }}
+
+ {{ t('server.auth.webCallback.message.finalizing') }}
+
-
+
diff --git a/apps/stage-web/src/pages/auth/sign-in.vue b/apps/stage-web/src/pages/auth/sign-in.vue
index 706ef47bf..79cf1723b 100644
--- a/apps/stage-web/src/pages/auth/sign-in.vue
+++ b/apps/stage-web/src/pages/auth/sign-in.vue
@@ -7,10 +7,12 @@ 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 { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
const router = useRouter()
+const { t } = useI18n()
const { isDesktop } = useBreakpoints()
@@ -29,7 +31,7 @@ async function handleSignIn(provider: OAuthProvider) {
})
}
catch (error) {
- toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
+ toast.error(error instanceof Error ? error.message : t('server.auth.signIn.error.unknown'))
}
finally {
loading.value[provider] = false
@@ -41,7 +43,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)
+ toast.error(error === 'auth_failed' ? t('server.auth.signIn.error.authFailed') : error)
url.searchParams.delete('error')
window.history.replaceState(null, '', url.pathname)
}
@@ -63,9 +65,9 @@ watch(isDesktop, (val) => {
-
+
diff --git a/apps/ui-server-auth/index.html b/apps/ui-server-auth/index.html
index ef6ffd4da..ec2cbc9b5 100644
--- a/apps/ui-server-auth/index.html
+++ b/apps/ui-server-auth/index.html
@@ -51,6 +51,7 @@
+
diff --git a/apps/ui-server-auth/src/main.ts b/apps/ui-server-auth/src/main.ts
index 80c73503d..68999ecb4 100644
--- a/apps/ui-server-auth/src/main.ts
+++ b/apps/ui-server-auth/src/main.ts
@@ -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)
diff --git a/apps/ui-server-auth/src/modules/server-auth-context.ts b/apps/ui-server-auth/src/modules/server-auth-context.ts
new file mode 100644
index 000000000..3afd52ec2
--- /dev/null
+++ b/apps/ui-server-auth/src/modules/server-auth-context.ts
@@ -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
+ cachedContext = {
+ apiServerUrl: parsed.apiServerUrl ?? SERVER_URL,
+ currentUrl: parsed.currentUrl ?? window.location.href,
+ oidcCallback: parsed.oidcCallback,
+ }
+ return cachedContext
+ }
+ catch {
+ cachedContext = null
+ return cachedContext
+ }
+}
diff --git a/apps/ui-server-auth/src/pages/electron-callback.vue b/apps/ui-server-auth/src/pages/electron-callback.vue
index 7924f8bca..50b639901 100644
--- a/apps/ui-server-auth/src/pages/electron-callback.vue
+++ b/apps/ui-server-auth/src/pages/electron-callback.vue
@@ -1,7 +1,10 @@
-
-
+
+
+ />
+
{{ viewModel.title }}
-
-
+
{{ viewModel.description }}
+
-
- {{ viewModel.detail }}
-
+
+
+ {{ t('server.auth.electronCallback.label.signIn') }}
+
-
+
+
+
+
+
+
+
+ {{ viewModel.title }}
+
+
+ {{ viewModel.description }}
+
+
+ {{ viewModel.detail }}
+
+
+
+
+
+
+
+ {{ t('server.auth.electronCallback.hint.closeTabManually') }}
+
+
+
+
+
-
-
+
{
+alias:
+ - /electron-callback
meta:
layout: plain
+path: /api/auth/oidc/electron-callback
diff --git a/apps/ui-server-auth/src/pages/sign-in.vue b/apps/ui-server-auth/src/pages/sign-in.vue
index dd4970f70..028a264dc 100644
--- a/apps/ui-server-auth/src/pages/sign-in.vue
+++ b/apps/ui-server-auth/src/pages/sign-in.vue
@@ -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(null)
const pendingProvider = shallowRef(null)
@@ -17,7 +23,7 @@ const autoStartedProvider = shallowRef(null)
const providerLookup = new Set(defaultSignInProviders.map(provider => provider.id))
-const signInContext = computed(() => createServerSignInContext(window.location.href, SERVER_URL))
+const signInContext = computed(() => createServerSignInContext(currentUrl, apiServerUrl))
const requestedProvider = computed(() => {
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) {
- Sign in
+ {{ t('server.auth.signIn.title') }}
{{ errorMessage }}
+
+
diff --git a/apps/ui-server-auth/vite.config.ts b/apps/ui-server-auth/vite.config.ts
index 6f5e00cbe..e5f6f11f2 100644
--- a/apps/ui-server-auth/vite.config.ts
+++ b/apps/ui-server-auth/vite.config.ts
@@ -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: {