Files
geniusrun/frontend/src/LoginGate.tsx

94 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { api, BASE_URL, NetworkError } from "./api/client";
import { showError } from "./banner";
import { BannerStack } from "./components/BannerStack";
import "./LoginGate.css";
import App from "./App";
import { OnboardingWizard } from "./OnboardingWizard";
import type { SessionInfo } from "./types/api";
type Status = "loading" | "authenticated" | "unauthenticated";
const AUTH_ERROR_MESSAGES: Record<string, string> = {
forbidden: "Your account isn't authorized for geniusrun.",
failed: "Login failed, please try again.",
};
// Wraps App: on mount, asks the backend whether this browser already has a
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
// this is the first fork -- "show the login screen" vs "show the app." A
// second fork, once authenticated, is whether the session's account has a
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
// sees OnboardingWizard instead of App until it completes one. Nothing is
// persisted until the wizard's Garmin-connect step actually succeeds (see
// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md),
// so has_profile alone is always sufficient here -- there's no separate
// "provisioned but not connected" state to gate on.
export function LoginGate() {
const [status, setStatus] = useState<Status>("loading");
const [session, setSession] = useState<SessionInfo | null>(null);
useEffect(() => {
api
.getSessionInfo()
.then((s) => {
setSession(s);
setStatus("authenticated");
})
.catch((e) => {
// A NetworkError here means the backend itself isn't reachable, not
// that this browser is genuinely logged out -- worth a banner, since
// otherwise this is the one place in the app where "backend not
// here" would show no feedback at all. Either way the screen still
// falls through to "unauthenticated" (showing the Log in button),
// since there's no session to trust regardless of why the check
// failed.
if (e instanceof NetworkError) showError(e.message);
setStatus("unauthenticated");
});
}, []);
// Keycloak redirects back here with ?auth_error=<code> when the
// login-gate's own role check (not Keycloak's own authentication) rejects
// a session -- surfaced once, as a banner, the moment this screen is
// reached.
useEffect(() => {
if (status !== "unauthenticated") return;
const authError = new URLSearchParams(window.location.search).get("auth_error");
if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again.");
}, [status]);
if (status === "loading") {
return (
<>
<BannerStack />
<div className="login-gate-loading">Loading</div>
</>
);
}
if (status === "unauthenticated") {
return (
<>
<BannerStack />
<div className="login-gate">
<h1>🧞 geniusrun</h1>
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
Log in
</a>
</div>
</>
);
}
if (!session!.has_profile) {
return (
<OnboardingWizard
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/>
);
}
return <App session={session!} />;
}