Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence
This commit is contained in:
+95
-13
@@ -29,14 +29,45 @@ import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
import { setCurrentSession } from '../shared/utils/currentSession';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
import { initialBackendName } from '../shared/refine/providers';
|
||||
import { getBackendUrl } from '../shared/utils/runtimeConfig';
|
||||
import { buildActivityPodsAuthProvider, parseAuthFlowStage } from './auth/activityPodsAuth';
|
||||
import {
|
||||
buildActivityPodsAuthProvider,
|
||||
parseAuthFlowStage,
|
||||
safeRedirectPath,
|
||||
} from './auth/activityPodsAuth';
|
||||
import { PodProviderPicker } from './auth/PodProviderPicker';
|
||||
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
|
||||
|
||||
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
|
||||
|
||||
/**
|
||||
* The in-flight (or settled) `handleCallback()` exchange, keyed by the `?code=`
|
||||
* it belongs to. React's StrictMode double-invokes effects in dev and the
|
||||
* authorization code is single-use: a second exchange would race the first, throw
|
||||
* on the loser, and show the fatal screen after a successful login. Both effect
|
||||
* runs share this one promise, so the exchange happens exactly once; only the
|
||||
* live run publishes its result. Scoped to the code value so a genuinely new
|
||||
* login (a new code) is not mistaken for a re-entry.
|
||||
*/
|
||||
let callbackExchange: { code: string; promise: Promise<CallbackResult> } | undefined;
|
||||
|
||||
/**
|
||||
* Drop the OIDC callback parameters once the exchange has run. The code is
|
||||
* consumed; leaving it in the address bar lets a later effect re-run — or a
|
||||
* reload — treat the page as a fresh callback.
|
||||
*/
|
||||
function stripCallbackParams(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
url.searchParams.delete('session_state');
|
||||
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
@@ -86,28 +117,68 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const stage = parseAuthFlowStage(window.location.search);
|
||||
// Captured before the exchange so stripping the params cannot erase what
|
||||
// the failure decision below needs.
|
||||
const code = params.get('code');
|
||||
const hadFlowState = params.has('state');
|
||||
|
||||
try {
|
||||
if (stage.stage === 'callback') {
|
||||
const result = await provider.handleCallback();
|
||||
if (cancelled) return;
|
||||
const session = provider.getSession();
|
||||
setCurrentPrincipal(result.webId);
|
||||
setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
|
||||
navigate(`/login?register_app=1&redirect=${encodeURIComponent(stage.redirectPath)}`);
|
||||
return;
|
||||
// One exchange per code: the StrictMode double-run (both mounts see the
|
||||
// same `?code=`) shares the in-flight promise instead of racing.
|
||||
if (!callbackExchange || callbackExchange.code !== code) {
|
||||
callbackExchange = { code: code ?? '', promise: provider.handleCallback() };
|
||||
}
|
||||
const exchange = callbackExchange;
|
||||
try {
|
||||
const result = await exchange.promise;
|
||||
stripCallbackParams();
|
||||
if (cancelled) return;
|
||||
const session = provider.getSession();
|
||||
setCurrentPrincipal(result.webId);
|
||||
setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
|
||||
// The contract's callback result carries where to return to; honour
|
||||
// it (validated) rather than trusting only the parsed URL.
|
||||
const redirect = safeRedirectPath(result.redirect ?? stage.redirectPath);
|
||||
navigate(`/login?register_app=1&redirect=${encodeURIComponent(redirect)}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
stripCallbackParams();
|
||||
if (cancelled) return;
|
||||
// A `?code=` with no flow state — a stale bookmark, any link that
|
||||
// happens to carry the param — is not a callback the provider can
|
||||
// complete: it had nothing to exchange. Degrade to the picker rather
|
||||
// than the fatal screen. A callback that DID carry flow state (a
|
||||
// `state` param) stays a visible error: it is a real attempt the
|
||||
// provider refused.
|
||||
if (!hadFlowState) {
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (callbackExchange === exchange) callbackExchange = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (stage.stage === 'register_app') {
|
||||
const session = provider.getSession();
|
||||
const registration = session ? await provider.registerApp(session.webId) : undefined;
|
||||
if (!session) {
|
||||
// No live session at this stage — a stale `?register_app=1` link, or
|
||||
// a token that has expired. There is nothing to register; show the
|
||||
// picker instead of stalling on a permanent blank `checking` screen.
|
||||
setCurrentSession(undefined);
|
||||
if (!cancelled) setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
const registration = await provider.registerApp(session.webId);
|
||||
if (cancelled) return;
|
||||
if (registration) {
|
||||
if (session) {
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
}
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
navigate(stage.redirectPath);
|
||||
}
|
||||
// If `registerApp` returned `undefined`, the browser left for the Pod's
|
||||
@@ -195,9 +266,20 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
return null;
|
||||
}
|
||||
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
|
||||
// once signed in, instead of dropping everyone on `/home`. Login and the
|
||||
// bare welcome route are not deep links.
|
||||
const hasExplicitRedirect = new URLSearchParams(window.location.search).has('redirect');
|
||||
const deepLink =
|
||||
route.page !== 'login' && route.page !== 'welcome' ? window.location.pathname : undefined;
|
||||
const loginRedirect = hasExplicitRedirect
|
||||
? parseAuthFlowStage(window.location.search).redirectPath
|
||||
: deepLink ?? '/home';
|
||||
return (
|
||||
<PodProviderPicker
|
||||
authProvider={apAuthProvider!}
|
||||
redirect={loginRedirect}
|
||||
onDemo={() => {
|
||||
setDemoMode(true);
|
||||
navigate('/home');
|
||||
|
||||
@@ -5,6 +5,12 @@ import { resolveActivityPodsAuthConfig } from './activityPodsAuth';
|
||||
|
||||
interface PodProviderPickerProps {
|
||||
authProvider: AuthProvider;
|
||||
/**
|
||||
* Where to send the user once the flow completes. The gate computes it —
|
||||
* a validated `?redirect=` value, or the route the user was trying to reach —
|
||||
* so the picker never reads the raw URL itself.
|
||||
*/
|
||||
redirect: string;
|
||||
onDemo?: () => void;
|
||||
}
|
||||
|
||||
@@ -15,13 +21,11 @@ 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, onDemo }: PodProviderPickerProps) {
|
||||
export function PodProviderPicker({ authProvider, redirect, 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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ test('clientId falls back to origin/app when nothing is configured', () => {
|
||||
expect(cfg.clientId).toBe('http://localhost:5000/app');
|
||||
});
|
||||
|
||||
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:3003/app');
|
||||
});
|
||||
|
||||
test('podProviderUrl is passed through when set', () => {
|
||||
setRuntimeConfig({ podProviderUrl: 'http://localhost:3000' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
@@ -66,8 +72,22 @@ test('parseAuthFlowStage detects app registration stage', () => {
|
||||
});
|
||||
|
||||
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' });
|
||||
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker', redirectPath: '/home' });
|
||||
// A bare `?redirect` does not change the stage; it is carried (validated) so
|
||||
// every stage exposes the same post-login destination.
|
||||
expect(parseAuthFlowStage('?redirect=/events')).toEqual({
|
||||
stage: 'picker',
|
||||
redirectPath: '/events',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAuthFlowStage rejects non-same-origin redirects', () => {
|
||||
// Absolute, protocol-relative, and backslash spellings all resolve off-origin
|
||||
// — they must fall back to /home, never reach history.pushState.
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=https://evil.example').redirectPath).toBe('/home');
|
||||
expect(parseAuthFlowStage('?register_app=1&redirect=//evil.example').redirectPath).toBe('/home');
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=/\\evil.example').redirectPath).toBe('/home');
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=relative/path').redirectPath).toBe('/home');
|
||||
// A same-origin deep link survives untouched.
|
||||
expect(parseAuthFlowStage('?code=abc&redirect=/events/42').redirectPath).toBe('/events/42');
|
||||
});
|
||||
|
||||
@@ -19,11 +19,43 @@ export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End the ActivityPods session on this device.
|
||||
*
|
||||
* Per the contract, `logout` clears local state — the stored OIDC token — and
|
||||
* the token lives at ONE fixed storage key per page (`One session per page`,
|
||||
* non-guarantees), so clearing it here ends the session for the whole app. The
|
||||
* provider is built from the same resolved config as the gate and the data
|
||||
* provider; the app's own session store (`utils/currentSession`) is cleared by
|
||||
* the caller, before the reload.
|
||||
*/
|
||||
export async function logoutActivityPods(): Promise<void> {
|
||||
await buildActivityPodsAuthProvider(window.location.origin).logout({});
|
||||
}
|
||||
|
||||
/** 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' };
|
||||
| { stage: 'picker'; redirectPath: string };
|
||||
|
||||
/**
|
||||
* A same-origin post-login destination, or `/home`.
|
||||
*
|
||||
* Only a leading-slash path is accepted. An absolute URL, a protocol-relative
|
||||
* `//host` value, its backslash spelling `/\host` (which the URL parser resolves
|
||||
* the same way), or anything not starting with `/` falls back to `/home`.
|
||||
* Validated here, in the one place the URL is parsed, so every consumer — the
|
||||
* callback navigation and the picker's login call — inherits it. Without this an
|
||||
* attacker-chosen `?redirect=` could leave the origin, and `history.pushState`
|
||||
* would throw a SecurityError into the fatal screen.
|
||||
*/
|
||||
export function safeRedirectPath(raw: string | null | undefined): string {
|
||||
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) {
|
||||
return '/home';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the current URL search params to decide what AuthGate should do.
|
||||
@@ -34,12 +66,13 @@ export type AuthFlowStage =
|
||||
* registered before entering the app.
|
||||
* - otherwise → show the Pod-provider picker.
|
||||
*
|
||||
* `redirect` is preserved through the stages and defaults to `/home`.
|
||||
* `redirect` is validated (`safeRedirectPath`) and carried on every stage,
|
||||
* defaulting to `/home`.
|
||||
*/
|
||||
export function parseAuthFlowStage(search: string): AuthFlowStage {
|
||||
const params = new URLSearchParams(search);
|
||||
const redirectPath = params.get('redirect') || '/home';
|
||||
const redirectPath = safeRedirectPath(params.get('redirect'));
|
||||
if (params.has('code')) return { stage: 'callback', redirectPath };
|
||||
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
|
||||
return { stage: 'picker' };
|
||||
return { stage: 'picker', redirectPath };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { parsePath, routeToPath, eventPath, userPath, type Route } from './router';
|
||||
|
||||
// Ids on the ActivityPods backend are absolute IRIs. The router must carry them
|
||||
// through the URL intact: encode on the way out, decode on the way in. A raw IRI
|
||||
// in a path would be read as route structure (the `/` and `:` split it, and the
|
||||
// `http:` segment falls through to the welcome page).
|
||||
|
||||
const IRI = 'http://localhost:3000/alice/data/abc';
|
||||
|
||||
test('an absolute-IRI event id survives routeToPath → parsePath intact', () => {
|
||||
const route: Route = { page: 'event-detail', eventId: IRI };
|
||||
const path = routeToPath(route);
|
||||
expect(path).toBe(`/events/${encodeURIComponent(IRI)}`);
|
||||
expect(parsePath(path)).toEqual({ page: 'event-detail', eventId: IRI });
|
||||
});
|
||||
|
||||
test('an absolute-IRI event id survives every sub-route intact', () => {
|
||||
const cases: Array<[Route, Route]> = [
|
||||
[{ page: 'update-event', eventId: IRI }, { page: 'update-event', eventId: IRI }],
|
||||
[{ page: 'invite', eventId: IRI }, { page: 'invite', eventId: IRI }],
|
||||
[{ page: 'participants', eventId: IRI }, { page: 'participants', eventId: IRI }],
|
||||
[{ page: 'meeting-points', eventId: IRI }, { page: 'meeting-points', eventId: IRI }],
|
||||
];
|
||||
for (const [route, expected] of cases) {
|
||||
expect(parsePath(routeToPath(route))).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('an absolute-IRI user id survives routeToPath → parsePath intact', () => {
|
||||
const route: Route = { page: 'user-profile', userId: IRI };
|
||||
expect(parsePath(routeToPath(route))).toEqual({ page: 'user-profile', userId: IRI });
|
||||
});
|
||||
|
||||
// The id ITSELF may end in a segment that names a sub-route; encoding is what
|
||||
// keeps it from being read as one.
|
||||
test('an encoded id whose tail looks like a sub-route is not mistaken for one', () => {
|
||||
const tricky = 'http://localhost:3000/alice/data/edit';
|
||||
expect(parsePath(routeToPath({ page: 'event-detail', eventId: tricky }))).toEqual({
|
||||
page: 'event-detail',
|
||||
eventId: tricky,
|
||||
});
|
||||
expect(parsePath(eventPath(tricky, 'participants'))).toEqual({
|
||||
page: 'participants',
|
||||
eventId: tricky,
|
||||
});
|
||||
});
|
||||
|
||||
test('eventPath and userPath are the encoded path builders', () => {
|
||||
expect(eventPath(IRI)).toBe(`/events/${encodeURIComponent(IRI)}`);
|
||||
expect(eventPath(IRI, 'edit')).toBe(`/events/${encodeURIComponent(IRI)}/edit`);
|
||||
expect(userPath(IRI)).toBe(`/users/${encodeURIComponent(IRI)}`);
|
||||
});
|
||||
|
||||
// Defensive: a raw (unencoded) IRI still parses rather than falling through —
|
||||
// the id is taken as the whole remainder and the sub-route from the tail.
|
||||
test('a raw absolute-IRI path still parses as the whole remainder', () => {
|
||||
expect(parsePath('/events/http://localhost:3000/alice/data/abc')).toEqual({
|
||||
page: 'event-detail',
|
||||
eventId: IRI,
|
||||
});
|
||||
expect(parsePath('/events/http://localhost:3000/alice/data/abc/participants')).toEqual({
|
||||
page: 'participants',
|
||||
eventId: IRI,
|
||||
});
|
||||
});
|
||||
|
||||
test('the fixed routes are unchanged', () => {
|
||||
expect(parsePath('/')).toEqual({ page: 'welcome' });
|
||||
expect(parsePath('/home')).toEqual({ page: 'home' });
|
||||
expect(parsePath('/events')).toEqual({ page: 'events' });
|
||||
expect(parsePath('/events/new')).toEqual({ page: 'create-event' });
|
||||
expect(parsePath('/profile/edit')).toEqual({ page: 'edit-profile' });
|
||||
expect(parsePath('/unknown')).toEqual({ page: 'welcome' });
|
||||
});
|
||||
+49
-13
@@ -34,7 +34,21 @@ export interface RouteParams {
|
||||
// Path parsing & generation
|
||||
// ============================================================================
|
||||
|
||||
function parsePath(pathname: string): Route {
|
||||
/**
|
||||
* Decode one path segment. Ids on the ActivityPods backend are absolute IRIs, so
|
||||
* they are percent-encoded by `routeToPath`; a malformed escape (a raw IRI that
|
||||
* was never encoded) is returned verbatim rather than throwing — the captured
|
||||
* text IS the id in that case.
|
||||
*/
|
||||
function decodeSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePath(pathname: string): Route {
|
||||
const path = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') return { page: 'welcome' };
|
||||
@@ -49,10 +63,14 @@ function parsePath(pathname: string): Route {
|
||||
if (path === '/profile/share') return { page: 'share-profile' };
|
||||
if (path === '/profile/connect') return { page: 'connect' };
|
||||
|
||||
// /events/:id/...
|
||||
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
|
||||
// /events/:id/... — the id is an absolute IRI, encoded by `routeToPath` so it
|
||||
// holds no `/`; the sub-route is matched from the tail, and a raw (unencoded)
|
||||
// IRI still parses as the whole remainder. An ENCODED id may itself contain a
|
||||
// `/edit` segment without being read as a sub-route, which is why encoding is
|
||||
// mandatory on every navigation.
|
||||
const eventMatch = path.match(/^\/events\/(.+?)(?:\/(edit|invite|participants|meeting-points))?$/);
|
||||
if (eventMatch) {
|
||||
const eventId = eventMatch[1]!;
|
||||
const eventId = decodeSegment(eventMatch[1]!);
|
||||
const sub = eventMatch[2];
|
||||
if (!sub) return { page: 'event-detail', eventId };
|
||||
if (sub === 'edit') return { page: 'update-event', eventId };
|
||||
@@ -61,10 +79,10 @@ function parsePath(pathname: string): Route {
|
||||
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
|
||||
}
|
||||
|
||||
// /users/:id
|
||||
const userMatch = path.match(/^\/users\/([^/]+)$/);
|
||||
// /users/:id — an absolute actor URI, encoded for the same reason.
|
||||
const userMatch = path.match(/^\/users\/(.+)$/);
|
||||
if (userMatch) {
|
||||
return { page: 'user-profile', userId: userMatch[1]! };
|
||||
return { page: 'user-profile', userId: decodeSegment(userMatch[1]!) };
|
||||
}
|
||||
|
||||
return { page: 'welcome' };
|
||||
@@ -77,21 +95,39 @@ export function routeToPath(route: Route): string {
|
||||
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`;
|
||||
case 'participants': return `/events/${route.eventId}/participants`;
|
||||
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
|
||||
// Ids are absolute IRIs: they MUST be percent-encoded into a single path
|
||||
// segment, or the `/` and `:` inside them are read as route structure and
|
||||
// navigation falls through to the welcome page. `parsePath` decodes them.
|
||||
case 'event-detail': return `/events/${encodeURIComponent(route.eventId)}`;
|
||||
case 'update-event': return `/events/${encodeURIComponent(route.eventId)}/edit`;
|
||||
case 'invite': return `/events/${encodeURIComponent(route.eventId)}/invite`;
|
||||
case 'participants': return `/events/${encodeURIComponent(route.eventId)}/participants`;
|
||||
case 'meeting-points': return `/events/${encodeURIComponent(route.eventId)}/meeting-points`;
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends': return '/profile/friends';
|
||||
case 'share-profile': return '/profile/share';
|
||||
case 'connect': return '/profile/connect';
|
||||
case 'user-profile': return `/users/${route.userId}`;
|
||||
case 'user-profile': return `/users/${encodeURIComponent(route.userId)}`;
|
||||
case 'settings': return '/settings';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a path to an event sub-route, encoding the id. Screens navigate by path
|
||||
* rather than by `Route`, so this is the one place that keeps a raw IRI out of
|
||||
* the URL structure.
|
||||
*/
|
||||
export function eventPath(eventId: string, sub?: 'edit' | 'invite' | 'participants' | 'meeting-points'): string {
|
||||
const base = `/events/${encodeURIComponent(eventId)}`;
|
||||
return sub ? `${base}/${sub}` : base;
|
||||
}
|
||||
|
||||
/** Build a path to a user profile, encoding the actor URI. */
|
||||
export function userPath(userId: string): string {
|
||||
return `/users/${encodeURIComponent(userId)}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router context
|
||||
// ============================================================================
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 5000;
|
||||
|
||||
// Strict base64 check (not a mere `Buffer.from` attempt, which silently drops invalid
|
||||
// characters instead of failing): reject anything that is not a well-formed base64 body
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
|
||||
const importableEvents = [
|
||||
{
|
||||
@@ -108,7 +108,7 @@ export function CreateEventScreen() {
|
||||
return;
|
||||
}
|
||||
showToast('Événement relayé', 'success');
|
||||
navigate(`/events/${newEvent.id}`);
|
||||
navigate(eventPath(newEvent.id));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -239,7 +239,7 @@ export function CreateEventScreen() {
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%', padding: 10, fontSize: 13 }}
|
||||
onClick={() => navigate(`/events/${ev.id}`)}
|
||||
onClick={() => navigate(eventPath(ev.id))}
|
||||
>
|
||||
Voir cet événement
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
|
||||
|
||||
export function EventDetailScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -68,11 +68,14 @@ export function EventDetailScreen() {
|
||||
const canEdit = ownership === 'mine';
|
||||
// Preview list shows the OTHER participants (deliberate — the total is in the
|
||||
// header count; the full list at "Voir tous les participants" shows everyone).
|
||||
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
|
||||
// same space as `p.id` — whereas `currentUserId` is the `urn:festipod:user:`
|
||||
// principal, which never equals a profile id in connected mode (so the old
|
||||
// `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown").
|
||||
// On this backend `currentUser.id` and `p.id` are BOTH actor URIs (the id the
|
||||
// attendees collection keys memberships by), so comparing them drops self.
|
||||
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
|
||||
// The rest of the total could not be resolved to a profile — a non-contact
|
||||
// sees only anonymous attendees. The total still counts them, so the
|
||||
// difference is rendered as anonymous rows (mirrors ParticipantsListScreen),
|
||||
// never dropped: the count and the rows must agree.
|
||||
const unknownCount = Math.max(0, (event?.participantCount ?? 0) - participants.length);
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
@@ -101,7 +104,7 @@ export function EventDetailScreen() {
|
||||
</div>
|
||||
{canEdit && (
|
||||
<span
|
||||
onClick={() => navigate(`/events/${eventId}/edit`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'edit'))}
|
||||
title="Modifier l'événement"
|
||||
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
|
||||
>✎</span>
|
||||
@@ -157,7 +160,7 @@ export function EventDetailScreen() {
|
||||
{joined && (
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={() => navigate(`/events/${eventId}/invite`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'invite'))}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
Inviter
|
||||
@@ -178,7 +181,7 @@ export function EventDetailScreen() {
|
||||
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<button
|
||||
onClick={() => navigate(`/events/${eventId}/meeting-points`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'meeting-points'))}
|
||||
style={{ width: '100%', padding: 12, border: '2px dashed #ddd', borderRadius: 12, background: 'none', fontSize: 13, fontWeight: 600, color: '#999', cursor: 'pointer', marginTop: 4, fontFamily: 'var(--font-app)' }}
|
||||
>
|
||||
+ Proposer un point de rencontre
|
||||
@@ -192,7 +195,7 @@ export function EventDetailScreen() {
|
||||
{knownParticipants.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => navigate(`/users/${p.id}`)}
|
||||
onClick={() => navigate(userPath(p.id))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
|
||||
>
|
||||
<Avatar initials={p.initials} size={38} color="#2B6CB0" />
|
||||
@@ -204,10 +207,21 @@ export function EventDetailScreen() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{knownParticipants.length < event.participantCount && (
|
||||
{Array.from({ length: unknownCount }, (_, i) => (
|
||||
<div
|
||||
key={`unknown-${i}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5' }}
|
||||
>
|
||||
<Avatar initials="?" size={38} color="#ccc" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, color: '#999' }}>Participant inconnu</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{unknownCount > 0 && (
|
||||
<div
|
||||
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/events/${eventId}/participants`)}
|
||||
onClick={() => navigate(eventPath(event.id, 'participants'))}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>Voir tous les participants →</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
|
||||
const PEOPLE = [
|
||||
{ name: 'Marie Leroy', color: '#E8590C' },
|
||||
@@ -77,7 +77,7 @@ export function EventsScreen() {
|
||||
</Text>
|
||||
)}
|
||||
{events.map(event => (
|
||||
<EventCard key={event.id} event={event} onClick={() => navigate(`/events/${event.id}`)} />
|
||||
<EventCard key={event.id} event={event} onClick={() => navigate(eventPath(event.id))} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function InviteScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -24,7 +24,7 @@ export function InviteScreen() {
|
||||
|
||||
const send = () => {
|
||||
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
navigate(eventPath(eventId ?? ''));
|
||||
};
|
||||
|
||||
if (step === 'message') {
|
||||
@@ -122,7 +122,7 @@ export function InviteScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Inviter des amis"
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function MeetingPointsScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,13 +28,13 @@ export function MeetingPointsScreen() {
|
||||
time: when || duration,
|
||||
});
|
||||
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
navigate(eventPath(eventId ?? ''));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={() => navigate(`/events/${eventId}`)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<button onClick={() => navigate(eventPath(eventId ?? ''))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<span style={{ fontSize: 17, fontWeight: 700 }}>Point de rencontre</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
|
||||
|
||||
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ParticipantsListScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title={`Participants (${totalCount})`}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
|
||||
@@ -54,7 +54,7 @@ export function ParticipantsListScreen() {
|
||||
{rows.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
onClick={p.known ? () => navigate(`/users/${p.key}`) : undefined}
|
||||
onClick={p.known ? () => navigate(userPath(p.key)) : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function UpdateEventScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -46,7 +46,7 @@ export function UpdateEventScreen() {
|
||||
}))
|
||||
.then(() => {
|
||||
showToast('Événement mis à jour', 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
navigate(eventPath(eventId ?? ''));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[UpdateEvent] event update failed:', err);
|
||||
@@ -61,7 +61,7 @@ export function UpdateEventScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<Text style={{ textAlign: 'center', color: '#888' }}>
|
||||
@@ -80,7 +80,7 @@ export function UpdateEventScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<Text aria-busy="true" style={{ textAlign: 'center', color: '#888' }}>
|
||||
@@ -95,7 +95,7 @@ export function UpdateEventScreen() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
|
||||
@@ -2,19 +2,32 @@ import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { initialBackendName } from '../../../shared/refine/providers';
|
||||
import { logoutNg } from '../../../shared/utils/ngSession';
|
||||
import { setCurrentSession } from '../../../shared/utils/currentSession';
|
||||
import { logoutActivityPods } from '../../../app/auth/activityPodsAuth';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [location, setLocation] = useState(true);
|
||||
const backendName = initialBackendName();
|
||||
|
||||
// The ONLY logout: stop the session of the wallet this deployment serves, which
|
||||
// forces a broker redirect on the next access. There is nothing app-side to sign
|
||||
// out of — the app never names an identity, so it holds none to drop.
|
||||
// Sign out of the session THIS deployment actually runs.
|
||||
//
|
||||
// ActivityPods keeps its OIDC token in storage, so the NextGraph no-op would
|
||||
// leave it in place and the gate would re-authenticate on the reload. The auth
|
||||
// provider's own `logout` clears it, and the app's session store is dropped too
|
||||
// so nothing re-reads the dead session in between. NextGraph (the suspended
|
||||
// variant) keeps its wallet-session stop.
|
||||
const handleLeaveEnvironment = async () => {
|
||||
await logoutNg();
|
||||
if (backendName === 'activitypods') {
|
||||
await logoutActivityPods();
|
||||
setCurrentSession(undefined);
|
||||
} else {
|
||||
await logoutNg();
|
||||
}
|
||||
if (typeof window !== 'undefined') window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { useNavigate, userPath } from '../../../app/router';
|
||||
|
||||
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
|
||||
|
||||
@@ -25,7 +25,7 @@ export function FriendsListScreen() {
|
||||
{friends.map((person, i) => (
|
||||
<div
|
||||
key={person.id}
|
||||
onClick={() => navigate(`/users/${person.id}`)}
|
||||
onClick={() => navigate(userPath(person.id))}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
|
||||
export function ProfileScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -70,7 +70,7 @@ export function ProfileScreen() {
|
||||
<div style={{ padding: 16 }}>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
|
||||
{myEvents.slice(0, 3).map((event) => (
|
||||
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
|
||||
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
|
||||
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
|
||||
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useGoBack, useParams } from '../../../app/router';
|
||||
import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router';
|
||||
|
||||
export function UserProfileScreen() {
|
||||
const navigate = useNavigate();
|
||||
@@ -65,7 +65,7 @@ export function UserProfileScreen() {
|
||||
<div style={{ padding: 16 }}>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
|
||||
{userEvents.map((event) => (
|
||||
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
|
||||
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
* resolved — undefined for everyone else.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
|
||||
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
|
||||
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
|
||||
@@ -109,10 +109,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const webId = session?.webId;
|
||||
const token = session?.token;
|
||||
|
||||
// WHO AM I — the Solid identity the session signed in as (display + the
|
||||
// membership key; the attendees collection carries actor URIs). The app's own
|
||||
// entity id space (`currentUserId`) is the PROFILE document URI, resolved
|
||||
// from the actor document below — the two never mix.
|
||||
// WHO AM I — the Solid identity the session signed in as. On this backend the
|
||||
// ACTOR URI (`webId`) is the canonical user key: the attendees collection
|
||||
// carries actor URIs, so `currentUserId` is the actor URI too, and the profile
|
||||
// record fetched for display is stamped with it (its own document URI is not
|
||||
// the identity this app compares memberships against). `currentPrincipal` is
|
||||
// display/log attribution only.
|
||||
const currentPrincipal = useCurrentPrincipal();
|
||||
|
||||
// --- READS over the shared as/Event container ------------------------------
|
||||
@@ -187,8 +189,13 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
});
|
||||
const myProfile: FpUserData | undefined = useMemo(() => {
|
||||
const record: unknown = myProfileQuery.result;
|
||||
return record !== undefined ? bindings.profile.toDomain(record) : undefined;
|
||||
}, [myProfileQuery.result, bindings]);
|
||||
if (record === undefined) return undefined;
|
||||
const domain = bindings.profile.toDomain(record);
|
||||
// Stamp the ACTOR URI as the id: it is the key memberships and every other
|
||||
// user reference live in. The profile's own document URI (which the binding
|
||||
// resolves) is deliberately not the id the app compares against.
|
||||
return webId ? { ...domain, id: webId } : domain;
|
||||
}, [myProfileQuery.result, bindings, webId]);
|
||||
const currentUser = myProfile;
|
||||
const currentUserId = myProfile?.id ?? '';
|
||||
|
||||
@@ -216,11 +223,25 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
if (record === undefined) {
|
||||
refused.push(id);
|
||||
} else {
|
||||
fresh.push(bindings.profile.toDomain(record));
|
||||
// Stamp the ACTOR URI the record was fetched for — the same key an
|
||||
// attendee reference carries — so `resolveParticipantUser` can match
|
||||
// it. The profile document URI differs and would never match.
|
||||
fresh.push({ ...bindings.profile.toDomain(record), id });
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (fresh.length) setResolvedProfiles(prev => [...prev, ...fresh]);
|
||||
if (fresh.length) {
|
||||
setResolvedProfiles(prev => [...prev, ...fresh]);
|
||||
// A successful fetch is a sign that access may have been granted since
|
||||
// an earlier refusal: drop the refusal marks for the attendees still in
|
||||
// scope, so a later pass retries them — a refusal is not permanent for
|
||||
// the page's life.
|
||||
setUnresolved(prev => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set([...prev].filter(id => !attendeeIds.includes(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}
|
||||
if (refused.length) setUnresolved(prev => new Set([...prev, ...refused]));
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
@@ -315,6 +336,24 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const { mutateAsync: updateRecord } = useUpdate();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
// A join/leave is maintained asynchronously by the backend, so the immediate
|
||||
// invalidate can re-fetch BEFORE the collection is updated — and nothing
|
||||
// re-reads afterwards (the list subscription only reports membership changes).
|
||||
// ONE delayed re-invalidation per action closes that gap: no polling, at most
|
||||
// one pending timer, cancelled on unmount.
|
||||
const revalidateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = null;
|
||||
}, []);
|
||||
const scheduleListRevalidate = useCallback(() => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = setTimeout(() => {
|
||||
revalidateTimer.current = null;
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, 1500);
|
||||
}, [invalidate, bindings]);
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log(`${logPrefix} createEvent (refine):`, event.title);
|
||||
const response = await createRecord({
|
||||
@@ -381,7 +420,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
await postJoinActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'join'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings]);
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
|
||||
const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
|
||||
if (userId && userId !== webId) {
|
||||
@@ -403,7 +443,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'leave'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings]);
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
|
||||
// --- The rest of the contract -------------------------------------------------------
|
||||
const addFriend = useCallback((friendId: string) => {
|
||||
|
||||
+162
-24
File diff suppressed because one or more lines are too long
@@ -72,17 +72,30 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
|
||||
}
|
||||
});
|
||||
|
||||
test('postLeaveActivity posts a Leave, and a missing host means no to field', async () => {
|
||||
test('postLeaveActivity posts a Leave addressed to the host', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({}, 201);
|
||||
});
|
||||
try {
|
||||
await postLeaveActivity(input(undefined));
|
||||
await postLeaveActivity(input('http://localhost:3000/bob'));
|
||||
const postCall = double.calls[1]!;
|
||||
const body = JSON.parse(String(postCall.init.body));
|
||||
expect(body.type).toBe('Leave');
|
||||
expect(body).not.toHaveProperty('to');
|
||||
expect(body.to).toBe('http://localhost:3000/bob');
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// A Join/Leave with no addressee is silently dropped by the backend, so posting
|
||||
// one is refused loudly BEFORE any network call is made.
|
||||
test('a missing host refuses the post before any network call', async () => {
|
||||
const double = installFetch(() => jsonResponse({ id: ACTOR, outbox: OUTBOX }));
|
||||
try {
|
||||
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
|
||||
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
|
||||
expect(double.calls).toHaveLength(0);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
@@ -91,7 +104,7 @@ test('postLeaveActivity posts a Leave, and a missing host means no to field', as
|
||||
test('an actor document without an outbox refuses the post, naming the activity', async () => {
|
||||
const double = installFetch(() => jsonResponse({ id: ACTOR }));
|
||||
try {
|
||||
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/carries no outbox.*Join/s);
|
||||
await expect(postJoinActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/carries no outbox.*Join/s);
|
||||
expect(double.calls).toHaveLength(1);
|
||||
} finally {
|
||||
double.restore();
|
||||
@@ -104,7 +117,7 @@ test('a non-2xx outbox response throws with the status', async () => {
|
||||
return jsonResponse({ error: 'Forbidden' }, 403);
|
||||
});
|
||||
try {
|
||||
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/HTTP 403/);
|
||||
await expect(postLeaveActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/HTTP 403/);
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* (`outbox` predicate) at post time — the reference integration resolves it
|
||||
* the same way, and posting before the actor document has been read is what it
|
||||
* guards against. The activity's `object` is the meeting point's URI and its
|
||||
* `to` is the meeting point's host, so the host's Pod receives it.
|
||||
* `to` is the meeting point's host (the record's Pod-stamped `dc:creator`), so
|
||||
* the host's Pod receives it. THE HOST IS REQUIRED: a Join/Leave with no
|
||||
* addressee is silently dropped by the backend, so posting without a host URI
|
||||
* throws rather than constructing an activity nothing will ever receive.
|
||||
*
|
||||
* ASYNCHRONY IS THE PLATFORM'S. The collection update lands when the backend
|
||||
* has processed the activity — the caller must not treat the 2xx of the post
|
||||
@@ -32,7 +35,7 @@ const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
|
||||
export interface OutboxActivityInput {
|
||||
/** URI of the meeting point the activity targets. */
|
||||
meetingPointUri: string;
|
||||
/** The meeting point's host (as:organizer) — where the activity is addressed. */
|
||||
/** The meeting point's host (the record's `dc:creator`) — where the activity is addressed. */
|
||||
hostUri: string | undefined;
|
||||
/** The authenticated session — whose outbox receives the activity. */
|
||||
session: CurrentSession;
|
||||
@@ -40,6 +43,15 @@ export interface OutboxActivityInput {
|
||||
|
||||
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
|
||||
const { meetingPointUri, hostUri, session } = input;
|
||||
// A Join/Leave is addressed to the host: without a `to` the backend never
|
||||
// routes it, so the attendee would silently never be recorded. Fail loudly
|
||||
// before posting rather than construct an activity nothing receives.
|
||||
if (!hostUri) {
|
||||
throw new Error(
|
||||
`cannot post ${type} for ${meetingPointUri}: the meeting point's host is unknown ` +
|
||||
'(no addressee) — the activity would never be delivered.',
|
||||
);
|
||||
}
|
||||
const actor = await fetchActorDocument(session.webId, session.token);
|
||||
if (!actor.outbox) {
|
||||
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
|
||||
@@ -49,8 +61,8 @@ async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput):
|
||||
type,
|
||||
actor: session.webId,
|
||||
object: meetingPointUri,
|
||||
to: hostUri,
|
||||
};
|
||||
if (hostUri) activity['to'] = hostUri;
|
||||
|
||||
const response = await fetch(actor.outbox, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -430,6 +430,62 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"sourceCode": "Then('le compte autoritatif de participation de A à l\\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) {\n const freshFrame = (this as any).recoFreshFrame;\n const eventId = (this as any).isoEventId;\n await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });",
|
||||
"lineNumber": 165
|
||||
},
|
||||
{
|
||||
"pattern": "le pod provider local répond sur son endpoint OpenID",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
|
||||
"lineNumber": 40
|
||||
},
|
||||
{
|
||||
"pattern": "le backend Festipod sert la description de son application",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
|
||||
"lineNumber": 46
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est chargée depuis la pile locale",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
|
||||
"lineNumber": 57
|
||||
},
|
||||
{
|
||||
"pattern": "l'entrée de connexion au Pod est proposée",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
|
||||
"lineNumber": 65
|
||||
},
|
||||
{
|
||||
"pattern": "aucune erreur runtime n'a été émise au chargement",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
|
||||
"lineNumber": 71
|
||||
},
|
||||
{
|
||||
"pattern": "la session rejoint le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (data/outbox.ts): as:Join to the user's outbox, carrying\n // the meeting point record so the backend finds its apods:attendees collection.\n await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId);\n});",
|
||||
"lineNumber": 158
|
||||
},
|
||||
{
|
||||
"pattern": "la session quitte le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId);\n});",
|
||||
"lineNumber": 166
|
||||
},
|
||||
{
|
||||
"pattern": "la collection apods:attendees contient la session",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await readAttendees(pod, collectionUri);\n expect(members.map(linkId)).to.include(pod.webId);\n});",
|
||||
"lineNumber": 172
|
||||
},
|
||||
{
|
||||
"pattern": "le portefeuille contient des données de test",
|
||||
"keyword": "Given",
|
||||
|
||||
@@ -15,11 +15,11 @@ interface RawFeatureTestStatus {
|
||||
const rawResults: RawFeatureTestStatus[] = [
|
||||
{
|
||||
"featureId": "us-13",
|
||||
"totalScenarios": 10,
|
||||
"passed": 7,
|
||||
"totalScenarios": 7,
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
@@ -37,18 +37,6 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"name": "Pouvoir annuler le relai d'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Détecter un événement similaire déjà relayé",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Importer un événement depuis une source externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Pas d'alerte doublon lors d'un import externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un événement",
|
||||
"status": "skipped"
|
||||
@@ -69,7 +57,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
@@ -91,7 +79,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants",
|
||||
@@ -117,35 +105,23 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-7",
|
||||
"totalScenarios": 6,
|
||||
"passed": 2,
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un événement avant inscription",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "S'inscrire à un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un événement",
|
||||
"status": "skipped"
|
||||
"name": "Voir le bouton d'inscription sur l'écran",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher un événement existant",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher dans une base existante (Mobilizon)",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -155,7 +131,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 8,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un macro-événement",
|
||||
@@ -193,26 +169,22 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-16",
|
||||
"totalScenarios": 4,
|
||||
"passed": 4,
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux points de rencontre",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le bouton pour proposer un point de rencontre",
|
||||
"name": "Voir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Ouvrir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Définir l'heure de rencontre",
|
||||
"name": "Renseigner les détails du point de rencontre",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
@@ -223,7 +195,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Partager un événement auquel je participe",
|
||||
@@ -253,7 +225,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Configurer les notifications de nouveaux participants",
|
||||
@@ -279,7 +251,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les événements à venir sur l'accueil",
|
||||
@@ -309,7 +281,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil d'un participant",
|
||||
@@ -343,7 +315,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des événements depuis le profil",
|
||||
@@ -381,7 +353,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des inscrits d'un événement",
|
||||
@@ -411,7 +383,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à mon profil",
|
||||
@@ -445,7 +417,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de profil",
|
||||
@@ -475,7 +447,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage de profil",
|
||||
@@ -505,7 +477,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage depuis le profil",
|
||||
@@ -535,7 +507,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
@@ -557,7 +529,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
@@ -575,11 +547,11 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
},
|
||||
{
|
||||
"featureId": "us-26",
|
||||
"totalScenarios": 4,
|
||||
"passed": 3,
|
||||
"totalScenarios": 3,
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
@@ -589,10 +561,6 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"name": "Définir le rayon d'intérêt",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Choisir une thématique",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs obligatoires",
|
||||
"status": "passed"
|
||||
@@ -605,7 +573,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil pour voir la photo",
|
||||
@@ -635,7 +603,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
@@ -665,7 +633,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au bilan consolidé",
|
||||
@@ -687,7 +655,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 7,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la création d'atelier",
|
||||
@@ -725,7 +693,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la zone de notes personnelles",
|
||||
@@ -755,7 +723,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants d'un atelier",
|
||||
@@ -785,7 +753,7 @@ const rawResults: RawFeatureTestStatus[] = [
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"lastRun": "2026-10-10T07:48:50.596Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les ateliers d'un événement",
|
||||
|
||||
@@ -119,7 +119,10 @@ test('event toValues composes the datetimes and never stores the derived fields'
|
||||
content: 'Fabriquer des objets',
|
||||
startTime: '2026-02-08T14:00',
|
||||
endTime: '2026-02-08T17:00',
|
||||
location: 'La Maison du Vélo, Lyon',
|
||||
// `location` is `@type: @id` in the platform context: the display text is
|
||||
// written as an inline named node so it round-trips as `as:name`, never as a
|
||||
// text value resolved into a garbage IRI.
|
||||
location: { name: 'La Maison du Vélo, Lyon' },
|
||||
image: 'https://example.org/atelier.jpg',
|
||||
});
|
||||
});
|
||||
@@ -136,10 +139,10 @@ test('event toValues keeps a date-only end and drops a time-only start', () => {
|
||||
location: '',
|
||||
participantCount: 0,
|
||||
}),
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: '' });
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
|
||||
expect(
|
||||
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
|
||||
).toEqual({ name: 'T', content: '', location: '' });
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' } });
|
||||
});
|
||||
|
||||
test('event toPatch sends only what changed', () => {
|
||||
@@ -147,7 +150,7 @@ test('event toPatch sends only what changed', () => {
|
||||
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
|
||||
});
|
||||
|
||||
test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
test('meetingPoint toDomain reads the context link and the host from dc:creator', () => {
|
||||
const { meetingPoint } = getBackend('activitypods').bindings;
|
||||
const objectForm = meetingPoint.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/mp-1',
|
||||
@@ -155,7 +158,8 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
name: 'Rdv entrée',
|
||||
content: 'On se retrouve là-bas',
|
||||
'as:context': { id: 'http://localhost:3000/alice/data/event-1' },
|
||||
organizer: 'http://localhost:3000/alice',
|
||||
// The Pod's DocumentTaggerMixin stamps this on create — the host convention.
|
||||
'dc:creator': 'http://localhost:3000/alice',
|
||||
location: { name: 'Café de la Place' },
|
||||
});
|
||||
expect(objectForm).toEqual({
|
||||
@@ -167,6 +171,14 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
location: 'Café de la Place',
|
||||
time: '',
|
||||
});
|
||||
// The full IRI form (a context that does not compact `dc`) and the legacy
|
||||
// `organizer` fallback both still resolve the host.
|
||||
expect(
|
||||
meetingPoint.toDomain({
|
||||
id: 'mp-full-iri',
|
||||
'dc:creator': { '@id': 'http://localhost:3000/alice' },
|
||||
}).hostId,
|
||||
).toBe('http://localhost:3000/alice');
|
||||
const stringForm = meetingPoint.toDomain({
|
||||
id: 'http://localhost:3000/alice/data/mp-2',
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
@@ -178,7 +190,7 @@ test('meetingPoint toDomain reads the context link in both its forms', () => {
|
||||
expect(stringForm.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test('meetingPoint toValues writes the context and organizer, not the unmapped fields', () => {
|
||||
test('meetingPoint toValues writes the context and the named location, never organizer', () => {
|
||||
const { meetingPoint } = getBackend('activitypods').bindings;
|
||||
const values = meetingPoint.toValues({
|
||||
eventId: 'http://localhost:3000/alice/data/event-1',
|
||||
@@ -192,10 +204,12 @@ test('meetingPoint toValues writes the context and organizer, not the unmapped f
|
||||
expect(values).toEqual({
|
||||
name: 'Rdv entrée',
|
||||
content: 'Sous l’horloge',
|
||||
location: 'Café de la Place',
|
||||
organizer: 'http://localhost:3000/alice',
|
||||
location: { name: 'Café de la Place' },
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
});
|
||||
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
|
||||
// the platform context does not define (it would not round-trip).
|
||||
expect(values).not.toHaveProperty('organizer');
|
||||
});
|
||||
|
||||
test('profile toDomain maps the AS2 profile fields and derives the initials', () => {
|
||||
@@ -240,7 +254,8 @@ test('profile toValues writes the mapped fields and none of the derived ones', (
|
||||
name: 'Marie Dupont',
|
||||
preferredUsername: 'mariedupont',
|
||||
summary: 'Bio',
|
||||
location: 'Lyon',
|
||||
// `location` is `@type: @id`: the city is a named node, not a bare text value.
|
||||
location: { name: 'Lyon' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -67,8 +67,11 @@
|
||||
* together, and the data/live providers take the auth provider as a parameter. Its
|
||||
* `appStatusCheckInterval` is disabled deliberately: construction installs a
|
||||
* `setInterval` and a `visibilitychange` listener with no teardown (usage
|
||||
* declaration, friction 1), and Festipod drives `getAppStatus()` on its own
|
||||
* schedule. The `clientId` names the app; the app description document it points to
|
||||
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
|
||||
* the planned replacement, but it is NOT wired yet: the contract documents the
|
||||
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
|
||||
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
|
||||
* the app; the app description document it points to
|
||||
* is served by Festipod's own backend, and the values resolve from the runtime
|
||||
* config — the SAME source the AuthGate flow uses, so both halves agree.
|
||||
*/
|
||||
@@ -134,6 +137,17 @@ function namedValue(value: unknown): string | undefined {
|
||||
return o ? asString(o['name']) ?? asString(o['url']) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A display string written into a predicate the platform context types as `@id`
|
||||
* (`location` → `as:location` with `"@type": "@id"`). A bare text value is
|
||||
* resolved against the base IRI into a garbage IRI on expansion, so the text is
|
||||
* wrapped in an inline named node — expanded to a blank node carrying `as:name`,
|
||||
* which IS defined in the context — and `namedValue` reads it back.
|
||||
*/
|
||||
function namedTextValue(text: string): Record<string, unknown> {
|
||||
return { name: text };
|
||||
}
|
||||
|
||||
/** A media value: a bare URL string, or an object such as an `as:Link` carrying url. */
|
||||
function urlValue(value: unknown): string | undefined {
|
||||
const direct = asString(value);
|
||||
@@ -200,7 +214,7 @@ function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
|
||||
if (startTime !== undefined) out['startTime'] = startTime;
|
||||
const endTime = composeDateTime(data.endDate, data.endTime);
|
||||
if (endTime !== undefined) out['endTime'] = endTime;
|
||||
if (data.location !== undefined) out['location'] = data.location;
|
||||
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
|
||||
if (data.coverImage !== undefined) out['image'] = data.coverImage;
|
||||
return out;
|
||||
}
|
||||
@@ -229,7 +243,12 @@ function eventFromRecord(record: unknown): FpEventData {
|
||||
|
||||
/**
|
||||
* The meeting point field mapping. A meeting point is an `as/Event` plus `as:context`
|
||||
* pointing at the anchor event; `as:organizer` carries the host.
|
||||
* pointing at the anchor event. THE HOST IS NOT A STORED PREDICATE: the Pod's
|
||||
* DocumentTaggerMixin stamps `dc:creator` on every created resource, and the
|
||||
* creator IS the host (the welcometomyplace convention, also what the on-behalf
|
||||
* backend reads) — so the host is read back from `dc:creator`, never written.
|
||||
* `organizer` is not a term of the platform context, so writing it would not
|
||||
* round-trip and the host would be lost on read.
|
||||
* NOT mapped, on purpose: `time` (a display string such as '30 min avant', which is
|
||||
* not an AS2 datetime — unmapped until the creation flow produces one), `place` (AS2
|
||||
* has a single location slot, taken by `location`) and `inbox` (nothing provisions an
|
||||
@@ -239,12 +258,19 @@ function meetingPointValues(data: Partial<FpMeetingPointData>): Record<string, u
|
||||
const out: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) out['name'] = data.title;
|
||||
if (data.description !== undefined) out['content'] = data.description;
|
||||
if (data.location !== undefined) out['location'] = data.location;
|
||||
if (data.hostId !== undefined) out['organizer'] = data.hostId;
|
||||
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
|
||||
if (data.eventId !== undefined) out['as:context'] = data.eventId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The Pod-stamped creator of a record, tolerant of the term's compact and full forms. */
|
||||
function creatorId(record: JsonRecord | undefined): string | undefined {
|
||||
return (
|
||||
linkId(firstEntry(record?.['dc:creator'])) ??
|
||||
linkId(firstEntry(record?.['http://purl.org/dc/terms/creator']))
|
||||
);
|
||||
}
|
||||
|
||||
function meetingPointFromRecord(record: unknown): FpMeetingPointData {
|
||||
const r = asRecord(record);
|
||||
return {
|
||||
@@ -252,7 +278,9 @@ function meetingPointFromRecord(record: unknown): FpMeetingPointData {
|
||||
// A meeting point IS an `as/Event` with a context; one without it is an event,
|
||||
// and the reader tells the two apart by this very field.
|
||||
eventId: linkId(r?.['as:context']) ?? '',
|
||||
hostId: linkId(r?.['organizer']),
|
||||
// The host is the Pod-stamped creator; the `organizer` fallback is kept for
|
||||
// robustness against records written before the convention was adopted.
|
||||
hostId: creatorId(r) ?? linkId(r?.['organizer']),
|
||||
title: firstString(r?.['name']),
|
||||
description: firstString(r?.['content']),
|
||||
location: namedValue(r?.['location']) ?? '',
|
||||
@@ -271,7 +299,7 @@ function profileValues(data: Partial<FpUserData>): Record<string, unknown> {
|
||||
if (data.name !== undefined) out['name'] = data.name;
|
||||
if (data.username !== undefined) out['preferredUsername'] = data.username;
|
||||
if (data.bio !== undefined) out['summary'] = data.bio;
|
||||
if (data.city !== undefined) out['location'] = data.city;
|
||||
if (data.city !== undefined) out['location'] = namedTextValue(data.city);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ export interface ResolvedAuthConfig {
|
||||
*/
|
||||
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
|
||||
const backendClientId = runtimeConfig.backendClientId?.trim();
|
||||
const backendUrl = runtimeConfig.backendUrl?.trim();
|
||||
// Strip trailing slashes first: appending `/app` to `http://host/` would double
|
||||
// the slash (`http://host//app`), and the client id must be the exact URI the
|
||||
// backend serves.
|
||||
const backendUrl = runtimeConfig.backendUrl?.trim().replace(/\/+$/, '');
|
||||
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
|
||||
return {
|
||||
clientId,
|
||||
|
||||
Reference in New Issue
Block a user