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 - - - -
- -

Signing in…

-
-

Completing authentication

- -
- - -` -} 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 - - - -
- -

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 @@ 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) => {