diff --git a/package.json b/package.json index 4a08872..12260e5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json", "test:auth-setup": "bun scripts/setup-test-auth.ts", "test:e2e-flow": "bun scripts/e2e-login-flow.ts", + "test:e2e-revocation": "bun scripts/e2e-revocation.ts", "cucumber:report": "bun scripts/parse-test-results.ts", "features:parse": "bun scripts/parse-features.ts", "steps:extract": "bun scripts/extract-step-definitions.ts", diff --git a/scripts/e2e-revocation.ts b/scripts/e2e-revocation.ts new file mode 100644 index 0000000..ea4cb84 --- /dev/null +++ b/scripts/e2e-revocation.ts @@ -0,0 +1,405 @@ +/** + * Revocation probe: drives the REAL local ActivityPods stack through the browser + * and asserts that Festipod notices when its registration on the user's pod is + * revoked, instead of staying in a "connected" view whose writes cannot land. + * + * THREE PHASES, one fresh user: + * + * 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on + * load (the GET of the pod's `/.well-known/app-status` is observed on the + * wire, with its body, and the app logs `[AppStatus] probe (load) → current`); + * a focus + visibilitychange right after does NOT probe at once (one-minute + * throttle); an event is created and read back from the pod; a + * visibilitychange inside the minute is deferred, not dropped — its probe + * lands at the end of the minute and reads current. + * 2. REVOKED — the registration is removed on the pod + * (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token). + * (a) Without any reload, a window focus inside the minute — the user coming + * back from the pod's tab — must, by the end of that minute, read the + * revocation and take the app off the connected view: the consent screen + * (the provider's `registerApp` redirect) or the picker. (b) Reloading the + * app on the event form must do the same through the load probe, and the + * event-creation drive that worked in phase 1 must not reach success: no + * "Événement relayé", no event page, and nothing with that title on the pod. + * 3. RECOVERED — accepting the consent screen brings the user back to a + * connected app whose probe reads current again, and a write lands. + * + * Nothing is stubbed: the probe result is whatever the pod answers. The probe + * is triggered only through the app's real paths (page load, window focus, + * visibilitychange); its one-minute throttle is untouched. + * + * ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for + * `http://localhost:4000/login` only, so the app MUST be served at APP_URL. + * + * Exit code 0 only when every phase holds; the JSON report says which did not. + */ + +import { chromium, type Page } from 'playwright'; +import { + linkId, + podLogin, + readResource, + recordUri, + type ApodsSession, + type ApodsStackConfig, +} from '../src/shared/support/activitypodsStack'; + +const strip = (u: string): string => u.replace(/\/+$/, ''); +const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003'); +const APP: string = strip(process.env.APP_URL || 'http://localhost:4000'); +const config: ApodsStackConfig = { + podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'), + backendUrl: BACKEND, +}; +const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000'); + +const USER = `revoke${Date.now()}`; +const PASS = 'Test1test'; +const DEADLINE = Date.now() + 10 * 60 * 1000; + +type Phase = 'current' | 'revoked' | 'recovered'; +let phase: Phase = 'current'; + +interface StatusSeen { phase: Phase; status: number; body: unknown } +interface HttpError { phase: Phase; url: string; status: number; method: string; body: string } + +const statusSeen: StatusSeen[] = []; +const probeLogs: Array<{ phase: Phase; at: number; text: string }> = []; +const errors4xx: HttpError[] = []; +const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = []; +const consoleErrors: string[] = []; +const redirects: string[] = []; +const hops: string[] = []; + +const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.'); +const bodyText = (page: Page): Promise => + page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim()); +const snap = (page: Page, name: string): Promise => + page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined); +const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); + +function wire(page: Page): void { + let last = ''; + page.on('framenavigated', f => { + if (f === page.mainFrame() && f.url() !== last) { + redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`); + last = f.url(); + } + }); + page.on('response', r => { + const method = r.request()?.method?.() ?? '?'; + const url = r.url(); + if (method === 'GET' && url.endsWith('/.well-known/app-status')) { + const at = phase; + r.json() + .then(body => statusSeen.push({ phase: at, status: r.status(), body })) + .catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' })); + } + if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) { + writes.push({ phase, method, url: clean(url), status: r.status() }); + } + if (r.status() >= 400) { + const at = phase; + r.text() + .then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) })) + .catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' })); + } + }); + page.on('console', m => { + const text = m.text(); + if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) }); + if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`); + }); + page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`)); +} + +const onApp = (page: Page): boolean => page.url().startsWith(APP); +const pickerShown = async (page: Page): Promise => + (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0; +const whereIs = async (page: Page): Promise => + !onApp(page) + ? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`) + : (await pickerShown(page)) ? 'picker' : 'still-connected'; + +/** Drive pod login → consent until the connected app shell is reached. */ +async function driveToApp(page: Page): Promise { + let authorizeSeen = 0; + for (let i = 0; i < 25 && Date.now() < DEADLINE; i++) { + const url = page.url(); + if (hops[hops.length - 1] !== `[${phase}] ${clean(url)}`) hops.push(`[${phase}] ${clean(url)}`); + + if (url.startsWith(POD_FRONT) && (await page.locator('input[name="username"]').count().catch(() => 0))) { + hops.push(` [${phase}] [login] submitting credentials`); + await page.locator('input[name="username"]').fill(USER).catch(() => {}); + await page.locator('input[name="password"]').fill(PASS).catch(() => {}); + const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i }); + if (await btn.count()) await btn.first().click().catch(() => {}); + else await page.locator('button[type=submit]').first().click().catch(() => {}); + await page.waitForTimeout(3500); + continue; + } + + if (url.startsWith(`${POD_FRONT}/authorize`)) { + authorizeSeen++; + const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i }); + const visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false); + hops.push(` [${phase}] [authorize #${authorizeSeen}] accept visible=${visible}`); + await snap(page, `revocation-${phase}-consent`); + if (visible) await acc.first().click().catch(() => {}); + if (authorizeSeen >= 3) { hops.push(` [${phase}] [LOOP] authorize revisited 3x`); return false; } + await page.waitForTimeout(5000); + continue; + } + + if (onApp(page)) { + if (url.includes('/login')) { await page.waitForTimeout(3000); continue; } + if (!(await pickerShown(page)) && !(await page.locator('#auth-reauthorizing').count().catch(() => 0))) { + hops.push(` [${phase}] [app] shell reached`); + return true; + } + } + await page.waitForTimeout(1500); + } + return false; +} + +/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */ +async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> { + let successToast = false; + const toastWatch = setInterval(() => { + void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {}); + }, 250); + try { + await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {}); + await page.waitForTimeout(1500); + await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 3000 }).catch(() => {}); + const dates = page.locator('input[type="date"]'); + if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {}); + await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 3000 }).catch(() => {}); + await page.waitForTimeout(1200); + const next = page.getByRole('button', { name: /Suivant/ }); + if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {}); + await page.waitForTimeout(1200); + await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {}); + await page.waitForTimeout(5000); + } finally { + clearInterval(toastWatch); + } + const finalUrl = page.url(); + const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null; + const segment = m?.[1]; + const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined; + return { eventUri, successToast, finalUrl }; +} + +/** Does the user's pod hold an event with this title? Read with the user's own token. */ +async function podHoldsEventTitled(session: ApodsSession, title: string): Promise { + const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined); + if (!container) return false; + if (JSON.stringify(container).includes(title)) return true; + const raw = container['ldp:contains'] ?? container['contains']; + const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u); + for (const uri of members) { + const rec = await readResource(session, uri).catch(() => undefined); + if (rec && JSON.stringify(rec).includes(title)) return true; + } + return false; +} + +/** Wait until a predicate holds or the bound elapses. */ +async function waitFor(pred: () => boolean | Promise, ms: number): Promise { + const end = Date.now() + ms; + while (Date.now() < end) { + if (await pred()) return true; + await sleep(250); + } + return pred(); +} + +/** Fire the real DOM event a probe listener is attached to. */ +const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise => + page.evaluate((which) => { + if (which === 'focus') window.dispatchEvent(new Event('focus')); + else document.dispatchEvent(new Event('visibilitychange')); + }, trigger).catch(() => {}); + +const lastProbeAt = (): number => + Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at)); + +/** + * The probe's own schedule, exercised live and never shortened: fire `trigger` + * less than a minute after the app's last probe, check that NO probe starts at + * once (throttle), then wait for the deferred probe of that trigger to land + * with the expected verdict at the end of the minute (not dropped). + */ +async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{ + firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number; +}> { + const last = lastProbeAt(); + const firedAfterLastProbeMs = Date.now() - last; + const before = probeLogs.length; + await fire(page, trigger); + await page.waitForTimeout(3000); + const immediate = probeLogs.slice(before).some(l => l.text.includes('probe (')); + const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`); + const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000); + const hit = probeLogs.slice(before).find(l => pattern.test(l.text)); + return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined }; +} + +const isCurrent = (b: unknown): boolean => + typeof b === 'object' && b !== null && (b as Record)['installed'] === true && (b as Record)['upgradeNeeded'] === false; + +/** Benign ≥400 by design (same classes as e2e-login-flow.ts). */ +function benign(e: HttpError): boolean { + if (e.url.includes('/.notifications/WebSocketChannel2023')) return true; + if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true; + return false; +} + +(async () => { + const user = await podLogin(config, USER, PASS); + const appDescription = await readResource(undefined, `${BACKEND}/app`); + const appUri = recordUri(appDescription); + if (!appUri) throw new Error('GET /app carries no id (the app actor URI)'); + + const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + wire(page); + + // ── Phase 1: CURRENT ──────────────────────────────────────────────────── + await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click(); + const connected = await driveToApp(page); + const probeRanCurrent = connected && await waitFor( + () => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body)) + && probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)), + 15000, + ); + // Throttle, observed through the real listeners: focus + visibilitychange + // seconds after the load probe must not issue another status request. + const beforeNudge = statusSeen.filter(s => s.phase === 'current').length; + await page.evaluate(() => { + window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + }).catch(() => {}); + await page.waitForTimeout(3000); + const afterNudge = statusSeen.filter(s => s.phase === 'current').length; + const throttled = beforeNudge >= 1 && afterNudge === beforeNudge; + const titleA = `Concert courant ${USER}`; + const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string }; + const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false; + await snap(page, 'revocation-current-after-write'); + // A visibilitychange right after the write's reload: deferred, then current. + const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined; + + // ── Phase 2: REVOKED ──────────────────────────────────────────────────── + phase = 'revoked'; + const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` }, + body: JSON.stringify({ appUri }), + }); + const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) }; + // Removal is processed by the pod and the app backend; give it a moment to settle. + await sleep(3000); + + // (a) FOCUS, no reload — the user revokes in the pod's tab and comes back + // within the minute of the last probe: the deferred focus probe must read the + // revocation and take the app off the connected view. + const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/); + const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000); + const leftByFocusTo = await whereIs(page); + await snap(page, 'revocation-revoked-left-by-focus'); + + // (b) RELOAD on the write path — the same event-creation drive that worked in + // phase 1: the load probe must take the app off again, and nothing may + // pretend the write succeeded. + const titleB = `Concert revoque ${USER}`; + const loadLogsBefore = probeLogs.length; + const writeB = await createEvent(page, titleB); + await snap(page, 'revocation-revoked-after-write-attempt'); + const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000); + const leftAfterReloadTo = await whereIs(page); + const loadProbeSawRevoked = probeLogs.slice(loadLogsBefore).some(l => /probe \(load\) → stale \(not-installed\)/.test(l.text)); + const writeBOnPod = await podHoldsEventTitled(user, titleB); + await snap(page, 'revocation-revoked-left'); + + // ── Phase 3: RECOVERED ────────────────────────────────────────────────── + phase = 'recovered'; + const reconnected = leftAfterReloadTo === 'consent' ? await driveToApp(page) : false; + const probeReadsCurrentAgain = reconnected && await waitFor( + () => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)), + 15000, + ); + const titleC = `Concert retabli ${USER}`; + const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string }; + const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false; + await snap(page, 'revocation-recovered-after-write'); + + await ctx.close(); + await browser.close(); + await sleep(1000); + + // The deferred probe lands at the end of the minute: never before (throttle), + // and not much after (it was deferred, not dropped). Measured between the + // app's own log lines, so a few hundred ms of request latency either way. + const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean => + !!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined + && p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000; + const positive = { + connected, + probeRanCurrent, + throttled, + statusRequestsAfterNudge: afterNudge, + write: { ...writeA, onPod: writeAOnPod }, + visibilityProbe, + ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod + && deferredOnTime(visibilityProbe), + }; + const negative = { + revoke: revokeResult, + focusProbe, + leftByFocus, + leftByFocusTo, + loadProbeSawRevoked, + leftAfterReload, + leftAfterReloadTo, + writeAttempt: { ...writeB, onPod: writeBOnPod }, + ok: (revoke.status === 200 || revoke.status === 204) + && deferredOnTime(focusProbe) && leftByFocus && (leftByFocusTo === 'consent' || leftByFocusTo === 'picker') + && loadProbeSawRevoked && leftAfterReload && (leftAfterReloadTo === 'consent' || leftAfterReloadTo === 'picker') + && !writeB.eventUri && !writeB.successToast && !writeBOnPod, + }; + const recovered = { + reconnected, + probeReadsCurrentAgain, + write: { ...writeC, onPod: writeCOnPod }, + ok: reconnected && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod, + }; + // Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean. + const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e)); + const report = { + user: USER, + appUri, + positive, + negative, + recovered, + statusBodies: statusSeen, + probeLogs, + writes, + revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`), + fatal4xx: fatal, + redirects, + hops, + consoleErrors, + ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0, + }; + console.log(JSON.stringify(report, null, 2)); + process.exit(report.ok ? 0 : 1); +})().catch(err => { + console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2)); + process.exit(1); +}); diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx index 426e045..c5328ac 100644 --- a/src/app/AuthGate.tsx +++ b/src/app/AuthGate.tsx @@ -10,6 +10,10 @@ * `clientId` and `${origin}/login` as `redirectUri`. Every session the flow * establishes is published to `utils/currentSession` — the module store the * data provider's dispatch reads, independent of where each mounts in the tree. + * While signed in, the app-status probe (`auth/appStatusProbe`) re-checks the + * registration on load and on focus/visibility: a revoked or stale app re-runs + * the provider's `registerApp` (its consent redirect is the recovery) instead of + * staying in a "connected" view whose writes cannot land. * * 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 @@ -24,7 +28,7 @@ * remains reachable. */ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ensureIdentity } from '@ng-eventually/polyfill'; import { startNgSession } from '../shared/utils/ngSession'; import { setCurrentPrincipal } from '../shared/utils/currentPrincipal'; @@ -39,8 +43,19 @@ import { safeRedirectPath, } from './auth/activityPodsAuth'; import { PodProviderPicker } from './auth/PodProviderPicker'; +import { recoverRegistration, resolveStaleRegistration, type StaleReason } from './auth/appStatusProbe'; +import { useAppStatusProbe } from './auth/useAppStatusProbe'; -type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error'; +/** + * `reauthorizing`: the app-status probe found the registration revoked or stale + * and `registerApp` is sending the browser to the consent screen — the app view + * is taken down rather than left looking connected. + */ +type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'reauthorizing' | 'error'; + +/** Shown on the picker when a revoked/stale registration could not be re-run. */ +const REGISTRATION_LOST_NOTICE = + 'Festipod n’est plus autorisé sur votre Pod. Reconnectez-vous pour l’autoriser à nouveau.'; type CallbackResult = Awaited>; @@ -86,6 +101,22 @@ export function AuthGate({ children }: { children: ReactNode }) { // ActivityPods flow state. const [apState, setApState] = useState('checking'); const [apError, setApError] = useState(null); + // Why the picker is back after a session existed (registration lost). + const [apNotice, setApNotice] = useState(null); + // Set when the app-status probe took the connected view down: the consent + // redirect is under way ('reauthorizing'), or the registration is gone and + // could not be re-run ('registration-lost'). The provider's token is still + // stored (logout is not ours to trigger here), so without this any later + // route change — a Back, a link — would re-derive "authenticated" from that + // token and bring back a view that can neither read nor write. Lives for the + // page: the consent and the picker's sign-in both leave it for the pod, and a + // reload probes afresh. + const blockedRef = useRef<{ kind: 'reauthorizing'; reason: StaleReason } | { kind: 'registration-lost' } | null>(null); + // One recovery at a time: the probe already serializes its own, this also + // covers repeated clicks on the reauthorizing screen's retry (each would + // start its own consent redirect). A `registerApp` that never settles keeps + // it set — the same limit as a hung probe run; a reload clears it. + const recoveringRef = useRef(false); const apAuthProvider = useMemo(() => { if (!activityPodsEnabled) return undefined; @@ -188,10 +219,13 @@ export function AuthGate({ children }: { children: ReactNode }) { } const session = provider.getSession(); - if (session) { + const blocked = blockedRef.current; + if (session && !blocked) { setCurrentPrincipal(session.webId); setCurrentSession({ webId: session.webId, token: session.token }); if (!cancelled) setApState('authenticated'); + } else if (session && blocked?.kind === 'reauthorizing') { + if (!cancelled) setApState('reauthorizing'); } else { setCurrentSession(undefined); if (!cancelled) setApState('unauthenticated'); @@ -209,6 +243,71 @@ export function AuthGate({ children }: { children: ReactNode }) { return () => { cancelled = true; }; }, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]); + // A revoked or stale registration: re-run the provider's registration flow. + // Its consent redirect IS the recovery; a resolved URI means the registration + // is current after all and the app stays as it is. When it fails, what + // happens depends on what was found: a REVOKED registration (nothing can be + // read or written) lands on the picker with a notice; a registration that + // only needs an upgrade stays connected and is retried by the next probe. + const handleStaleRegistration = useCallback(async (reason: StaleReason) => { + const provider = apAuthProvider; + if (!provider || recoveringRef.current) return; + recoveringRef.current = true; + let outcome: Awaited>; + try { + outcome = await recoverRegistration({ + getWebId: () => provider.getSession()?.webId, + registerApp: (webId) => provider.registerApp(webId), + }); + } finally { + recoveringRef.current = false; + } + const resolution = resolveStaleRegistration(reason, outcome); + console.info(`[AppStatus] recovery (${reason}) → ${outcome.kind} → ${resolution}`); + if (outcome.kind === 'failed') console.error('[AppStatus] registerApp failed:', outcome.detail); + switch (resolution) { + case 'stay-connected': { + // From the probe this changes nothing; from the reauthorizing screen's + // retry (a consent given meanwhile) it brings the app back — with the + // session published again, or the data layer would serve demo data + // behind a connected-looking app. + const session = provider.getSession(); + if (!session) { + blockedRef.current = null; + setCurrentSession(undefined); + setApState('unauthenticated'); + return; + } + blockedRef.current = null; + setCurrentPrincipal(session.webId); + setCurrentSession({ webId: session.webId, token: session.token }); + setApState('authenticated'); + return; + } + case 'reauthorizing': + blockedRef.current = { kind: 'reauthorizing', reason }; + setApState('reauthorizing'); + return; + case 'signed-out': + blockedRef.current = null; + setCurrentSession(undefined); + setApState('unauthenticated'); + return; + case 'registration-lost': + blockedRef.current = { kind: 'registration-lost' }; + setCurrentSession(undefined); + setApNotice(REGISTRATION_LOST_NOTICE); + setApState('unauthenticated'); + return; + } + }, [apAuthProvider]); + + useAppStatusProbe({ + provider: apAuthProvider, + active: activityPodsEnabled && !demoMode && apState === 'authenticated', + onStale: handleStaleRegistration, + }); + // Once identified, leave the welcome screen for the app home. useEffect(() => { if (apState === 'authenticated' && route.page === 'welcome') { @@ -265,6 +364,30 @@ export function AuthGate({ children }: { children: ReactNode }) { if (apState === 'checking') { return null; } + if (apState === 'reauthorizing') { + return ( +
+

Autorisation à renouveler

+

+ Festipod n’est plus autorisé sur votre Pod, ou son autorisation doit être + mise à jour. Redirection vers votre Pod pour la renouveler… +

+ {/* The redirect can be interrupted (Back, a page restored from the + browser's cache): this screen must never be a dead end. */} + +
+ ); + } if (apState === 'unauthenticated') { // Seed the post-login destination: an explicit (validated) `?redirect=` // wins; otherwise a deep link the user opened lands them back on that page @@ -280,6 +403,7 @@ export function AuthGate({ children }: { children: ReactNode }) { { setDemoMode(true); navigate('/home'); diff --git a/src/app/auth/PodProviderPicker.tsx b/src/app/auth/PodProviderPicker.tsx index 8dde3ef..037430c 100644 --- a/src/app/auth/PodProviderPicker.tsx +++ b/src/app/auth/PodProviderPicker.tsx @@ -11,6 +11,8 @@ interface PodProviderPickerProps { * so the picker never reads the raw URL itself. */ redirect: string; + /** Why the user is back on the picker (e.g. the app's registration was revoked). */ + notice?: string; onDemo?: () => void; } @@ -21,7 +23,7 @@ interface PodProviderPickerProps { * 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, redirect, onDemo }: PodProviderPickerProps) { +export function PodProviderPicker({ authProvider, redirect, notice, onDemo }: PodProviderPickerProps) { const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin); const [customUrl, setCustomUrl] = useState(''); @@ -68,6 +70,17 @@ export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProvide Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre. + {notice && ( +

+ {notice} +

+ )} + {defaultProviderHost && (