Files
geniusrun/frontend/src/LoginGate.tsx

75 lines
2.7 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 } from "./api/client";
import { showError } from "./banner";
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(() => 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 <div className="login-gate-loading">Loading</div>;
}
if (status === "unauthenticated") {
return (
<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!} />;
}