diff --git a/apps/pwa/src/components/AuthSplash.tsx b/apps/pwa/src/components/AuthSplash.tsx new file mode 100644 index 0000000..a4d3992 --- /dev/null +++ b/apps/pwa/src/components/AuthSplash.tsx @@ -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 ( +
+ {showSpinner && ( +
+ ) +} diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 542d417..fea479b 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -46,6 +46,7 @@ import { hydrateEvents } from '../lib/hydrateEvents.js' import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js' import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js' import { useCalendarStore } from '../store/calendarStore.js' +import { AuthSplash } from './AuthSplash.js' import { EventDetailPopover } from './EventDetailPopover.js' import { EventForm } from './EventForm.js' import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js' @@ -209,29 +210,32 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void // ── Render helpers ──────────────────────────────────────────────────────── - // Determine which content to show in the calendar area - const isInitialLoading = meQuery.isLoading || (eventsQuery.isLoading && !eventsQuery.data) + // Determine which content to show in the calendar area. + // 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 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 + } if (meQuery.isError) { - return ( -
- Sign-in required -
- ) + // useEffect at line 196 calls maybeRedirectToLogin() — splash shows while redirect fires + return } // ── Calendar content ───────────────────────────────────────────────────────