feat(node-player): redesign auth page with loading state
- 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:
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user