feat(login): prevent login form flash on OIDC callback
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user