mirror of
https://github.com/moeru-ai/airi.git
synced 2026-04-28 06:29:33 +00:00
refactor(ui-server-auth,server): use better design of signin page
This commit is contained in:
parent
f769580099
commit
abc0cb0bc7
15 changed files with 351 additions and 451 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -73,6 +73,7 @@ components.d.ts
|
|||
**/.netlify/*
|
||||
**/.netlify/functions-serve/*
|
||||
|
||||
**/server/public/*
|
||||
**/public/assets/js/*
|
||||
**/assets/live2d/models/*
|
||||
**/assets/vrm/models/*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = /</g
|
||||
|
||||
function renderRelayPage(params: {
|
||||
code: string
|
||||
state: string
|
||||
error: string
|
||||
errorDescription: string
|
||||
}): string {
|
||||
// Escape values for safe embedding in HTML/JS
|
||||
const esc = (s: string) => s.replace(RE_BACKSLASH, '\\\\').replace(RE_SINGLE_QUOTE, '\\\'').replace(RE_LT, '\\x3c')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Signing in — AIRI</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 24px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03), 0 20px 25px -5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 24px;
|
||||
background: #111827;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
h1 { font-size: 24px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.025em; }
|
||||
.status { font-size: 15px; color: #6b7280; line-height: 1.5; }
|
||||
.status.error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 8px; border: 1px solid #fecaca; margin-top: 16px; }
|
||||
.status.success { color: #059669; background: #ecfdf5; padding: 12px; border-radius: 8px; border: 1px solid #a7f3d0; margin-top: 16px; }
|
||||
.link {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
color: #4f46e5;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
word-break: break-all;
|
||||
}
|
||||
.link:hover { color: #4338ca; text-decoration: underline; }
|
||||
.link[hidden] { display: none; }
|
||||
.spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid #f3f4f6;
|
||||
border-top-color: #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 24px auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #030712; color: #f9fafb; }
|
||||
.card { background: #111827; border-color: #1f2937; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); }
|
||||
.logo { background: #ffffff; color: #111827; }
|
||||
.status { color: #9ca3af; }
|
||||
.status.error { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
|
||||
.status.success { background: #064e3b; border-color: #065f46; color: #6ee7b7; }
|
||||
.link { color: #818cf8; }
|
||||
.link:hover { color: #a5b4fc; }
|
||||
.spinner { border-color: #374151; border-top-color: #818cf8; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Ai</div>
|
||||
<h1 id="title">Signing in…</h1>
|
||||
<div class="spinner" id="spinner"></div>
|
||||
<p class="status" id="status">Completing authentication</p>
|
||||
<a id="manual-link" class="link" hidden rel="noreferrer">If AIRI does not open automatically, click here</a>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var code = '${esc(params.code)}';
|
||||
var fullState = '${esc(params.state)}';
|
||||
var error = '${esc(params.error)}';
|
||||
var errorDesc = '${esc(params.errorDescription)}';
|
||||
|
||||
var titleEl = document.getElementById('title');
|
||||
var statusEl = document.getElementById('status');
|
||||
var spinnerEl = document.getElementById('spinner');
|
||||
var manualLinkEl = document.getElementById('manual-link');
|
||||
|
||||
function done(ok, msg) {
|
||||
spinnerEl.style.display = 'none';
|
||||
titleEl.textContent = ok ? 'Signed in!' : 'Sign-in failed';
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status ' + (ok ? 'success' : 'error');
|
||||
}
|
||||
|
||||
function revealManualLink(url, text) {
|
||||
manualLinkEl.href = url;
|
||||
manualLinkEl.textContent = text;
|
||||
manualLinkEl.hidden = false;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
done(false, errorDesc || error);
|
||||
return;
|
||||
}
|
||||
|
||||
// State format: "{port}:{originalState}"
|
||||
var sep = fullState.indexOf(':');
|
||||
if (sep === -1) {
|
||||
done(false, 'Invalid state parameter');
|
||||
return;
|
||||
}
|
||||
var port = fullState.substring(0, sep);
|
||||
var originalState = fullState.substring(sep + 1);
|
||||
|
||||
// Send the code and state to the Electron loopback server
|
||||
var url = 'http://127.0.0.1:' + port + '/callback?code=' + encodeURIComponent(code) + '&state=' + encodeURIComponent(originalState);
|
||||
revealManualLink(url, 'If AIRI does not open automatically, click here');
|
||||
|
||||
fetch(url)
|
||||
.then(function() {
|
||||
done(true, 'You can close this tab and return to AIRI.');
|
||||
})
|
||||
.catch(function() {
|
||||
done(false, 'Trying to open AIRI directly…');
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.replace(url);
|
||||
}, 150);
|
||||
|
||||
setTimeout(function() {
|
||||
done(false, 'Could not reach AIRI automatically. Use the link below to continue.');
|
||||
}, 1000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
|
|
|||
55
apps/server/src/utils/server-auth-ui.ts
Normal file
55
apps/server/src/utils/server-auth-ui.ts
Normal file
|
|
@ -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_GT = />/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')
|
||||
}
|
||||
|
|
@ -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 = /</g
|
||||
|
||||
/**
|
||||
* Render a minimal sign-in page for the OIDC Provider flow.
|
||||
*
|
||||
* When the oidcProvider plugin redirects an unauthenticated user here,
|
||||
* they choose a social provider. After authentication, the social
|
||||
* callback redirects to callbackURL, which points back to the OIDC
|
||||
* authorize endpoint so the authorization code flow can complete.
|
||||
*
|
||||
* NOTICE: better-auth's `/api/auth/sign-in/social` is a POST endpoint
|
||||
* that expects JSON body `{ provider, callbackURL }` and returns a
|
||||
* redirect URL in JSON. We use fetch + redirect in JS, not `<a>` 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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in — AIRI</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 24px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03), 0 20px 25px -5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 24px;
|
||||
background: #111827;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
h1 { font-size: 24px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.025em; }
|
||||
.subtitle { font-size: 15px; color: #6b7280; margin-bottom: 32px; line-height: 1.5; }
|
||||
.buttons { display: flex; flex-direction: column; gap: 12px; }
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.btn:hover { background: #f9fafb; border-color: #d1d5db; transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); }
|
||||
.btn:active { transform: translateY(0); box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
.btn svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.footer { margin-top: 32px; font-size: 13px; color: #9ca3af; line-height: 1.5; }
|
||||
.footer a { color: #6b7280; text-decoration: none; transition: color 0.2s; }
|
||||
.footer a:hover { color: #374151; text-decoration: underline; }
|
||||
.error { margin-top: 16px; font-size: 14px; color: #ef4444; display: none; padding: 12px; background: #fef2f2; border-radius: 8px; border: 1px solid #fecaca; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #030712; color: #f9fafb; }
|
||||
.card { background: #111827; border-color: #1f2937; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); }
|
||||
.logo { background: #ffffff; color: #111827; }
|
||||
.subtitle { color: #9ca3af; }
|
||||
.btn { background: #1f2937; border-color: #374151; color: #e5e7eb; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); }
|
||||
.btn:hover { background: #374151; border-color: #4b5563; }
|
||||
.footer { color: #6b7280; }
|
||||
.footer a { color: #9ca3af; }
|
||||
.footer a:hover { color: #e5e7eb; }
|
||||
.error { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Ai</div>
|
||||
<h1>Sign in to AIRI</h1>
|
||||
<p class="subtitle">Choose a provider to continue</p>
|
||||
<div class="buttons">
|
||||
<button class="btn" onclick="signIn('google', this)">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
||||
Google
|
||||
</button>
|
||||
<button class="btn" onclick="signIn('github', this)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/></svg>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
<p class="error" id="error"></p>
|
||||
<p class="footer">
|
||||
By continuing, you agree to our
|
||||
<a href="https://airi.moeru.ai/docs/en/about/terms">Terms</a> and
|
||||
<a href="https://airi.moeru.ai/docs/en/about/privacy">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
async function signIn(provider, btn) {
|
||||
var errorEl = document.getElementById('error');
|
||||
errorEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
var res = await fetch('${signInEndpoint}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: provider,
|
||||
callbackURL: '${escapedCallbackURL}'
|
||||
}),
|
||||
credentials: 'include',
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
// better-auth returns { url, redirect } for social sign-in
|
||||
if (res.type === 'opaqueredirect' || res.status === 302) {
|
||||
window.location.href = res.headers.get('location') || '/';
|
||||
return;
|
||||
}
|
||||
|
||||
var data = await res.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else if (data.error) {
|
||||
throw new Error(data.error.message || data.error);
|
||||
} else {
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message || 'Sign in failed';
|
||||
errorEl.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export function initializeElectronAuthCallbackBridge() {
|
|||
await fetchSession()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFrom(error) ?? 'Login failed')
|
||||
toast.error(errorMessageFrom(error) ?? 'Sign-in failed')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { applyOIDCTokens, fetchSession } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
|
||||
import { Button, Callout } from '@proj-airi/ui'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -20,13 +22,13 @@ onMounted(async () => {
|
|||
}
|
||||
|
||||
if (!code || !state) {
|
||||
error.value = 'Missing authorization code or state'
|
||||
error.value = t('server.auth.webCallback.message.missingCodeOrState')
|
||||
return
|
||||
}
|
||||
|
||||
const persisted = consumeFlowState()
|
||||
if (!persisted) {
|
||||
error.value = 'Missing OIDC flow state — please try logging in again'
|
||||
error.value = t('server.auth.webCallback.message.missingFlowState')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ onMounted(async () => {
|
|||
router.replace('/')
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Token exchange failed'
|
||||
error.value = err instanceof Error ? err.message : t('server.auth.webCallback.message.tokenExchangeFailed')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -47,33 +49,61 @@ function handleTryAgain() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['min-h-screen', 'flex flex-col items-center justify-center']">
|
||||
<div v-if="error" :class="['max-w-md', 'flex flex-col items-center']">
|
||||
<div class="mb-8 text-3xl font-bold">
|
||||
Sign in
|
||||
<main :class="['min-h-screen', 'flex flex-col items-center justify-center', 'px-6 py-10', 'font-cuteen']">
|
||||
<div v-if="error" :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.webCallback.title.signIn') }}
|
||||
</div>
|
||||
<Callout theme="orange" label="We encountered an error while signing you in">
|
||||
<div :class="['mt-1', 'text-sm']">
|
||||
{{ error }}
|
||||
|
||||
<div
|
||||
: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']">
|
||||
{{ t('server.auth.webCallback.title.errorLabel') }}
|
||||
</div>
|
||||
<div :class="['mt-1 text-sm text-orange-700 dark:text-orange-300']">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<Button :class="['mt-3 inline-flex']" @click="handleTryAgain">
|
||||
Try again
|
||||
{{ t('server.auth.webCallback.action.tryAgain') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else :class="['text-center']">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'mx-auto mb-3',
|
||||
'h-10 w-10',
|
||||
'h-15 w-15',
|
||||
'i-svg-spinners:ring-resize',
|
||||
'text-primary-500',
|
||||
]"
|
||||
/>
|
||||
<div :class="['text-lg']">
|
||||
Signing in...
|
||||
{{ t('server.auth.webCallback.title.loading') }}
|
||||
</div>
|
||||
<p :class="['mt-2 text-sm text-neutral-600 dark:text-neutral-300']">
|
||||
{{ t('server.auth.webCallback.message.finalizing') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isDesktop" class="min-h-screen flex flex-col items-center justify-center">
|
||||
<div v-if="isDesktop" class="min-h-screen flex flex-col items-center justify-center font-cuteen">
|
||||
<div class="mb-8 text-3xl font-bold">
|
||||
Sign in
|
||||
{{ t('server.auth.signIn.title') }}
|
||||
</div>
|
||||
<div class="max-w-xs w-full flex flex-col gap-3">
|
||||
<Button
|
||||
|
|
@ -86,7 +88,10 @@ watch(isDesktop, (val) => {
|
|||
</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>.
|
||||
{{ 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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
41
apps/ui-server-auth/src/modules/server-auth-context.ts
Normal file
41
apps/ui-server-auth/src/modules/server-auth-context.ts
Normal file
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue