frontend: add LoginGate, wire session into App, add logout link

This commit is contained in:
2026-07-24 21:47:41 +02:00
parent 6dd4d9309c
commit 815db1ec1f
5 changed files with 110 additions and 11 deletions

View File

@@ -57,7 +57,6 @@ body {
alongside Activities/Progression/Training plan, so it reads more like an alongside Activities/Progression/Training plan, so it reads more like an
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */ account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
.profile-name { .profile-name {
margin-left: auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@@ -87,6 +86,23 @@ body {
color: white; color: white;
} }
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.6rem;
}
.logout-link {
color: #9aa0ab;
font-size: 0.85rem;
text-decoration: none;
}
.logout-link:hover {
color: #3b82f6;
}
.garmin-connection { .garmin-connection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -5,6 +5,7 @@ import { Dashboard } from "./pages/Dashboard";
import { Plan } from "./pages/Plan"; import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile"; import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue"; import { ReviewQueue } from "./pages/ReviewQueue";
import type { SessionInfo } from "./types/api";
const TABS = [ const TABS = [
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue }, { key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
@@ -14,7 +15,7 @@ const TABS = [
type TabKey = (typeof TABS)[number]["key"]; type TabKey = (typeof TABS)[number]["key"];
function App() { function App({ session }: { session: SessionInfo }) {
const [tab, setTab] = useState<TabKey>("activities"); const [tab, setTab] = useState<TabKey>("activities");
// Profile isn't a tab: it's reached via the profile name in the top-right // Profile isn't a tab: it's reached via the profile name in the top-right
// corner instead, since (for now, single-profile) it's account settings, // corner instead, since (for now, single-profile) it's account settings,
@@ -47,13 +48,18 @@ function App() {
</button> </button>
))} ))}
</nav> </nav>
<button <div className="header-actions">
type="button" <button
className={showProfile ? "profile-name active" : "profile-name"} type="button"
onClick={() => setShowProfile(true)} className={showProfile ? "profile-name active" : "profile-name"}
> onClick={() => setShowProfile(true)}
{profileName ?? "Profile"} >
</button> {profileName ?? "Profile"}
</button>
<a className="logout-link" href="/api/session/logout" title={session.email}>
Log out
</a>
</div>
</header> </header>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main> <main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div> </div>

View File

@@ -0,0 +1,27 @@
.login-gate-loading {
text-align: center;
margin-top: 8rem;
color: #9aa0ab;
}
.login-gate {
max-width: 420px;
margin: 8rem auto 0;
text-align: center;
color: #e6e6e6;
}
.login-gate-error {
color: #f87171;
}
.login-gate-button {
display: inline-block;
margin-top: 1rem;
padding: 0.6rem 1.5rem;
background: #3b82f6;
color: white;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { api } from "./api/client";
import "./LoginGate.css";
import App from "./App";
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 only fork in the whole frontend between "show the login
// screen" and "show the app."
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"));
}, []);
if (status === "loading") {
return <div className="login-gate-loading">Loading</div>;
}
if (status === "unauthenticated") {
const authError = new URLSearchParams(window.location.search).get("auth_error");
return (
<div className="login-gate">
<h1>🧞 geniusrun</h1>
{authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
<a className="login-gate-button" href="/api/session/login">
Log in
</a>
</div>
);
}
return <App session={session!} />;
}

View File

@@ -1,9 +1,9 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import { LoginGate } from './LoginGate.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <LoginGate />
</StrictMode>, </StrictMode>,
) )