ActivityPods Solid-OIDC auth flow in AuthGate with pod provider picker and /login callback
This commit is contained in:
+19
-12
@@ -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 <HomeScreen />;
|
||||
case 'events': return <EventsScreen />;
|
||||
case 'create-event': return <CreateEventScreen />;
|
||||
case 'login': return null;
|
||||
case 'event-detail': return <EventDetailScreen />;
|
||||
case 'update-event': return <UpdateEventScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
@@ -53,20 +55,25 @@ function AppContent() {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const backendName = initialBackendName();
|
||||
const isNg = backendName === 'nextgraph';
|
||||
|
||||
const appTree = (
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+151
-46
@@ -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<string | null>(null);
|
||||
|
||||
// ActivityPods flow state.
|
||||
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
|
||||
const [apError, setApError] = useState<string | null>(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 (
|
||||
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
@@ -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 (
|
||||
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Connexion impossible</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’a pas réussi à se connecter à votre Pod. Vérifiez l’URL du
|
||||
provider et réessayez.
|
||||
</p>
|
||||
<p className="app-text" data-testid="auth-error-detail">{apError}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-btn"
|
||||
onClick={() => {
|
||||
setApError(null);
|
||||
setApState('unauthenticated');
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ActivityPods variant: show the picker until the user has a session.
|
||||
if (isActivityPods && activityPodsEnabled && !demoMode) {
|
||||
if (apState === 'checking') {
|
||||
return null;
|
||||
}
|
||||
if (apState === 'unauthenticated') {
|
||||
return (
|
||||
<PodProviderPicker
|
||||
authProvider={apAuthProvider!}
|
||||
onDemo={() => {
|
||||
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}</>;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: '100dvh',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 16 }}>
|
||||
Festipod
|
||||
</Title>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
marginBottom: 32,
|
||||
lineHeight: 1.5,
|
||||
color: '#555',
|
||||
}}
|
||||
>
|
||||
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
|
||||
</Text>
|
||||
|
||||
{defaultProviderHost && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => startLogin(podProviderUrl!)}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
Se connecter avec {defaultProviderHost}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="URL de votre Pod provider"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
style={{ marginBottom: 8, width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => customUrl && startLogin(customUrl)}
|
||||
disabled={!customUrl}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Continuer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onDemo && (
|
||||
<Button variant="accent-outline" onClick={onDemo} style={{ marginBottom: 12 }}>
|
||||
Continuer hors ligne
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
|
||||
Version beta
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { setRuntimeConfig } from '../../shared/utils/runtimeConfig';
|
||||
import {
|
||||
parseAuthFlowStage,
|
||||
resolveActivityPodsAuthConfig,
|
||||
type AuthFlowStage,
|
||||
} from './activityPodsAuth';
|
||||
|
||||
test('clientId uses explicit backendClientId when set', () => {
|
||||
setRuntimeConfig({ backendClientId: 'https://app.example/id.json' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:3000');
|
||||
expect(cfg.clientId).toBe('https://app.example/id.json');
|
||||
expect(cfg.redirectUri).toBe('http://localhost:3000/login');
|
||||
});
|
||||
|
||||
test('clientId derives from backendUrl when backendClientId is empty', () => {
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:3003/app');
|
||||
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
|
||||
});
|
||||
|
||||
test('clientId falls back to origin/app when nothing is configured', () => {
|
||||
setRuntimeConfig({});
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:5000/app');
|
||||
});
|
||||
|
||||
test('podProviderUrl is passed through when set', () => {
|
||||
setRuntimeConfig({ podProviderUrl: 'http://localhost:3000' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.podProviderUrl).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
test('whitespace-only runtime values are treated as empty', () => {
|
||||
setRuntimeConfig({
|
||||
backendUrl: ' ',
|
||||
backendClientId: ' ',
|
||||
podProviderUrl: ' ',
|
||||
});
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:5000/app');
|
||||
expect(cfg.podProviderUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage detects OIDC callback', () => {
|
||||
expect(parseAuthFlowStage('?code=abc&state=xyz')).toEqual({
|
||||
stage: 'callback',
|
||||
redirectPath: '/home',
|
||||
});
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=/events')).toEqual({
|
||||
stage: 'callback',
|
||||
redirectPath: '/events',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage detects app registration stage', () => {
|
||||
expect(parseAuthFlowStage('?register_app=1')).toEqual({
|
||||
stage: 'register_app',
|
||||
redirectPath: '/home',
|
||||
});
|
||||
expect(parseAuthFlowStage('?register_app=1&redirect=/profile')).toEqual({
|
||||
stage: 'register_app',
|
||||
redirectPath: '/profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage defaults to picker', () => {
|
||||
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker' });
|
||||
// A bare `?redirect` does not change the stage; the redirect path is only read
|
||||
// when the user lands back from the Pod provider.
|
||||
expect(parseAuthFlowStage('?redirect=/events')).toEqual({ stage: 'picker' });
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { getRuntimeConfig } from '../../shared/utils/runtimeConfig';
|
||||
|
||||
/**
|
||||
* Resolved ActivityPods auth configuration.
|
||||
*
|
||||
* `clientId` names the app description document the backend serves, as required
|
||||
* by the Solid-OIDC flow. `redirectUri` is fixed to `${origin}/login` because the
|
||||
* app renders its own callback route there.
|
||||
*/
|
||||
export interface ResolvedAuthConfig {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
podProviderUrl: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the auth configuration from runtime values.
|
||||
*
|
||||
* - `backendClientId` wins when explicitly set.
|
||||
* - Otherwise, `backendUrl` is used to derive `${backendUrl}/app`.
|
||||
* - Otherwise, fall back to `${origin}/app` so local dev still typechecks and
|
||||
* can be exercised without a backend.
|
||||
*/
|
||||
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
|
||||
const cfg = getRuntimeConfig();
|
||||
const backendClientId = cfg.backendClientId?.trim();
|
||||
const backendUrl = cfg.backendUrl?.trim();
|
||||
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
|
||||
return {
|
||||
clientId,
|
||||
redirectUri: `${origin}/login`,
|
||||
podProviderUrl: cfg.podProviderUrl?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an ActivityPods auth provider with the Festipod-specific configuration. */
|
||||
export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
|
||||
const cfg = resolveActivityPodsAuthConfig(origin);
|
||||
return createActivityPodsAuthProvider({
|
||||
clientId: cfg.clientId,
|
||||
redirectUri: cfg.redirectUri,
|
||||
appStatusCheckInterval: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Which stage of the Solid-OIDC / app-registration flow the current URL represents. */
|
||||
export type AuthFlowStage =
|
||||
| { stage: 'callback'; redirectPath: string }
|
||||
| { stage: 'register_app'; redirectPath: string }
|
||||
| { stage: 'picker' };
|
||||
|
||||
/**
|
||||
* Parse the current URL search params to decide what AuthGate should do.
|
||||
*
|
||||
* - `?code=...` → the Pod provider redirected back after login; complete the
|
||||
* OAuth exchange and move to app registration.
|
||||
* - `?register_app=1` → back from the Pod's consent screen; ensure the app is
|
||||
* registered before entering the app.
|
||||
* - otherwise → show the Pod-provider picker.
|
||||
*
|
||||
* `redirect` is preserved through the stages and defaults to `/home`.
|
||||
*/
|
||||
export function parseAuthFlowStage(search: string): AuthFlowStage {
|
||||
const params = new URLSearchParams(search);
|
||||
const redirectPath = params.get('redirect') || '/home';
|
||||
if (params.has('code')) return { stage: 'callback', redirectPath };
|
||||
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
|
||||
return { stage: 'picker' };
|
||||
}
|
||||
+27
-5
@@ -24,16 +24,38 @@
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { setRuntimeConfig } from "../shared/utils/runtimeConfig";
|
||||
|
||||
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
|
||||
/** Runtime config shape served by `/festipod-config.json`. */
|
||||
interface FestipodConfig {
|
||||
sharedWalletPassword?: string;
|
||||
autoSeed?: string;
|
||||
backendUrl?: string;
|
||||
backendClientId?: string;
|
||||
podProviderUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the runtime config and make it available to the app.
|
||||
*
|
||||
* The shared-wallet globals may already be inlined by `build.ts`'s `define`; the
|
||||
* runtime endpoint is still fetched for the ActivityPods backend values, which
|
||||
* are env-driven and not baked into the bundle.
|
||||
*/
|
||||
async function loadRuntimeConfig(): Promise<void> {
|
||||
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
|
||||
// so that same `define` (which rewrites the dotted global) leaves this read alone.
|
||||
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
|
||||
try {
|
||||
const res = await fetch("/festipod-config.json");
|
||||
if (!res.ok) return;
|
||||
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
|
||||
const cfg = (await res.json()) as FestipodConfig;
|
||||
|
||||
// Backend coupling: the browser cannot read process.env, so the server exposes
|
||||
// the values the ActivityPods auth flow needs.
|
||||
setRuntimeConfig({
|
||||
backendUrl: cfg.backendUrl,
|
||||
backendClientId: cfg.backendClientId,
|
||||
podProviderUrl: cfg.podProviderUrl,
|
||||
});
|
||||
|
||||
// Bracket access so build.ts's `define` (which matches the dotted global)
|
||||
// never rewrites this assignment. Only set when the env actually carries one.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
+4
-1
@@ -20,7 +20,8 @@ type Route =
|
||||
| { page: 'share-profile' }
|
||||
| { page: 'connect' }
|
||||
| { page: 'user-profile'; userId: string }
|
||||
| { page: 'settings' };
|
||||
| { page: 'settings' }
|
||||
| { page: 'login' };
|
||||
|
||||
export type { Route };
|
||||
|
||||
@@ -40,6 +41,7 @@ function parsePath(pathname: string): Route {
|
||||
if (path === '/home') return { page: 'home' };
|
||||
if (path === '/events') return { page: 'events' };
|
||||
if (path === '/events/new') return { page: 'create-event' };
|
||||
if (path === '/login') return { page: 'login' };
|
||||
if (path === '/settings') return { page: 'settings' };
|
||||
if (path === '/profile') return { page: 'profile' };
|
||||
if (path === '/profile/edit') return { page: 'edit-profile' };
|
||||
@@ -74,6 +76,7 @@ export function routeToPath(route: Route): string {
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'login': return '/login';
|
||||
case 'event-detail': return `/events/${route.eventId}`;
|
||||
case 'update-event': return `/events/${route.eventId}/edit`;
|
||||
case 'invite': return `/events/${route.eventId}/invite`;
|
||||
|
||||
Reference in New Issue
Block a user