feat(login): prevent login form flash on OIDC callback

This commit is contained in:
Edouard Vanbelle
2026-08-14 13:26:21 +02:00
parent 0d5a726ef4
commit 1a8306f3db
+69 -5
View File
@@ -117,6 +117,16 @@
// config, or the local account is already linked to a different // config, or the local account is already linked to a different
// identity). See docs/plan/oidc-account-linking.md § Auto-link. // identity). See docs/plan/oidc-account-linking.md § Auto-link.
let loginErrorNotice = $state<string | null>(null); let loginErrorNotice = $state<string | null>(null);
// True while we're mid-OIDC-callback and about to redirect into the
// app. Read synchronously at script-init from `?oidc_code=…` so the
// FIRST paint suppresses the form and shows a loader instead —
// without this the SPA briefly renders the empty username/password
// fields between the IdP redirect and the exchange-then-goto,
// making it look like the login screen "flashed." Cleared in
// onMount if the exchange fails so the normal form takes over.
let willRedirect = $state(
typeof window !== 'undefined' && page.url.searchParams.has('oidc_code')
);
// Refs used by the mode-driven auto-focus effect. Bound with // Refs used by the mode-driven auto-focus effect. Bound with
// `bind:this` on the first input of each mode's form so the effect // `bind:this` on the first input of each mode's form so the effect
// can focus the "primary" field each time the mode changes without // can focus the "primary" field each time the mode changes without
@@ -368,7 +378,10 @@
await goto(resolve(redirectTarget), { replaceState: true }); await goto(resolve(redirectTarget), { replaceState: true });
return; return;
} }
// Exchange failed — fall through to the normal login UI. // Exchange failed — fall through to the normal login UI. Drop
// the loader guard so the form appears; if we leave it true
// the user stares at a spinner indefinitely.
willRedirect = false;
} }
// 2) Existing-session probe: if already authenticated, skip the form. // 2) Existing-session probe: if already authenticated, skip the form.
@@ -444,7 +457,24 @@
<title>{t('app.title', 'OxiCloud')}</title> <title>{t('app.title', 'OxiCloud')}</title>
</svelte:head> </svelte:head>
<div class="auth-container"> {#if willRedirect}
<!-- Full-viewport overlay that visually continues the boot splash
(see app.html `#app-splash`) — same background, same spinner —
so the OIDC callback landing goes spinner → spinner → /files
with no intermediate flash of the auth-panel shell (logo +
language selector). Auth-container below is skipped entirely
during this window; if the exchange fails, `willRedirect`
flips false and the normal login form takes over. -->
<div
class="auth-splash"
role="status"
aria-label="Signing in"
data-testid="login-redirect-loader"
>
<div class="auth-splash__spinner" aria-hidden="true"></div>
</div>
{:else}
<div class="auth-container">
<div class="auth-panel"> <div class="auth-panel">
<div class="auth-logo"> <div class="auth-logo">
<div class="auth-logo-icon"> <div class="auth-logo-icon">
@@ -705,7 +735,12 @@
{#if regError}<div class="auth-error" style="display: block" role="alert"> {#if regError}<div class="auth-error" style="display: block" role="alert">
{regError} {regError}
</div>{/if} </div>{/if}
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate> <form
class="auth-form"
data-testid="login-register-form"
onsubmit={onRegister}
novalidate
>
<!-- Email is the only required identifier since PR 18 — the <!-- Email is the only required identifier since PR 18 — the
backend accepts email-only signup and mints a welcome backend accepts email-only signup and mints a welcome
magic-link. Username is optional at this stage; the user magic-link. Username is optional at this stage; the user
@@ -854,7 +889,9 @@
{#if setupError}<div class="auth-error" style="display: block" role="alert"> {#if setupError}<div class="auth-error" style="display: block" role="alert">
{setupError} {setupError}
</div>{/if} </div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if} {#if setupSuccess}<div class="auth-success" style="display: block">
{setupSuccess}
</div>{/if}
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate> <form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group"> <div class="auth-input-group">
@@ -1000,7 +1037,8 @@
</select> </select>
</div> </div>
</div> </div>
</div> </div>
{/if}
<style> <style>
.auth-lang { .auth-lang {
@@ -1053,4 +1091,30 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin: 0; margin: 0;
} }
/* OIDC-callback continuation splash — VISUALLY IDENTICAL to the
boot splash in app.html so the transition boot-splash →
post-exchange overlay is imperceptible: same background, same
spinner dimensions, same colors, same animation cadence.
Tokens match by design — `--color-bg-page` resolves to the
same `light-dark(#f5f7fa, #0f172a)` the boot splash hard-codes;
ditto `--color-border` and `--color-accent`. If you ever retone
the splash, update BOTH sites at once (app.html + this file). */
.auth-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: var(--color-bg-page);
}
.auth-splash__spinner {
width: 38px;
height: 38px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
</style> </style>