2026-07-24 21:47:41 +02:00
|
|
|
|
import { useEffect, useState } from "react";
|
2026-07-27 11:00:00 +02:00
|
|
|
|
import { api, BASE_URL, NetworkError } from "./api/client";
|
2026-07-27 10:07:00 +02:00
|
|
|
|
import { showError } from "./banner";
|
2026-08-04 00:04:03 +02:00
|
|
|
|
import { BannerStack } from "./components/BannerStack";
|
2026-07-24 21:47:41 +02:00
|
|
|
|
import "./LoginGate.css";
|
|
|
|
|
|
import App from "./App";
|
2026-07-26 13:32:32 +02:00
|
|
|
|
import { OnboardingWizard } from "./OnboardingWizard";
|
2026-07-24 21:47:41 +02:00
|
|
|
|
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
|
2026-07-25 18:42:23 +02:00
|
|
|
|
// 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
|
2026-07-26 13:32:32 +02:00
|
|
|
|
// 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.
|
2026-07-24 21:47:41 +02:00
|
|
|
|
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");
|
|
|
|
|
|
})
|
2026-07-27 11:00:00 +02:00
|
|
|
|
.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");
|
|
|
|
|
|
});
|
2026-07-24 21:47:41 +02:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-27 10:07:00 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-07-24 21:47:41 +02:00
|
|
|
|
if (status === "loading") {
|
2026-08-04 00:04:03 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<BannerStack />
|
|
|
|
|
|
<div className="login-gate-loading">Loading…</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
2026-07-24 21:47:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status === "unauthenticated") {
|
|
|
|
|
|
return (
|
2026-08-04 00:04:03 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<BannerStack />
|
|
|
|
|
|
<div className="login-gate">
|
|
|
|
|
|
<h1>🧞♀️ geniusrun</h1>
|
|
|
|
|
|
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
|
|
|
|
|
|
Log in
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-07-24 21:47:41 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:42:23 +02:00
|
|
|
|
if (!session!.has_profile) {
|
|
|
|
|
|
return (
|
2026-07-26 13:32:32 +02:00
|
|
|
|
<OnboardingWizard
|
2026-07-25 18:42:23 +02:00
|
|
|
|
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 21:47:41 +02:00
|
|
|
|
return <App session={session!} />;
|
|
|
|
|
|
}
|