feat(06-05): gate app render behind AuthSplash (no pre-auth flash)
- Create AuthSplash component: full-screen centered column, loading/redirecting/dead-end states - loading state: Loader2 spinner + 'Signing you in' heading + 'Taking you to the sign-in page…' body - redirecting state: spinner + customisable heading/body (defaults to cold-load copy) - dead-end state: tap-to-retry button (clearLoginRedirect + maybeRedirectToLogin) - role=status, aria-label, inline styles matching SkeletonCalendar layout approach - CalendarShell: early-return AuthSplash on meQuery.isLoading (no skeleton before auth) - CalendarShell: replace 'Sign-in required' role=alert block with AuthSplash state=redirecting - CalendarShell: isInitialLoading no longer includes meQuery.isLoading (handled by early return)
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* AuthSplash — full-screen auth interstitial (D-10 + D-11, Plan 06-05).
|
||||||
|
*
|
||||||
|
* Replaces the optimistic calendar/skeleton/alert renders while auth state is
|
||||||
|
* unknown (loading) or failing (redirecting), and surfaces the session-expired
|
||||||
|
* interstitial for mid-use 401s.
|
||||||
|
*
|
||||||
|
* States:
|
||||||
|
* loading — initial cold load; meQuery is still pending. Spin + "Signing you in".
|
||||||
|
* redirecting — meQuery failed (cold load OR session-expiry); maybeRedirectToLogin()
|
||||||
|
* already fired (or the session-expiry handler fired it). Spin +
|
||||||
|
* heading/body driven by `copy` prop (defaults to cold-load copy).
|
||||||
|
* dead-end — one-shot guard has already fired; redirect failed or guard is exhausted.
|
||||||
|
* No spinner; "Sign-in required. Tap here to try again."
|
||||||
|
*
|
||||||
|
* Copy contract (UI-SPEC §Copywriting Contract §Surface 1 + §Surface 2):
|
||||||
|
* loading: heading="Signing you in" body="Taking you to the sign-in page…"
|
||||||
|
* redirecting: same defaults (or "Session expired" / "Signing you back in…" via props)
|
||||||
|
* dead-end: "Sign-in required. Tap here to try again." (tap calls clearLoginRedirect + maybeRedirectToLogin)
|
||||||
|
*
|
||||||
|
* Layout analog: SkeletonCalendar.tsx — full-screen centered column, inline styles only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { clearLoginRedirect, maybeRedirectToLogin } from '../lib/loginRedirect.js'
|
||||||
|
|
||||||
|
export type AuthSplashState = 'loading' | 'redirecting' | 'dead-end'
|
||||||
|
|
||||||
|
export interface AuthSplashProps {
|
||||||
|
state: AuthSplashState
|
||||||
|
/**
|
||||||
|
* Override the heading for the loading/redirecting states.
|
||||||
|
* Defaults to "Signing you in" (cold-load copy per UI-SPEC §Surface 1).
|
||||||
|
*/
|
||||||
|
heading?: string
|
||||||
|
/**
|
||||||
|
* Override the body text for the loading/redirecting states.
|
||||||
|
* Defaults to "Taking you to the sign-in page…" (cold-load copy per UI-SPEC §Surface 1).
|
||||||
|
*/
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthSplash({
|
||||||
|
state,
|
||||||
|
heading = 'Signing you in',
|
||||||
|
body = 'Taking you to the sign-in page…',
|
||||||
|
}: AuthSplashProps) {
|
||||||
|
const isDeadEnd = state === 'dead-end'
|
||||||
|
const showSpinner = !isDeadEnd
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Signing you in"
|
||||||
|
style={{
|
||||||
|
height: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showSpinner && (
|
||||||
|
<Loader2
|
||||||
|
size={24}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-member-0)',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDeadEnd ? (
|
||||||
|
// Dead-end: one-shot guard has already fired — show tap-to-retry
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
clearLoginRedirect()
|
||||||
|
maybeRedirectToLogin()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign-in required. Tap here to try again.
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import { hydrateEvents } from '../lib/hydrateEvents.js'
|
|||||||
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
|
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
|
||||||
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
|
import { AuthSplash } from './AuthSplash.js'
|
||||||
import { EventDetailPopover } from './EventDetailPopover.js'
|
import { EventDetailPopover } from './EventDetailPopover.js'
|
||||||
import { EventForm } from './EventForm.js'
|
import { EventForm } from './EventForm.js'
|
||||||
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
|
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
|
||||||
@@ -209,29 +210,32 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
|
|
||||||
// ── Render helpers ────────────────────────────────────────────────────────
|
// ── Render helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Determine which content to show in the calendar area
|
// Determine which content to show in the calendar area.
|
||||||
const isInitialLoading = meQuery.isLoading || (eventsQuery.isLoading && !eventsQuery.data)
|
// Note: meQuery.isLoading is handled above by an early AuthSplash return — it
|
||||||
|
// will always be false when we reach this line (meQuery.isSuccess is guaranteed).
|
||||||
|
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data
|
||||||
const isEventsError = eventsQuery.isError
|
const isEventsError = eventsQuery.isError
|
||||||
|
|
||||||
const phone = isPhone()
|
const phone = isPhone()
|
||||||
|
|
||||||
// ── Sign-in required ───────────────────────────────────────────────────────
|
// ── Auth splash (D-10) ────────────────────────────────────────────────────
|
||||||
|
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
||||||
|
// "Sign-in required" alert paints before Authelia (D-10 success criterion 5).
|
||||||
|
//
|
||||||
|
// loading: meQuery is still pending — show the neutral "Signing you in" splash.
|
||||||
|
// No skeleton, no CalendarContent — nothing app-specific until authed.
|
||||||
|
// isError: meQuery failed — the maybeRedirectToLogin() useEffect fires immediately
|
||||||
|
// after this render. Show the "redirecting" splash while the browser navigates.
|
||||||
|
// If the one-shot guard is exhausted (flag already set), maybeRedirectToLogin
|
||||||
|
// returns false and this splash stays — the dead-end "Tap to try again" is
|
||||||
|
// rendered by AuthSplash's dead-end state (wired in Task 3 via sessionExpired).
|
||||||
|
if (meQuery.isLoading) {
|
||||||
|
return <AuthSplash state="loading" />
|
||||||
|
}
|
||||||
|
|
||||||
if (meQuery.isError) {
|
if (meQuery.isError) {
|
||||||
return (
|
// useEffect at line 196 calls maybeRedirectToLogin() — splash shows while redirect fires
|
||||||
<div
|
return <AuthSplash state="redirecting" />
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
color: 'var(--color-destructive)',
|
|
||||||
padding: 'var(--space-4)',
|
|
||||||
background: 'var(--color-surface-dim)',
|
|
||||||
borderRadius: 'var(--space-2)',
|
|
||||||
fontFamily: 'var(--font-family-base)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sign-in required
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Calendar content ───────────────────────────────────────────────────────
|
// ── Calendar content ───────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user