From b09884d586fe83668506f734c29033fc1609192f Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 9 Oct 2026 14:07:47 +0200 Subject: [PATCH] ActivityPods Solid-OIDC auth flow in AuthGate with pod provider picker and /login callback --- .env.example | 5 +- src/app/App.tsx | 31 ++-- src/app/AuthGate.tsx | 197 ++++++++++++++++++++------ src/app/auth/PodProviderPicker.tsx | 111 +++++++++++++++ src/app/auth/activityPodsAuth.test.ts | 73 ++++++++++ src/app/auth/activityPodsAuth.ts | 71 ++++++++++ src/app/frontend.tsx | 32 ++++- src/app/router.tsx | 5 +- src/index.ts | 15 +- src/shared/utils/runtimeConfig.ts | 38 +++++ 10 files changed, 510 insertions(+), 68 deletions(-) create mode 100644 src/app/auth/PodProviderPicker.tsx create mode 100644 src/app/auth/activityPodsAuth.test.ts create mode 100644 src/app/auth/activityPodsAuth.ts create mode 100644 src/shared/utils/runtimeConfig.ts diff --git a/.env.example b/.env.example index 98f7690..ea4fb24 100644 --- a/.env.example +++ b/.env.example @@ -80,9 +80,12 @@ SEMAPPS_PORT=3003 # URL du backend telle que vue par le navigateur. VITE_BACKEND_URL=http://localhost:3003 -# Client ID OAuth/Solid du backend (laisser vide pour l'auto-découverte). +# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/app`). VITE_BACKEND_CLIENT_ID= +# Pod provider ActivityPods par défaut (optionnel). Vide → l'écran de login demande une URL. +VITE_POD_PROVIDER_URL=http://localhost:3000 + # ── Outillage dev (facultatif) ───────────────────────────────────────────── # Override du chemin local du polyfill @ng-eventually/sdk pour `pnpm run # overlay:polyfill` (overlay local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk. diff --git a/src/app/App.tsx b/src/app/App.tsx index a2c7b58..f7000c4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,6 +4,7 @@ import { NextGraphProvider } from '../shared/context/NextGraphContext'; import { FestipodDataProvider } from '../shared/context/FestipodDataContext'; import { AuthGate } from './AuthGate'; import { ToastContainer } from '../shared/components/sketchy'; +import { initialBackendName } from '../shared/refine/providers'; // Auth import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen'; @@ -37,6 +38,7 @@ function AppContent() { case 'home': return ; case 'events': return ; case 'create-event': return ; + case 'login': return null; case 'event-detail': return ; case 'update-event': return ; case 'invite': return ; @@ -53,20 +55,25 @@ function AppContent() { } export function App() { + const backendName = initialBackendName(); + const isNg = backendName === 'nextgraph'; + + const appTree = ( + + +
+ + + + +
+
+
+ ); + return ( - - - -
- - - - -
-
-
-
+ {isNg ? {appTree} : appTree}
); } diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx index bbd5b75..d707827 100644 --- a/src/app/AuthGate.tsx +++ b/src/app/AuthGate.tsx @@ -1,83 +1,147 @@ /** * AuthGate — the ONE await the application makes before it renders. * - * Signing in is `ensureIdentity()` and nothing else: it takes no identifier, - * resolves who we are and does the connection work (restoring what others shared - * with us, draining our inboxes). Whatever a user has to see or do while that - * resolves — opening the wallet, loading it onto a first-time device — belongs to - * the SDK: it mounts a full-screen barrier of its own on every top-level load and - * takes it down itself, and it owns the return from the broker round-trip (the - * barrier comes back prefilled, and confirming it hands the page over a second - * time; our page is never reloaded and nothing outside the barrier is touched). - * Festipod renders no access screen of its own and re-drives nothing. + * Signing in is backend-specific. There are three paths in this gate: * - * TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`), - * then await `ensureIdentity()`. A session arrives only through `init`, and - * `ensureIdentity()` awaited before it has been called THROWS. The order is a fact - * of the statement sequence below, not of React's effect ordering — which would - * get it wrong: this gate's effect runs BEFORE its parent provider's. + * 1. ACTIVITYPODS (default variant). The app renders the Solid-OIDC flow itself: + * a Pod-provider picker when no session exists, and the `/login` route handling + * the OAuth callback plus the ActivityPods app-registration stage. The auth + * provider is built with the app description document served by our backend as + * `clientId` and `${origin}/login` as `redirectUri`. * - * It hands back WHO WE ARE, and this is the app's only upstream answer to that - * question — everything else it knows about the user it has to read first. The - * value is published for display (`shared/utils/currentPrincipal`) and goes - * nowhere near a data call: no call takes an identity, because the session - * already belongs to one user. + * 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy + * `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns + * the broker round-trip. * - * Nothing of the app renders before that await settles: a screen mounted earlier - * would read as an identity that is not yet settled. + * 3. LOCAL DEMO. When no ActivityPods backend is configured, or when the user + * explicitly chooses to continue offline, children render without an + * authenticated session and `FestipodDataProvider` serves the seeded demo data. * - * AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the - * app degrades through: an app that could not sign in but still shows its screens - * is indistinguishable from an app whose user simply owns nothing — a total - * failure wearing the face of an empty account. So the rejection is SHOWN, and the - * children stay unmounted, which is also what keeps the data layer from settling - * on its empty stand-in provider for the rest of the session. + * Whatever a user has to see or do while signing in belongs on screen: the + * ActivityPods picker replaces the old wallet barrier, and the local demo fallback + * remains reachable. */ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { ensureIdentity } from '@ng-eventually/polyfill'; import { startNgSession } from '../shared/utils/ngSession'; import { setCurrentPrincipal } from '../shared/utils/currentPrincipal'; import { useRouter, useNavigate } from './router'; +import { initialBackendName } from '../shared/refine/providers'; +import { getBackendUrl } from '../shared/utils/runtimeConfig'; +import { buildActivityPodsAuthProvider, parseAuthFlowStage } from './auth/activityPodsAuth'; +import { PodProviderPicker } from './auth/PodProviderPicker'; + +type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error'; export function AuthGate({ children }: { children: ReactNode }) { const { route } = useRouter(); const navigate = useNavigate(); - // Whether the ONE identity await has resolved. + + const backendName = initialBackendName(); + const isActivityPods = backendName === 'activitypods'; + const isNg = backendName === 'nextgraph'; + const activityPodsEnabled = isActivityPods && !!getBackendUrl(); + const [demoMode, setDemoMode] = useState(false); + + // Whether the ONE identity await has resolved (NextGraph path). const [identityReady, setIdentityReady] = useState(false); // Why it did NOT resolve. Set once, never cleared: signing in is attempted once. const [signInError, setSignInError] = useState(null); + // ActivityPods flow state. + const [apState, setApState] = useState('checking'); + const [apError, setApError] = useState(null); + + const apAuthProvider = useMemo(() => { + if (!activityPodsEnabled) return undefined; + return buildActivityPodsAuthProvider(window.location.origin); + }, [activityPodsEnabled]); + + // NextGraph identity flow — unchanged. useEffect(() => { + if (!isNg) return; let cancelled = false; - // FIRST — a session arrives only through the SDK's `init`. Idempotent, so the - // provider above may have started it already; what matters is that it has been - // called before the await below, or the await throws. void startNgSession(); void ensureIdentity() - .then(principal => { - // Publish who we are BEFORE anything renders — the identity is a fact of - // the session, not state of this component, so it is recorded even if the - // effect was torn down in between. + .then((principal) => { setCurrentPrincipal(principal); if (!cancelled) setIdentityReady(true); }) - .catch(err => { + .catch((err) => { console.error('[Auth] ensureIdentity failed:', err); if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err)); }); return () => { cancelled = true; }; - }, []); + }, [isNg]); + + // ActivityPods Solid-OIDC + app-registration flow. + useEffect(() => { + if (!activityPodsEnabled || !apAuthProvider || demoMode) return; + const provider = apAuthProvider; + + let cancelled = false; + + async function run() { + const stage = parseAuthFlowStage(window.location.search); + + try { + if (stage.stage === 'callback') { + const result = await provider.handleCallback(); + if (cancelled) return; + setCurrentPrincipal(result.webId); + navigate(`/login?register_app=1&redirect=${encodeURIComponent(stage.redirectPath)}`); + return; + } + + if (stage.stage === 'register_app') { + const session = provider.getSession(); + const registration = session ? await provider.registerApp(session.webId) : undefined; + if (cancelled) return; + if (registration) { + if (session) setCurrentPrincipal(session.webId); + navigate(stage.redirectPath); + } + // If `registerApp` returned `undefined`, the browser left for the Pod's + // consent screen. The effect is torn down; it will resume when the user + // comes back. + return; + } + + const session = provider.getSession(); + if (session) { + setCurrentPrincipal(session.webId); + if (!cancelled) setApState('authenticated'); + } else { + if (!cancelled) setApState('unauthenticated'); + } + } catch (err) { + console.error('[Auth] ActivityPods flow failed:', err); + if (!cancelled) { + setApError(err instanceof Error ? err.message : String(err)); + setApState('error'); + } + } + } + + void run(); + return () => { cancelled = true; }; + }, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]); + + // Once identified, leave the welcome screen for the app home. + useEffect(() => { + if (apState === 'authenticated' && route.page === 'welcome') { + navigate('/home'); + } + }, [apState, route.page, navigate]); - // Once identified, leave the disconnected welcome screen for the app home. useEffect(() => { if (identityReady && route.page === 'welcome') { navigate('/home'); } }, [identityReady, route.page, navigate]); - // Signing in FAILED — say so. The app has nothing legitimate to show, and - // showing it anyway would pass a broken session off as an empty one. + // Signing in FAILED — say so. The app has nothing legitimate to show. if (signInError) { return (