feat(node-player): redesign auth page with loading state
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m31s
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 43s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 2m22s

- Show spinner while checking session (no login form flash on refresh)
- Translate UI to English
- Match player's dark theme (colors, fonts, card style)
- Render login form only when authentication is actually needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ultradesu
2026-04-08 16:27:22 +01:00
parent 1ea5f66ea3
commit ed918b9373
2 changed files with 158 additions and 135 deletions
+106 -54
View File
@@ -1,71 +1,123 @@
.page { @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
.auth-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 24px; padding: 24px;
background: #0a0c12;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
} }
.card { /* ---------- loading spinner ---------- */
width: min(520px, 100%);
border: 1px solid #d8dde6; .auth-loading {
border-radius: 14px; display: flex;
padding: 24px; flex-direction: column;
background-color: #ffffff; align-items: center;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08); gap: 20px;
} }
.subtitle { .spinner {
margin-top: 0; width: 36px;
margin-bottom: 20px; height: 36px;
color: #5a6475; border: 3px solid #1f2c45;
border-top-color: #7c6af7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
.settings { @keyframes spin {
margin-bottom: 16px; to { transform: rotate(360deg); }
padding: 12px;
border: 1px solid #e6eaf2;
border-radius: 10px;
background: #f8fafc;
} }
.toggle { .auth-loading .logo {
font-size: 1.6rem;
font-weight: 700;
color: #7c6af7;
}
.auth-loading p {
color: #64748b;
font-size: 0.85rem;
}
/* ---------- login card ---------- */
.auth-card {
width: min(380px, 100%);
background: #111520;
border: 1px solid #1f2c45;
border-radius: 16px;
padding: 2.5rem 2rem;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-card .logo {
font-size: 1.8rem;
font-weight: 700;
color: #7c6af7;
margin-bottom: 4px;
}
.auth-card .subtitle {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 2rem;
}
.auth-card .btn-login {
display: block;
width: 100%;
padding: 0.75rem;
text-align: center;
background: #7c6af7;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background 0.2s;
}
.auth-card .btn-login:hover {
background: #6b58e8;
}
.auth-card .error {
color: #f87171;
font-size: 0.85rem;
margin-bottom: 1rem;
}
.auth-card .settings {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #1f2c45;
}
.auth-card .toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
color: #0f172a; gap: 8px;
font-weight: 600; color: #64748b;
font-size: 0.8rem;
cursor: pointer;
} }
.toggle input { .auth-card .toggle input {
width: 18px; width: 14px;
height: 18px; height: 14px;
} accent-color: #7c6af7;
.hint {
margin: 10px 0 0;
color: #5a6475;
}
.btn {
display: inline-block;
text-decoration: none;
background: #2251ff;
color: #ffffff;
padding: 10px 16px;
border-radius: 8px;
font-weight: 600;
}
.btn.ghost {
background: #edf1ff;
color: #1e3fc4;
margin-top: 10px;
}
.profile p {
margin: 8px 0;
}
.error {
color: #cc1e1e;
} }
+52 -81
View File
@@ -23,8 +23,6 @@ function App() {
} }
}) })
const apiBase = ''
useEffect(() => { useEffect(() => {
if (runWithoutAuth) { if (runWithoutAuth) {
setError(null) setError(null)
@@ -35,9 +33,7 @@ function App() {
const loadMe = async () => { const loadMe = async () => {
try { try {
const response = await fetch(`${apiBase}/auth/me`, { const response = await fetch('/auth/me', { credentials: 'include' })
credentials: 'include',
})
if (response.status === 401) { if (response.status === 401) {
setUser(null) setUser(null)
@@ -52,12 +48,9 @@ function App() {
const data = await response.json() const data = await response.json()
setUser(data.user ?? null) setUser(data.user ?? null)
// Fetch OIDC access token for Rust API Bearer auth
if (data.user) { if (data.user) {
try { try {
const tokenRes = await fetch(`${apiBase}/auth/token`, { const tokenRes = await fetch('/auth/token', { credentials: 'include' })
credentials: 'include',
})
if (tokenRes.ok) { if (tokenRes.ok) {
const tokenData = await tokenRes.json() const tokenData = await tokenRes.json()
if (tokenData.access_token) { if (tokenData.access_token) {
@@ -65,7 +58,7 @@ function App() {
} }
} }
} catch { } catch {
// Token fetch failed — API calls will fall back to other auth methods // Token fetch failed
} }
} }
} catch (err) { } catch (err) {
@@ -78,82 +71,60 @@ function App() {
void loadMe() void loadMe()
}, [runWithoutAuth]) }, [runWithoutAuth])
const loginUrl = `${apiBase}/auth/login` // Authenticated — render player immediately
const logoutUrl = `${apiBase}/auth/logout` if (!loading && (user || runWithoutAuth)) {
return <FurumiPlayer />
}
// Loading — show spinner (no login form flash)
if (loading) {
return (
<main className="auth-page">
<div className="auth-loading">
<div className="logo">Furumi</div>
<div className="spinner" />
<p>Loading...</p>
</div>
</main>
)
}
// Not authenticated — show login
return ( return (
<> <main className="auth-page">
{!loading && (user || runWithoutAuth) ? ( <section className="auth-card">
<FurumiPlayer /> <div className="logo">Furumi</div>
) : ( <p className="subtitle">Sign in to continue</p>
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<div className="settings"> {error && <p className="error">{error}</p>}
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
</div>
{loading && <p>Проверяю сессию...</p>} <a className="btn-login" href="/auth/login">
{error && <p className="error">Ошибка: {error}</p>} Sign in with SSO
</a>
{!loading && runWithoutAuth && ( <div className="settings">
<p className="hint"> <label className="toggle">
Режим без авторизации включён. Для входа отключи настройку выше. <input
</p> type="checkbox"
)} checked={runWithoutAuth}
onChange={(e) => {
{!loading && !user && ( const next = e.target.checked
<a className="btn" href={loginUrl}> setRunWithoutAuth(next)
Войти через OIDC try {
</a> if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
)} else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
{!loading && user && ( // ignore
<div className="profile"> }
<p> setLoading(true)
<strong>ID:</strong> {user.sub} setUser(null)
</p> }}
{user.name && ( />
<p> <span>Run without authentication</span>
<strong>Имя:</strong> {user.name} </label>
</p> </div>
)} </section>
{user.email && ( </main>
<p>
<strong>Email:</strong> {user.email}
</p>
)}
{!runWithoutAuth && (
<a className="btn ghost" href={logoutUrl}>
Выйти
</a>
)}
</div>
)}
</section>
</main>
)}
</>
) )
} }