frontend: add LoginGate, wire session into App, add logout link
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
27
frontend/src/LoginGate.css
Normal file
27
frontend/src/LoginGate.css
Normal 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;
|
||||||
|
}
|
||||||
50
frontend/src/LoginGate.tsx
Normal file
50
frontend/src/LoginGate.tsx
Normal 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!} />;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user