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 (
@@ -91,13 +155,54 @@ export function AuthGate({ children }: { children: ReactNode }) {
);
}
- // Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
- // barrier is what is on screen, it put it there and it takes it down. Anything
- // of ours here would be a second thing competing with it.
- if (!identityReady) {
+ if (apError) {
+ return (
+
+
Connexion impossible
+
+ Festipod n’a pas réussi à se connecter à votre Pod. Vérifiez l’URL du
+ provider et réessayez.
+
+
{apError}
+
+
+ );
+ }
+
+ // ActivityPods variant: show the picker until the user has a session.
+ if (isActivityPods && activityPodsEnabled && !demoMode) {
+ if (apState === 'checking') {
+ return null;
+ }
+ if (apState === 'unauthenticated') {
+ return (
+ {
+ setDemoMode(true);
+ navigate('/home');
+ }}
+ />
+ );
+ }
+ // authenticated → fall through to children
+ }
+
+ // NextGraph variant: the SDK's own full-screen barrier is what is on screen.
+ if (isNg && !identityReady) {
return null;
}
- // The app.
+ // The app (local demo or authenticated ActivityPods / NextGraph).
return <>{children}>;
}
diff --git a/src/app/auth/PodProviderPicker.tsx b/src/app/auth/PodProviderPicker.tsx
new file mode 100644
index 0000000..651d062
--- /dev/null
+++ b/src/app/auth/PodProviderPicker.tsx
@@ -0,0 +1,111 @@
+import { useState } from 'react';
+import type { AuthProvider } from '@activitypods/refine-providers';
+import { Button, Input, Title, Text } from '../../shared/components/sketchy';
+import { resolveActivityPodsAuthConfig } from './activityPodsAuth';
+
+interface PodProviderPickerProps {
+ authProvider: AuthProvider;
+ onDemo?: () => void;
+}
+
+/**
+ * Pod-provider picker — the ActivityPods sign-in screen.
+ *
+ * When a default Pod provider is configured via `VITE_POD_PROVIDER_URL`, it is
+ * offered as a one-click option. The user can also type any Solid Pod provider
+ * URL. A "continue offline" button keeps the local demo fallback reachable.
+ */
+export function PodProviderPicker({ authProvider, onDemo }: PodProviderPickerProps) {
+ const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
+ const [customUrl, setCustomUrl] = useState('');
+
+ const startLogin = (issuer: string) => {
+ const params = new URLSearchParams(window.location.search);
+ const redirect = params.get('redirect') || '/home';
+ void authProvider.login({ issuer, redirect });
+ };
+
+ const defaultProviderHost = podProviderUrl ? safeHost(podProviderUrl) : undefined;
+
+ return (
+
+
+
+ Festipod
+
+
+ Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
+
+
+ {defaultProviderHost && (
+
+ )}
+
+