From dc269b60fc73f70a374e13f37f55454145c54ffc Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 09:22:22 +0100 Subject: [PATCH 01/38] Install solid/reactive-authentication package --- package-lock.json | 69 +++++++++++++++++++++++++++++++++++++++++++---- package.json | 1 + 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ac325a..7352638 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,14 @@ "@inrupt/solid-client-authn-browser": "^3.1.1", "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", + "@solid/reactive-authentication": "^0.1.5", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.1" + "solid-react-component": "^0.2.2" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", @@ -80,6 +81,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -7817,6 +7819,30 @@ "url": "https://github.com/sponsors/rubensworks/" } }, + "node_modules/@solid/reactive-authentication": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@solid/reactive-authentication/-/reactive-authentication-0.1.5.tgz", + "integrity": "sha512-GGIB5ughd3FHmKUzpMkaOKGlISi7OjNj0KXuABEjv7zMd29IQbJdrLYNtztwOx0QLTLCeGD2bsKGI6n6u1Ue7Q==", + "license": "MIT", + "dependencies": { + "@rdfjs/wrapper": "^0.34", + "dpop": "^2", + "n3": "^2", + "oauth4webapi": "^3" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@solid/reactive-authentication/node_modules/@rdfjs/wrapper": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.34.0.tgz", + "integrity": "sha512-psGw4IAH0E27P7Nlro0SzjqPJJ+aE3MkYKWfNbBk+vwT2Zt+gJJyuznuYg54peZs4rAF9FLoqCe4JdvQNDTzMw==", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@solidlab/policy-engine": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/@solidlab/policy-engine/-/policy-engine-0.0.2.tgz", @@ -8773,6 +8799,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8949,6 +8976,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9522,6 +9550,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9994,6 +10023,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10663,7 +10693,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -10936,6 +10967,15 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11255,6 +11295,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11440,6 +11481,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13663,6 +13705,7 @@ "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^1.3.8", "content-disposition": "~1.0.1", @@ -13706,6 +13749,7 @@ "resolved": "https://registry.npmjs.org/ky/-/ky-0.25.1.tgz", "integrity": "sha512-PjpCEWlIU7VpiMVrTwssahkYXX1by6NCT0fhTUX34F3DTinARlgMpriuroolugFPcMgpPWrOW4mTb984Qm1RXA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -14702,6 +14746,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -15873,6 +15926,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15882,6 +15936,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16670,9 +16725,10 @@ } }, "node_modules/solid-react-component": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.1.tgz", - "integrity": "sha512-gENFQeJLeXqPo51UD+PRP8YTbflSsxwceiFAbv0c4DQM3fvbq6OtiHejWZsZoGrqHwd+Sh5h8SR4wGhN976rcg==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.8.tgz", + "integrity": "sha512-Tn3oA1ctUsmOKH1TLNzyRpRt/w8URZpG5W+8rS9AdLsBQOY1/7pJ/zoJXnrRxWIXSplgk/F/4rRQffzFE5230g==", + "deprecated": "Moved to @solid/react-component", "license": "MIT", "peerDependencies": { "@ldo/solid-react": ">=1.0.0-alpha.33", @@ -17287,6 +17343,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17554,6 +17611,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18114,6 +18172,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 06c0322..7f37056 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@inrupt/solid-client-authn-browser": "^3.1.1", "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", + "@solid/reactive-authentication": "^0.1.5", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", From e94e4cb3503a48d5c22fee8a6e660f836c230083 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 09:22:40 +0100 Subject: [PATCH 02/38] feat: add OAuth authorization code callback page --- public/callback.html | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 public/callback.html diff --git a/public/callback.html b/public/callback.html new file mode 100644 index 0000000..2a27872 --- /dev/null +++ b/public/callback.html @@ -0,0 +1,22 @@ + + + + + Authorization Code callback + + +

Completing sign-in…

+ + + \ No newline at end of file From dc8d2ac92ad24fcff51de1c2c3b9e692eb0b449f Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 09:54:58 +0100 Subject: [PATCH 03/38] chore: add fetch instrumentation for auth spike --- app/spike/instruments.ts | 189 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 app/spike/instruments.ts diff --git a/app/spike/instruments.ts b/app/spike/instruments.ts new file mode 100644 index 0000000..c253366 --- /dev/null +++ b/app/spike/instruments.ts @@ -0,0 +1,189 @@ +/** + * Measurement instrumentation for the reactive-authentication spike. + * + * Wraps global fetch and window.open so we can count exactly what one + * authenticated request costs: how many OIDC round-trips, how many popups. + * + * Throwaway — lives under app/spike/ and is deleted with it. + */ + +export type RequestClass = "discovery" | "registration" | "token" | "resource"; + +export interface LogEntry { + seq: number; + method: string; + url: string; + requestClass: RequestClass; + status: number | null; + ms: number; + error?: string; +} + +export interface Summary { + total: number; + discovery: number; + registration: number; + token: number; + resource: number; + popupsOpened: number; + popupsBlocked: number; + totalMs: number; +} + +const DISCOVERY_PATH = "/.well-known/openid-configuration"; + +let installed = false; +let pristineFetch: typeof globalThis.fetch; +let nativeOpen: typeof window.open; + +let seq = 0; +let log: LogEntry[] = []; +let popupsOpened = 0; +let popupsBlocked = 0; +let summaryCache: Summary | null = null; + +/** + * Endpoints learned from discovery responses, used to classify later requests. + * Deliberately not cleared by resetLog() so classification stays accurate + * across experiments. + */ +const learned = new Map(); + +const listeners = new Set<() => void>(); + +function notify(): void { + summaryCache = null; + for (const listener of listeners) { + listener(); + } +} + +function stripQuery(url: string): string { + const index = url.indexOf("?"); + return index === -1 ? url : url.slice(0, index); +} + +function urlOf(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +function methodOf(input: RequestInfo | URL, init?: RequestInit): string { + if (init?.method) return init.method.toUpperCase(); + if (input instanceof Request) return input.method.toUpperCase(); + return "GET"; +} + +function classify(url: string): RequestClass { + if (url.includes(DISCOVERY_PATH)) return "discovery"; + return learned.get(stripQuery(url)) ?? "resource"; +} + +/** Reads endpoint URLs out of a discovery document so we can label what follows. */ +async function learnEndpoints(response: Response): Promise { + try { + const config = await response.clone().json(); + if (typeof config.registration_endpoint === "string") { + learned.set(stripQuery(config.registration_endpoint), "registration"); + } + if (typeof config.token_endpoint === "string") { + learned.set(stripQuery(config.token_endpoint), "token"); + } + } catch { + // Unreadable discovery document; classification falls back to "resource". + } +} + +async function instrumentedFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const url = urlOf(input); + const start = performance.now(); + const entry: LogEntry = { + seq: ++seq, + method: methodOf(input, init), + url, + requestClass: "resource", + status: null, + ms: 0, + }; + + try { + const response = await pristineFetch(input, init); + if (url.includes(DISCOVERY_PATH)) { + await learnEndpoints(response); + } + entry.status = response.status; + entry.requestClass = classify(url); + return response; + } catch (error) { + entry.error = error instanceof Error ? error.message : String(error); + throw error; + } finally { + entry.ms = Math.round(performance.now() - start); + log = log.concat(entry); + notify(); + } +} + +function instrumentedOpen( + ...args: Parameters +): Window | null { + const opened = nativeOpen(...args); + if (opened === null) { + popupsBlocked++; + } else { + popupsOpened++; + } + notify(); + return opened; +} + +/** Installs the wrappers. Idempotent, and a no-op during server rendering. */ +export function installInstrument(): void { + if (installed || typeof window === "undefined") return; + installed = true; + + pristineFetch = globalThis.fetch.bind(globalThis); + globalThis.fetch = instrumentedFetch; + + nativeOpen = window.open.bind(window); + window.open = instrumentedOpen; +} + +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getLog(): readonly LogEntry[] { + return log; +} + +export function getSummary(): Summary { + if (summaryCache) return summaryCache; + + summaryCache = { + total: log.length, + discovery: log.filter((e) => e.requestClass === "discovery").length, + registration: log.filter((e) => e.requestClass === "registration").length, + token: log.filter((e) => e.requestClass === "token").length, + resource: log.filter((e) => e.requestClass === "resource").length, + popupsOpened, + popupsBlocked, + totalMs: log.reduce((sum, e) => sum + e.ms, 0), + }; + return summaryCache; +} + +export function resetLog(): void { + log = []; + seq = 0; + popupsOpened = 0; + popupsBlocked = 0; + notify(); +} From 9dd482033242837fcd3b48cdd32554f96479f375 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 10:15:09 +0100 Subject: [PATCH 04/38] chore: add reactive-auth spike harness --- app/spike/custom-elements.d.ts | 18 ++++++ app/spike/page.tsx | 115 +++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 app/spike/custom-elements.d.ts create mode 100644 app/spike/page.tsx diff --git a/app/spike/custom-elements.d.ts b/app/spike/custom-elements.d.ts new file mode 100644 index 0000000..fb41787 --- /dev/null +++ b/app/spike/custom-elements.d.ts @@ -0,0 +1,18 @@ +import type { DetailedHTMLProps, HTMLAttributes } from "react"; +import type { + AuthorizationCodeFlow, + IdpPicker, + WebIdPicker, +} from "@solid/reactive-authentication"; + +type CustomElement = DetailedHTMLProps, T>; + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "authorization-code-flow": CustomElement; + "idp-picker": CustomElement; + "webid-picker": CustomElement; + } + } +} diff --git a/app/spike/page.tsx b/app/spike/page.tsx new file mode 100644 index 0000000..c202f54 --- /dev/null +++ b/app/spike/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + AuthorizationCodeFlow, + ReactiveFetchManager as Manager, +} from "@solid/reactive-authentication"; +import { installInstrument } from "./instruments"; + +const DEFAULT_RESOURCE = "https://storage.inrupt.com/0260f9c0-5d12-446e-abe2-7ab651343d77/"; +const DEFAULT_ISSUER = "https://login.inrupt.com"; + +export default function SpikePage() { + const codeUiRef = useRef(null); + const managerRef = useRef(null); + const issuerRef = useRef(DEFAULT_ISSUER); + + const [resourceUrl, setResourceUrl] = useState(DEFAULT_RESOURCE); + const [issuer, setIssuer] = useState(DEFAULT_ISSUER); + const [ready, setReady] = useState(false); + const [result, setResult] = useState(""); + + // getIssuer closes over this ref so it always sees the current input value. + useEffect(() => { + issuerRef.current = issuer; + }, [issuer]); + + useEffect(() => { + let cancelled = false; + + async function setup() { + // Must run before the manager is constructed: ReactiveFetchManager + // captures globalThis.fetch once, in its constructor. + installInstrument(); + + // Both imports are dynamic because the package defines classes that + // extend HTMLElement at module scope, which throws under SSR. + await import("@solid/reactive-authentication/registerElements"); + const { DPoPTokenProvider, ReactiveFetchManager } = await import( + "@solid/reactive-authentication" + ); + if (cancelled || managerRef.current) return; + + const provider = new DPoPTokenProvider( + `${window.location.origin}/callback.html`, + // Read the method lazily — the custom element may not have upgraded yet. + (authorizationUri, signal) => { + const ui = codeUiRef.current; + if (!ui) throw new Error("authorization-code-flow is not mounted"); + return ui.getCode(authorizationUri, signal); + }, + async () => new URL(issuerRef.current), + ); + + managerRef.current = new ReactiveFetchManager([provider]); + setReady(true); + } + + setup(); + return () => { + cancelled = true; + }; + }, []); + + const fetchOnce = useCallback(async () => { + const manager = managerRef.current; + if (!manager) return; + + setResult("fetching…"); + try { + const response = await manager.fetch(resourceUrl); + const body = await response.text(); + setResult(`${response.status} ${response.statusText}\n\n${body.slice(0, 400)}`); + } catch (error) { + setResult(`error: ${error instanceof Error ? error.message : String(error)}`); + } + }, [resourceUrl]); + + return ( +
+

reactive-authentication spike

+ + + + + + + +
{result}
+ + +
+ ); +} \ No newline at end of file From de8739abfeaf0877eacc16c7695845717dbe36e1 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 10:28:49 +0100 Subject: [PATCH 05/38] chore: add reactive-auth spike experiments --- app/spike/page.tsx | 168 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 21 deletions(-) diff --git a/app/spike/page.tsx b/app/spike/page.tsx index c202f54..fb7bc4b 100644 --- a/app/spike/page.tsx +++ b/app/spike/page.tsx @@ -1,15 +1,41 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import type { AuthorizationCodeFlow, ReactiveFetchManager as Manager, } from "@solid/reactive-authentication"; -import { installInstrument } from "./instruments"; +import { + getLog, + getSummary, + installInstrument, + resetLog, + subscribe, + type LogEntry, + type Summary, +} from "./instruments"; const DEFAULT_RESOURCE = "https://storage.inrupt.com/0260f9c0-5d12-446e-abe2-7ab651343d77/"; const DEFAULT_ISSUER = "https://login.inrupt.com"; +/** Kept deliberately small — each 401 may register a new client on a real server. */ +const BATCH = 5; + +/** Must exceed the browser's ~5s transient activation window. */ +const NO_GESTURE_DELAY_MS = 6000; + +const EMPTY_LOG: readonly LogEntry[] = []; +const EMPTY_SUMMARY: Summary = { + total: 0, + discovery: 0, + registration: 0, + token: 0, + resource: 0, + popupsOpened: 0, + popupsBlocked: 0, + totalMs: 0, +}; + export default function SpikePage() { const codeUiRef = useRef(null); const managerRef = useRef(null); @@ -18,7 +44,11 @@ export default function SpikePage() { const [resourceUrl, setResourceUrl] = useState(DEFAULT_RESOURCE); const [issuer, setIssuer] = useState(DEFAULT_ISSUER); const [ready, setReady] = useState(false); - const [result, setResult] = useState(""); + const [status, setStatus] = useState(""); + const [running, setRunning] = useState(false); + + const log = useSyncExternalStore(subscribe, getLog, () => EMPTY_LOG); + const summary = useSyncExternalStore(subscribe, getSummary, () => EMPTY_SUMMARY); // getIssuer closes over this ref so it always sees the current input value. useEffect(() => { @@ -62,19 +92,63 @@ export default function SpikePage() { }; }, []); - const fetchOnce = useCallback(async () => { + const fetchResource = useCallback(async () => { const manager = managerRef.current; - if (!manager) return; + if (!manager) throw new Error("manager is not ready"); + // no-store keeps the HTTP cache from hiding a 401 -> auth -> 200 cycle. + const response = await manager.fetch(resourceUrl, { cache: "no-store" }); + await response.arrayBuffer(); + return response.status; + }, [resourceUrl]); - setResult("fetching…"); + const run = useCallback(async (label: string, experiment: () => Promise) => { + setRunning(true); + resetLog(); + setStatus(`running ${label}…`); + const started = performance.now(); try { - const response = await manager.fetch(resourceUrl); - const body = await response.text(); - setResult(`${response.status} ${response.statusText}\n\n${body.slice(0, 400)}`); + await experiment(); + setStatus(`${label} — done in ${Math.round(performance.now() - started)}ms`); } catch (error) { - setResult(`error: ${error instanceof Error ? error.message : String(error)}`); + setStatus(`${label} — failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setRunning(false); } - }, [resourceUrl]); + }, []); + + const experiments: { key: string; label: string; go: () => Promise }[] = [ + { + key: "A", + label: `A. fetch once`, + go: async () => { + await fetchResource(); + }, + }, + { + key: "B", + label: `B. fetch ${BATCH}x sequentially`, + go: async () => { + for (let i = 0; i < BATCH; i++) { + await fetchResource(); + } + }, + }, + { + key: "C", + label: `C. fetch ${BATCH}x in parallel`, + go: async () => { + await Promise.all(Array.from({ length: BATCH }, () => fetchResource())); + }, + }, + { + key: "D", + label: `D. fetch after ${NO_GESTURE_DELAY_MS / 1000}s (no gesture)`, + go: async () => { + await new Promise((resolve) => setTimeout(resolve, NO_GESTURE_DELAY_MS)); + await fetchResource(); + }, + }, + ]; return (
@@ -98,18 +172,70 @@ export default function SpikePage() { /> - - -
{result}
+
+ {experiments.map((experiment) => ( + + ))} + +
+ +

{ready ? status : "loading…"}

+ +
+ + + 1} /> + 1} /> + + 0} /> + +
+ + + + + + + + + + + + + + {log.map((entry) => ( + + + + + + + + + ))} + +
#methodclassstatusmsurl
{entry.seq}{entry.method}{entry.requestClass}{entry.error ? "err" : entry.status}{entry.ms}{entry.error ?? entry.url}
); +} + +function Stat({ label, value, warn }: { label: string; value: number; warn?: boolean }) { + return ( +
+
{label}:
+
{value}
+
+ ); } \ No newline at end of file From 8914c8bdc40be3db7f2f7e17ce6e611759bfb468 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 11:33:34 +0100 Subject: [PATCH 06/38] chore: update reactive-auth package to point to github branch(temp) --- package-lock.json | 5 ++--- package.json | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7352638..c2efec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@inrupt/solid-client-authn-browser": "^3.1.1", "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", - "@solid/reactive-authentication": "^0.1.5", + "@solid/reactive-authentication": "github:solid-contrib/reactive-authentication#feat/dpop-session-cache-installable", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", @@ -7821,8 +7821,7 @@ }, "node_modules/@solid/reactive-authentication": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@solid/reactive-authentication/-/reactive-authentication-0.1.5.tgz", - "integrity": "sha512-GGIB5ughd3FHmKUzpMkaOKGlISi7OjNj0KXuABEjv7zMd29IQbJdrLYNtztwOx0QLTLCeGD2bsKGI6n6u1Ue7Q==", + "resolved": "git+ssh://git@github.com/solid-contrib/reactive-authentication.git#0e6149e8a64489cbee5434f3d39891e1b8fbf31f", "license": "MIT", "dependencies": { "@rdfjs/wrapper": "^0.34", diff --git a/package.json b/package.json index 7f37056..390839b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@inrupt/solid-client-authn-browser": "^3.1.1", "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", - "@solid/reactive-authentication": "^0.1.5", + "@solid/reactive-authentication": "github:solid-contrib/reactive-authentication#feat/dpop-session-cache-installable", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", From d0e4809ee1840e3253cf86afd7e9cddb537fc081 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 11:34:11 +0100 Subject: [PATCH 07/38] chore: delete temporary spike custom-element --- app/spike/custom-elements.d.ts | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 app/spike/custom-elements.d.ts diff --git a/app/spike/custom-elements.d.ts b/app/spike/custom-elements.d.ts deleted file mode 100644 index fb41787..0000000 --- a/app/spike/custom-elements.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { DetailedHTMLProps, HTMLAttributes } from "react"; -import type { - AuthorizationCodeFlow, - IdpPicker, - WebIdPicker, -} from "@solid/reactive-authentication"; - -type CustomElement = DetailedHTMLProps, T>; - -declare module "react" { - namespace JSX { - interface IntrinsicElements { - "authorization-code-flow": CustomElement; - "idp-picker": CustomElement; - "webid-picker": CustomElement; - } - } -} From 18248610c8284a3e621576d600da74a6f2162709 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 11:34:51 +0100 Subject: [PATCH 08/38] chore: move custom element types to app/lib/auth --- app/lib/auth/custom-elements.d.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/lib/auth/custom-elements.d.ts diff --git a/app/lib/auth/custom-elements.d.ts b/app/lib/auth/custom-elements.d.ts new file mode 100644 index 0000000..fb41787 --- /dev/null +++ b/app/lib/auth/custom-elements.d.ts @@ -0,0 +1,18 @@ +import type { DetailedHTMLProps, HTMLAttributes } from "react"; +import type { + AuthorizationCodeFlow, + IdpPicker, + WebIdPicker, +} from "@solid/reactive-authentication"; + +type CustomElement = DetailedHTMLProps, T>; + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "authorization-code-flow": CustomElement; + "idp-picker": CustomElement; + "webid-picker": CustomElement; + } + } +} From 064bf44dc48012fdc09a6ae1488a07368a96399f Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 12:04:38 +0100 Subject: [PATCH 09/38] chore: move elements.ts to lib folder --- app/lib/auth/elements.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 app/lib/auth/elements.ts diff --git a/app/lib/auth/elements.ts b/app/lib/auth/elements.ts new file mode 100644 index 0000000..5ee1911 --- /dev/null +++ b/app/lib/auth/elements.ts @@ -0,0 +1,33 @@ +let registration: Promise | null = null; + +/** + * Defines the reactive-authentication custom elements + * (``, ``, ``) exactly once. + * + * The import is dynamic because the module declares classes that extend + * `HTMLElement` at module scope, which throws during server rendering. Call this + * from an effect — never at module scope or during render. + * + * @returns A promise that resolves once the elements are defined. Concurrent + * callers, including React's double-mount in development, share one registration. + */ +export function ensureElementsRegistered(): Promise { + if (typeof window === "undefined") { + throw new Error( + "ensureElementsRegistered() must be called in the browser: the element module extends HTMLElement at module scope.", + ); + } + + if (registration === null) { + registration = import( + "@solid/reactive-authentication/registerElements" + ).then(() => undefined); + + // Never cache a failure — a chunk load error should stay retryable. + registration.catch(() => { + registration = null; + }); + } + + return registration; +} From 7d12b5ff69bdc1727b19f8dd45409202c9a213c0 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 12:21:06 +0100 Subject: [PATCH 10/38] chore: simplify element code --- app/lib/auth/elements.ts | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/app/lib/auth/elements.ts b/app/lib/auth/elements.ts index 5ee1911..408afb8 100644 --- a/app/lib/auth/elements.ts +++ b/app/lib/auth/elements.ts @@ -1,33 +1,17 @@ let registration: Promise | null = null; -/** - * Defines the reactive-authentication custom elements - * (``, ``, ``) exactly once. - * - * The import is dynamic because the module declares classes that extend - * `HTMLElement` at module scope, which throws during server rendering. Call this - * from an effect — never at module scope or during render. - * - * @returns A promise that resolves once the elements are defined. Concurrent - * callers, including React's double-mount in development, share one registration. - */ +// Dynamic import: the module runs customElements.define() on classes extending +// HTMLElement as soon as it loads, which throws during server rendering. Call +// this from an effect. export function ensureElementsRegistered(): Promise { if (typeof window === "undefined") { throw new Error( - "ensureElementsRegistered() must be called in the browser: the element module extends HTMLElement at module scope.", + "ensureElementsRegistered() must run in the browser: the element module extends HTMLElement at import time.", ); } - if (registration === null) { - registration = import( - "@solid/reactive-authentication/registerElements" - ).then(() => undefined); - - // Never cache a failure — a chunk load error should stay retryable. - registration.catch(() => { - registration = null; - }); - } - + registration ??= import("@solid/reactive-authentication/registerElements").then( + () => undefined, + ); return registration; } From 2b95e7892072013a0088f2eb0f4e74eea6af5cdd Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 12:21:31 +0100 Subject: [PATCH 11/38] feat: add reactive-auth manager --- app/lib/auth/manager.ts | 100 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 app/lib/auth/manager.ts diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts new file mode 100644 index 0000000..77bffbd --- /dev/null +++ b/app/lib/auth/manager.ts @@ -0,0 +1,100 @@ +import type { + GetCodeCallback, + GetIssuerCallback, + TokenProvider, +} from "@solid/reactive-authentication"; + +export interface AuthManagerOptions { + callbackUri: string; + getCode: GetCodeCallback; + getIssuer: GetIssuerCallback; +} + +let authFetch: typeof globalThis.fetch | null = null; +let initialization: Promise | null = null; + +// DPoPTokenProvider.matches() accepts every 401, from any host. This app fetches +// URLs it does not control — previews, shared resources, foreign WebIDs — so +// credentials go only to origins named here. +const allowedOrigins = new Set(); + +export function allowOrigin(url: string): void { + let origin: string; + try { + origin = new URL(url).origin; + } catch { + throw new Error(`allowOrigin() needs an absolute URL, received "${url}"`); + } + allowedOrigins.add(origin); +} + +function isAllowed(url: string): boolean { + try { + return allowedOrigins.has(new URL(url).origin); + } catch { + return false; + } +} + +function originGated(inner: TokenProvider): TokenProvider { + return { + async matches(request) { + return isAllowed(request.url) && inner.matches(request); + }, + upgrade: (request) => inner.upgrade(request), + }; +} + +export function initAuthManager( + options: AuthManagerOptions, +): Promise { + if (typeof window === "undefined") { + throw new Error( + "initAuthManager() must run in the browser: the package defines custom elements as soon as it loads.", + ); + } + + initialization ??= createFetch(options); + return initialization; +} + +async function createFetch(options: AuthManagerOptions) { + // Kept out of the top-level imports: the package declares classes extending + // HTMLElement at import time, which throws during server rendering. + const { DPoPTokenProvider, ReactiveFetchManager } = await import( + "@solid/reactive-authentication" + ); + + const manager = new ReactiveFetchManager([ + originGated( + new DPoPTokenProvider( + options.callbackUri, + options.getCode, + options.getIssuer, + ), + ), + ]); + + // manager.fetch returns a newly bound function on every read, which React + // would treat as a changed dependency. + authFetch = manager.fetch; + return authFetch; +} + +export function getAuthFetch(): typeof globalThis.fetch { + if (authFetch === null) { + throw new Error( + "getAuthFetch() called before initAuthManager() resolved. Render behind the auth provider.", + ); + } + return authFetch; +} + +// The library keeps its session in a private field on the provider with no way +// to clear it, so logging out means dropping the provider. +// TODO: replace with the provider's own teardown if the library grows one. +export function resetAuthManager(): void { + authFetch = null; + initialization = null; + allowedOrigins.clear(); +} From cdc71d66278b7c07b950789592bf3d992040ab1a Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 12:21:52 +0100 Subject: [PATCH 12/38] chore: update comment --- app/lib/auth/manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts index 77bffbd..c60538d 100644 --- a/app/lib/auth/manager.ts +++ b/app/lib/auth/manager.ts @@ -14,7 +14,7 @@ let authFetch: typeof globalThis.fetch | null = null; let initialization: Promise | null = null; // DPoPTokenProvider.matches() accepts every 401, from any host. This app fetches -// URLs it does not control — previews, shared resources, foreign WebIDs — so +// URLs it does not control (previews, shared resources, foreign WebIDs) so // credentials go only to origins named here. const allowedOrigins = new Set(); @@ -59,7 +59,7 @@ export function initAuthManager( } async function createFetch(options: AuthManagerOptions) { - // Kept out of the top-level imports: the package declares classes extending + // Kept out of the top-level imports because the package declares classes extending // HTMLElement at import time, which throws during server rendering. const { DPoPTokenProvider, ReactiveFetchManager } = await import( "@solid/reactive-authentication" From bcb0e451926d5c6eaa344e2066a87bd19891b40b Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 12:25:29 +0100 Subject: [PATCH 13/38] chore: point spike harness at the shared auth core --- app/spike/page.tsx | 90 ++++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/app/spike/page.tsx b/app/spike/page.tsx index fb7bc4b..f3056e3 100644 --- a/app/spike/page.tsx +++ b/app/spike/page.tsx @@ -1,10 +1,14 @@ "use client"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import type { - AuthorizationCodeFlow, - ReactiveFetchManager as Manager, -} from "@solid/reactive-authentication"; +import type { AuthorizationCodeFlow } from "@solid/reactive-authentication"; +import { ensureElementsRegistered } from "../lib/auth/elements"; +import { + allowOrigin, + getAuthFetch, + initAuthManager, + resetAuthManager, +} from "../lib/auth/manager"; import { getLog, getSummary, @@ -38,7 +42,6 @@ const EMPTY_SUMMARY: Summary = { export default function SpikePage() { const codeUiRef = useRef(null); - const managerRef = useRef(null); const issuerRef = useRef(DEFAULT_ISSUER); const [resourceUrl, setResourceUrl] = useState(DEFAULT_RESOURCE); @@ -55,34 +58,31 @@ export default function SpikePage() { issuerRef.current = issuer; }, [issuer]); - useEffect(() => { - let cancelled = false; - - async function setup() { - // Must run before the manager is constructed: ReactiveFetchManager - // captures globalThis.fetch once, in its constructor. - installInstrument(); - - // Both imports are dynamic because the package defines classes that - // extend HTMLElement at module scope, which throws under SSR. - await import("@solid/reactive-authentication/registerElements"); - const { DPoPTokenProvider, ReactiveFetchManager } = await import( - "@solid/reactive-authentication" - ); - if (cancelled || managerRef.current) return; - - const provider = new DPoPTokenProvider( - `${window.location.origin}/callback.html`, + const buildManager = useCallback( + () => + initAuthManager({ + callbackUri: `${window.location.origin}/callback.html`, // Read the method lazily — the custom element may not have upgraded yet. - (authorizationUri, signal) => { + getCode: (authorizationUri, signal) => { const ui = codeUiRef.current; if (!ui) throw new Error("authorization-code-flow is not mounted"); return ui.getCode(authorizationUri, signal); }, - async () => new URL(issuerRef.current), - ); + getIssuer: async () => new URL(issuerRef.current), + }), + [], + ); + + useEffect(() => { + let cancelled = false; - managerRef.current = new ReactiveFetchManager([provider]); + async function setup() { + // ReactiveFetchManager captures globalThis.fetch in its constructor, so + // the instrument has to be installed first. + installInstrument(); + await ensureElementsRegistered(); + await buildManager(); + if (cancelled) return; setReady(true); } @@ -90,16 +90,18 @@ export default function SpikePage() { return () => { cancelled = true; }; - }, []); - - const fetchResource = useCallback(async () => { - const manager = managerRef.current; - if (!manager) throw new Error("manager is not ready"); - // no-store keeps the HTTP cache from hiding a 401 -> auth -> 200 cycle. - const response = await manager.fetch(resourceUrl, { cache: "no-store" }); - await response.arrayBuffer(); - return response.status; - }, [resourceUrl]); + }, [buildManager]); + + const fetchResource = useCallback( + async (vouch = true) => { + if (vouch) allowOrigin(resourceUrl); + // no-store keeps the HTTP cache from hiding a 401 -> auth -> 200 cycle. + const response = await getAuthFetch()(resourceUrl, { cache: "no-store" }); + await response.arrayBuffer(); + return response.status; + }, + [resourceUrl], + ); const run = useCallback(async (label: string, experiment: () => Promise) => { setRunning(true); @@ -148,6 +150,18 @@ export default function SpikePage() { await fetchResource(); }, }, + { + key: "F", + label: "F. origin gate (no vouch)", + go: async () => { + resetAuthManager(); + await buildManager(); + const status = await fetchResource(false); + if (status !== 401) { + throw new Error(`expected 401 for an unvouched origin, got ${status}`); + } + }, + }, ]; return ( @@ -238,4 +252,4 @@ function Stat({ label, value, warn }: { label: string; value: number; warn?: boo
{value}
); -} \ No newline at end of file +} From b38e22ce3ec26ff4ed8503a469adfd9273e3a2b5 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 13:50:39 +0100 Subject: [PATCH 14/38] feat: add SolidAuthProvider --- .../providers/SolidAuthProvider.tsx | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 app/components/providers/SolidAuthProvider.tsx diff --git a/app/components/providers/SolidAuthProvider.tsx b/app/components/providers/SolidAuthProvider.tsx new file mode 100644 index 0000000..1702e47 --- /dev/null +++ b/app/components/providers/SolidAuthProvider.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { AuthorizationCodeFlow } from "@solid/reactive-authentication"; +import { ensureElementsRegistered } from "@/app/lib/auth/elements"; +import { + allowOrigin, + initAuthManager, + resetAuthManager, +} from "@/app/lib/auth/manager"; + +const STORAGE_KEY = "solid-file-manager-session"; + +export interface SolidCredentials { + webId: string; + issuer: string; +} + +export type SolidAuthStatus = "restoring" | "anonymous" | "authenticated"; + +export interface SolidAuthContextValue { + session: { + isLoggedIn: boolean; + webId: string | null; + issuer: string | null; + }; + status: SolidAuthStatus; + login: (credentials: SolidCredentials) => void; + logout: () => Promise; +} + +export const SolidAuthContext = createContext(null); + +export default function SolidAuthProvider({ children }: { children: ReactNode }) { + const codeUiRef = useRef(null); + const issuerRef = useRef(null); + const generationRef = useRef(0); + + const [credentials, setCredentials] = useState(null); + const [status, setStatus] = useState("restoring"); + + const buildManager = useCallback( + () => + initAuthManager({ + callbackUri: `${window.location.origin}/callback.html`, + // Read the method lazily: the custom element may not have upgraded yet. + getCode: (authorizationUri, signal) => { + const ui = codeUiRef.current; + if (!ui) throw new Error("authorization-code-flow is not mounted"); + return ui.getCode(authorizationUri, signal); + }, + getIssuer: async () => { + const issuer = issuerRef.current; + if (!issuer) throw new Error("No issuer known: sign in first"); + return new URL(issuer); + }, + }), + [], + ); + + useEffect(() => { + const generation = ++generationRef.current; + + async function restore() { + await ensureElementsRegistered(); + await buildManager(); + if (generationRef.current !== generation) return; + + // Only the identity is persisted. Tokens live in the token provider + // and die with the page, so the next protected request re-runs the + // flow, silently while the issuer's cookie lasts. + const stored = readStoredCredentials(); + if (stored) { + allowOrigin(stored.webId); + issuerRef.current = stored.issuer; + setCredentials(stored); + } + setStatus(stored ? "authenticated" : "anonymous"); + } + + restore(); + }, [buildManager]); + + const login = useCallback((next: SolidCredentials) => { + generationRef.current++; + allowOrigin(next.webId); + issuerRef.current = next.issuer; + writeStoredCredentials(next); + setCredentials(next); + setStatus("authenticated"); + }, []); + + const logout = useCallback(async () => { + const generation = ++generationRef.current; + clearStoredCredentials(); + issuerRef.current = null; + setCredentials(null); + setStatus("restoring"); + + resetAuthManager(); + await buildManager(); + if (generationRef.current !== generation) return; + setStatus("anonymous"); + }, [buildManager]); + + const value = useMemo( + () => ({ + session: { + isLoggedIn: status === "authenticated", + webId: credentials?.webId ?? null, + issuer: credentials?.issuer ?? null, + }, + status, + login, + logout, + }), + [credentials, status, login, logout], + ); + + return ( + + {children} + {/* Hosts the login popup and its blocked-popup dialogs. */} + + + ); +} + +function isCredentials(value: unknown): value is SolidCredentials { + return ( + typeof value === "object" && + value !== null && + typeof (value as SolidCredentials).webId === "string" && + typeof (value as SolidCredentials).issuer === "string" + ); +} + +function readStoredCredentials(): SolidCredentials | null { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); + return isCredentials(parsed) ? parsed : null; + } catch (error) { + console.warn("Discarding an unreadable stored session", error); + return null; + } +} + +function writeStoredCredentials(credentials: SolidCredentials): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials)); + } catch (error) { + // The session still works; it just won't survive a reload. + console.warn("Could not persist the session", error); + } +} + +function clearStoredCredentials(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + // The identity stays on disk until something else clears it. + console.warn("Could not clear the stored session", error); + } +} From b639992fff1db7517cec587196fb5c18337ee253 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 13:52:15 +0100 Subject: [PATCH 15/38] feat: add useSolidAuth hook --- app/lib/hooks/index.ts | 1 + app/lib/hooks/useSolidAuth.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 app/lib/hooks/useSolidAuth.ts diff --git a/app/lib/hooks/index.ts b/app/lib/hooks/index.ts index 77dddc2..648cbf9 100644 --- a/app/lib/hooks/index.ts +++ b/app/lib/hooks/index.ts @@ -9,6 +9,7 @@ * ``` */ +export { useSolidAuth } from "./useSolidAuth"; export { useSolidStorages } from "./useSolidStorages"; export type { SolidStorage } from "./useSolidStorages"; export { useUserProfile } from "./useUserProfile"; diff --git a/app/lib/hooks/useSolidAuth.ts b/app/lib/hooks/useSolidAuth.ts new file mode 100644 index 0000000..6135f31 --- /dev/null +++ b/app/lib/hooks/useSolidAuth.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useContext } from "react"; +import { + SolidAuthContext, + type SolidAuthContextValue, +} from "@/app/components/providers/SolidAuthProvider"; + +export function useSolidAuth(): SolidAuthContextValue { + const value = useContext(SolidAuthContext); + if (value === null) { + throw new Error("useSolidAuth() must be called inside "); + } + return value; +} From 948c0c9a9bb5a875a4280ac08223709db4d033be Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 13:54:12 +0100 Subject: [PATCH 16/38] feat: mount SolidAuthProvider in the root layout --- app/layout.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index f4b86b1..763108f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Toaster from "./components/shared/Toaster"; import LdoProvider from "./components/providers/LdoProvider"; +import SolidAuthProvider from "./components/providers/SolidAuthProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -34,10 +35,12 @@ export default function RootLayout({ - - {children} - - + + + {children} + + + ); From f4fbbb042e296dfaae1b80ba0fc9c00ddfe15b58 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 13:57:32 +0100 Subject: [PATCH 17/38] chore: exercise SolidAuthProvider from the spike harness --- app/spike/page.tsx | 158 +++++++++++++++++++++------------------------ 1 file changed, 74 insertions(+), 84 deletions(-) diff --git a/app/spike/page.tsx b/app/spike/page.tsx index f3056e3..7e834cd 100644 --- a/app/spike/page.tsx +++ b/app/spike/page.tsx @@ -1,14 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import type { AuthorizationCodeFlow } from "@solid/reactive-authentication"; -import { ensureElementsRegistered } from "../lib/auth/elements"; -import { - allowOrigin, - getAuthFetch, - initAuthManager, - resetAuthManager, -} from "../lib/auth/manager"; +import { useCallback, useState, useSyncExternalStore } from "react"; +import { allowOrigin, getAuthFetch } from "../lib/auth/manager"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; import { getLog, getSummary, @@ -19,8 +13,14 @@ import { type Summary, } from "./instruments"; -const DEFAULT_RESOURCE = "https://storage.inrupt.com/0260f9c0-5d12-446e-abe2-7ab651343d77/"; +// Runs at import time so the manager, built in SolidAuthProvider's effect, +// captures the instrumented fetch. Load /spike directly rather than navigating +// to it from another route. +installInstrument(); + +const DEFAULT_WEB_ID = "https://id.inrupt.com/preciousoritsedere"; const DEFAULT_ISSUER = "https://login.inrupt.com"; +const DEFAULT_RESOURCE = "https://storage.inrupt.com/0260f9c0-5d12-446e-abe2-7ab651343d77/"; /** Kept deliberately small — each 401 may register a new client on a real server. */ const BATCH = 5; @@ -41,57 +41,17 @@ const EMPTY_SUMMARY: Summary = { }; export default function SpikePage() { - const codeUiRef = useRef(null); - const issuerRef = useRef(DEFAULT_ISSUER); + const { session, status, login, logout } = useSolidAuth(); - const [resourceUrl, setResourceUrl] = useState(DEFAULT_RESOURCE); + const [webId, setWebId] = useState(DEFAULT_WEB_ID); const [issuer, setIssuer] = useState(DEFAULT_ISSUER); - const [ready, setReady] = useState(false); - const [status, setStatus] = useState(""); + const [resourceUrl, setResourceUrl] = useState(DEFAULT_RESOURCE); + const [result, setResult] = useState(""); const [running, setRunning] = useState(false); const log = useSyncExternalStore(subscribe, getLog, () => EMPTY_LOG); const summary = useSyncExternalStore(subscribe, getSummary, () => EMPTY_SUMMARY); - // getIssuer closes over this ref so it always sees the current input value. - useEffect(() => { - issuerRef.current = issuer; - }, [issuer]); - - const buildManager = useCallback( - () => - initAuthManager({ - callbackUri: `${window.location.origin}/callback.html`, - // Read the method lazily — the custom element may not have upgraded yet. - getCode: (authorizationUri, signal) => { - const ui = codeUiRef.current; - if (!ui) throw new Error("authorization-code-flow is not mounted"); - return ui.getCode(authorizationUri, signal); - }, - getIssuer: async () => new URL(issuerRef.current), - }), - [], - ); - - useEffect(() => { - let cancelled = false; - - async function setup() { - // ReactiveFetchManager captures globalThis.fetch in its constructor, so - // the instrument has to be installed first. - installInstrument(); - await ensureElementsRegistered(); - await buildManager(); - if (cancelled) return; - setReady(true); - } - - setup(); - return () => { - cancelled = true; - }; - }, [buildManager]); - const fetchResource = useCallback( async (vouch = true) => { if (vouch) allowOrigin(resourceUrl); @@ -106,13 +66,13 @@ export default function SpikePage() { const run = useCallback(async (label: string, experiment: () => Promise) => { setRunning(true); resetLog(); - setStatus(`running ${label}…`); + setResult(`running ${label}…`); const started = performance.now(); try { await experiment(); - setStatus(`${label} — done in ${Math.round(performance.now() - started)}ms`); + setResult(`${label} — done in ${Math.round(performance.now() - started)}ms`); } catch (error) { - setStatus(`${label} — failed: ${error instanceof Error ? error.message : String(error)}`); + setResult(`${label} — failed: ${error instanceof Error ? error.message : String(error)}`); } finally { setRunning(false); } @@ -121,7 +81,7 @@ export default function SpikePage() { const experiments: { key: string; label: string; go: () => Promise }[] = [ { key: "A", - label: `A. fetch once`, + label: "A. fetch once", go: async () => { await fetchResource(); }, @@ -152,13 +112,16 @@ export default function SpikePage() { }, { key: "F", + // Signing in vouches for the WebID's origin only. On providers where + // the pod lives elsewhere, the pod stays unvouched until something + // calls allowOrigin for it. label: "F. origin gate (no vouch)", go: async () => { - resetAuthManager(); - await buildManager(); - const status = await fetchResource(false); - if (status !== 401) { - throw new Error(`expected 401 for an unvouched origin, got ${status}`); + await logout(); + login({ webId, issuer }); + const httpStatus = await fetchResource(false); + if (httpStatus !== 401) { + throw new Error(`expected 401 for an unvouched origin, got ${httpStatus}`); } }, }, @@ -168,23 +131,31 @@ export default function SpikePage() {

reactive-authentication spike

- - - + + + + +
+ + + + {status} — {session.webId ?? "no identity"} + +
{experiments.map((experiment) => ( @@ -192,7 +163,7 @@ export default function SpikePage() { key={experiment.key} type="button" className="border px-3 py-1 disabled:opacity-40" - disabled={!ready || running} + disabled={running || !session.isLoggedIn} onClick={() => run(experiment.label, experiment.go)} > {experiment.label} @@ -203,7 +174,7 @@ export default function SpikePage() {
-

{ready ? status : "loading…"}

+

{result}

@@ -239,12 +210,31 @@ export default function SpikePage() { ))} - -
); } +function Field({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + function Stat({ label, value, warn }: { label: string; value: number; warn?: boolean }) { return (
From 19b88fade22c5adda8f79ab55d00d43f4e0a8fdb Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 15:07:18 +0100 Subject: [PATCH 18/38] refactor: hold the current session in the auth manager --- .../providers/SolidAuthProvider.tsx | 35 +++++--------- app/lib/auth/manager.ts | 47 ++++++++++++++----- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/app/components/providers/SolidAuthProvider.tsx b/app/components/providers/SolidAuthProvider.tsx index 1702e47..0e05f42 100644 --- a/app/components/providers/SolidAuthProvider.tsx +++ b/app/components/providers/SolidAuthProvider.tsx @@ -7,23 +7,22 @@ import { useMemo, useRef, useState, + useSyncExternalStore, type ReactNode, } from "react"; import type { AuthorizationCodeFlow } from "@solid/reactive-authentication"; import { ensureElementsRegistered } from "@/app/lib/auth/elements"; import { - allowOrigin, + getCurrentSession, initAuthManager, resetAuthManager, + setCurrentSession, + subscribeToSession, + type SolidCredentials, } from "@/app/lib/auth/manager"; const STORAGE_KEY = "solid-file-manager-session"; -export interface SolidCredentials { - webId: string; - issuer: string; -} - export type SolidAuthStatus = "restoring" | "anonymous" | "authenticated"; export interface SolidAuthContextValue { @@ -41,10 +40,13 @@ export const SolidAuthContext = createContext(null export default function SolidAuthProvider({ children }: { children: ReactNode }) { const codeUiRef = useRef(null); - const issuerRef = useRef(null); const generationRef = useRef(0); - const [credentials, setCredentials] = useState(null); + const credentials = useSyncExternalStore( + subscribeToSession, + getCurrentSession, + () => null, + ); const [status, setStatus] = useState("restoring"); const buildManager = useCallback( @@ -57,11 +59,6 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) if (!ui) throw new Error("authorization-code-flow is not mounted"); return ui.getCode(authorizationUri, signal); }, - getIssuer: async () => { - const issuer = issuerRef.current; - if (!issuer) throw new Error("No issuer known: sign in first"); - return new URL(issuer); - }, }), [], ); @@ -78,11 +75,7 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) // and die with the page, so the next protected request re-runs the // flow, silently while the issuer's cookie lasts. const stored = readStoredCredentials(); - if (stored) { - allowOrigin(stored.webId); - issuerRef.current = stored.issuer; - setCredentials(stored); - } + if (stored) setCurrentSession(stored); setStatus(stored ? "authenticated" : "anonymous"); } @@ -91,18 +84,14 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) const login = useCallback((next: SolidCredentials) => { generationRef.current++; - allowOrigin(next.webId); - issuerRef.current = next.issuer; + setCurrentSession(next); writeStoredCredentials(next); - setCredentials(next); setStatus("authenticated"); }, []); const logout = useCallback(async () => { const generation = ++generationRef.current; clearStoredCredentials(); - issuerRef.current = null; - setCredentials(null); setStatus("restoring"); resetAuthManager(); diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts index c60538d..961840c 100644 --- a/app/lib/auth/manager.ts +++ b/app/lib/auth/manager.ts @@ -1,18 +1,24 @@ -import type { - GetCodeCallback, - GetIssuerCallback, - TokenProvider, -} from "@solid/reactive-authentication"; +import type { GetCodeCallback, TokenProvider } from "@solid/reactive-authentication"; + +export interface SolidCredentials { + webId: string; + issuer: string; +} export interface AuthManagerOptions { callbackUri: string; getCode: GetCodeCallback; - getIssuer: GetIssuerCallback; } let authFetch: typeof globalThis.fetch | null = null; let initialization: Promise | null = null; +// Who we are currently authenticating as. Held here rather than in React so +// that plain modules — the session facade, the profile and contact helpers — +// read the same value the UI renders. +let currentSession: SolidCredentials | null = null; +const sessionListeners = new Set<() => void>(); + // DPoPTokenProvider.matches() accepts every 401, from any host. This app fetches // URLs it does not control (previews, shared resources, foreign WebIDs) so // credentials go only to origins named here. @@ -45,6 +51,24 @@ function originGated(inner: TokenProvider): TokenProvider { }; } +export function getCurrentSession(): SolidCredentials | null { + return currentSession; +} + +export function setCurrentSession(next: SolidCredentials | null): void { + currentSession = next; + // Reading the profile is the first thing anything does with an identity. + if (next) allowOrigin(next.webId); + for (const listener of sessionListeners) listener(); +} + +export function subscribeToSession(listener: () => void): () => void { + sessionListeners.add(listener); + return () => { + sessionListeners.delete(listener); + }; +} + export function initAuthManager( options: AuthManagerOptions, ): Promise { @@ -67,11 +91,11 @@ async function createFetch(options: AuthManagerOptions) { const manager = new ReactiveFetchManager([ originGated( - new DPoPTokenProvider( - options.callbackUri, - options.getCode, - options.getIssuer, - ), + new DPoPTokenProvider(options.callbackUri, options.getCode, async () => { + const session = getCurrentSession(); + if (!session) throw new Error("No issuer known: sign in first"); + return new URL(session.issuer); + }), ), ]); @@ -97,4 +121,5 @@ export function resetAuthManager(): void { authFetch = null; initialization = null; allowedOrigins.clear(); + setCurrentSession(null); } From 3c608fe6c0dc0435ab128715ff3ee37c21f61cce Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 15:07:32 +0100 Subject: [PATCH 19/38] feat: expose oidcIssuer on the profile agent --- app/lib/class/Agent.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/lib/class/Agent.ts b/app/lib/class/Agent.ts index 6c0ba19..39dcb9f 100644 --- a/app/lib/class/Agent.ts +++ b/app/lib/class/Agent.ts @@ -1,6 +1,7 @@ import { LiteralAs, NamedNodeAs, NamedNodeFrom, OptionalFrom, SetFrom, TermAs, TermWrapper } from "@rdfjs/wrapper" import { FOAF, PIM, SOLID, VCARD } from "@/app/lib/class/Vocabulary" +// TODO: Migrate this to the objects library export class Agent extends TermWrapper { get vcardFn(): string | undefined { return OptionalFrom.subjectPredicate(this, VCARD.fn, LiteralAs.string) @@ -63,6 +64,11 @@ export class Agent extends TermWrapper { return SetFrom.subjectPredicate(this, SOLID.storage, NamedNodeAs.string, NamedNodeFrom.string) } + // A profile may name several; the login page asks which one to use. + get oidcIssuers(): Set { + return SetFrom.subjectPredicate(this, SOLID.oidcIssuer, NamedNodeAs.string, NamedNodeFrom.string) + } + get email(): string | null { return this.hasEmail?.actualValue ?? null } From e32b1e0be890dbac9015bcca2c03f381e8676289 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 15:21:05 +0100 Subject: [PATCH 20/38] feat: identify a WebID or login server from one input --- app/lib/auth/discovery.ts | 57 +++++++++++++++++++++++++++++ app/lib/auth/identify.ts | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 app/lib/auth/discovery.ts create mode 100644 app/lib/auth/identify.ts diff --git a/app/lib/auth/discovery.ts b/app/lib/auth/discovery.ts new file mode 100644 index 0000000..75e0602 --- /dev/null +++ b/app/lib/auth/discovery.ts @@ -0,0 +1,57 @@ +export interface DiscoveryDocument { + issuer: string; + end_session_endpoint?: string; +} + +const TIMEOUT_MS = 8000; + +const cache = new Map(); + +/** + * Reads an authorization server's OIDC metadata. Returns null when the URL is + * not an issuer, the login page uses it to tell an issuer apart from a WebID. + */ +export async function fetchDiscovery( + issuer: string, +): Promise { + const url = discoveryUrl(issuer); + if (!url) return null; + + const cached = cache.get(url); + if (cached) return cached; + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + if (!response.ok) return null; + + const document: unknown = await response.json(); + if (!isDiscoveryDocument(document)) return null; + + cache.set(url, document); + return document; + } catch { + // Unreachable, blocked by CORS, timed out, or not JSON. For the caller + // these all mean the same thing, so only successes are worth caching. + return null; + } +} + +function discoveryUrl(issuer: string): string | null { + try { + const url = new URL(issuer); + url.pathname = `${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`; + url.search = ""; + url.hash = ""; + return url.href; + } catch { + return null; + } +} + +function isDiscoveryDocument(value: unknown): value is DiscoveryDocument { + return ( + typeof value === "object" && + value !== null && + typeof (value as DiscoveryDocument).issuer === "string" + ); +} diff --git a/app/lib/auth/identify.ts b/app/lib/auth/identify.ts new file mode 100644 index 0000000..d14866b --- /dev/null +++ b/app/lib/auth/identify.ts @@ -0,0 +1,75 @@ +import { DataFactory, Parser, Store } from "n3"; +import { WebIdDataset } from "@/app/lib/class/WebIdDataset"; +import { fetchDiscovery } from "./discovery"; + +const TIMEOUT_MS = 8000; + +export type LoginTarget = + | { kind: "webid"; webId: string; issuers: string[] } + | { kind: "issuer"; issuer: string } + | { kind: "unknown" }; + +/** + * Works out whether the user typed a WebID or a login server, by asking both + * questions at once rather than guessing from the URL's shape + */ +export async function identify(input: string): Promise { + const url = withScheme(input.trim()); + if (!url) return { kind: "unknown" }; + + const [issuers, discovery] = await Promise.all([ + probeProfile(url), + fetchDiscovery(url), + ]); + + // Order matters. A CSS pod root parses as Turtle with no issuer in it, so + // discovery has to win over an empty profile, or the root would be mistaken + // for a WebID. + if (issuers?.length) return { kind: "webid", webId: url, issuers }; + if (discovery) return { kind: "issuer", issuer: discovery.issuer }; + if (issuers) return { kind: "webid", webId: url, issuers: [] }; + return { kind: "unknown" }; +} + +function withScheme(input: string): string | null { + if (!input) return null; + const candidate = /^https?:\/\//i.test(input) ? input : `https://${input}`; + try { + return new URL(candidate).href; + } catch { + return null; + } +} + +/** + * Returns the issuers named by a WebID profile, an empty array when the + * document is a profile we cannot read the issuers from, or null when it is not + * a profile at all. + */ +async function probeProfile(webId: string): Promise { + let response: Response; + try { + response = await fetch(webId, { + headers: { accept: "text/turtle" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch { + return null; + } + + // A profile we are not allowed to read is still a profile. The login page + // asks for the issuer instead of giving up. + if (response.status === 401 || response.status === 403) return []; + if (!response.ok) return null; + + try { + const store = new Store(); + const parser = new Parser({ baseIRI: response.url || webId }); + store.addQuads(parser.parse(await response.text())); + + const agent = new WebIdDataset(store, DataFactory).mainSubject; + return agent ? [...agent.oidcIssuers] : []; + } catch { + return null; + } +} From 5973074babd24c9031244c37f25704bde950f284 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 15:50:36 +0100 Subject: [PATCH 21/38] feat: rebuild the login page on WebID entry --- app/components/IssuerPickerDialog.tsx | 53 +++++ app/components/LoginPage.tsx | 196 +++++++++++++++--- app/components/SignedOutNotice.tsx | 58 ++++++ .../providers/SolidAuthProvider.tsx | 11 +- app/lib/helpers/idpHistoryUtils.ts | 29 --- app/lib/helpers/loginHistoryUtils.ts | 23 ++ 6 files changed, 311 insertions(+), 59 deletions(-) create mode 100644 app/components/IssuerPickerDialog.tsx create mode 100644 app/components/SignedOutNotice.tsx delete mode 100644 app/lib/helpers/idpHistoryUtils.ts create mode 100644 app/lib/helpers/loginHistoryUtils.ts diff --git a/app/components/IssuerPickerDialog.tsx b/app/components/IssuerPickerDialog.tsx new file mode 100644 index 0000000..fc85dbe --- /dev/null +++ b/app/components/IssuerPickerDialog.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Button from "./shared/Button"; +import Modal from "./shared/Modal"; + +interface IssuerPickerDialogProps { + isOpen: boolean; + issuers: string[]; + onSelect: (issuer: string) => void; + onClose: () => void; +} + +export default function IssuerPickerDialog({ + isOpen, + issuers, + onSelect, + onClose, +}: IssuerPickerDialogProps) { + const firstOptionRef = useRef(null); + + // The dialog opens without a click of its own, so focus has to be moved in. + useEffect(() => { + if (isOpen) firstOptionRef.current?.focus(); + }, [isOpen]); + + return ( + +

+ That WebID lists more than one. Pick the one to sign in with. +

+
    + {issuers.map((issuer, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index 0b73684..398b8ba 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -1,44 +1,182 @@ "use client"; -import { useEffect, useState } from "react"; + +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; -import { SolidLoginPage } from "solid-react-component/login/next"; -import { getLastIdp, saveLastIdp } from "../lib/helpers/idpHistoryUtils"; +import { identify } from "../lib/auth/identify"; +import { + getLastLoginEntry, + saveLastLoginEntry, +} from "../lib/helpers/loginHistoryUtils"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; +import IssuerPickerDialog from "./IssuerPickerDialog"; +import SignedOutNotice from "./SignedOutNotice"; +import Button from "./shared/Button"; +import GitHubLinks from "./shared/GitHubLinks"; +import UrlCombobox, { type ComboboxOption } from "./shared/UrlCombobox"; + +// Suggestions only — anything the user types is accepted, so this list is here +// for convenience and can grow or go without affecting the flow. +const KNOWN_ISSUERS: ComboboxOption[] = [ + { label: "Inrupt PodSpaces", value: "https://login.inrupt.com" }, + { label: "solidcommunity.net", value: "https://solidcommunity.net" }, +]; + +const LABELS = { + either: "WebID (or OIDC issuer)", + issuer: "Your login server", + webid: "Your WebID", +} as const; +const HELPER_TEXT = { + either: "For example https://id.inrupt.com/you", + issuer: "That WebID does not name a login server, so please enter one.", + webid: "We have your login server. Now enter the WebID it signs you in as.", +} as const; export default function LoginPage() { const router = useRouter(); - const [mounted, setMounted] = useState(false); - const [defaultIssuer, setDefaultIssuer] = useState(""); + const { login, signedOutFrom } = useSolidAuth(); + + const [entry, setEntry] = useState(""); + const [remembered, setRemembered] = useState(""); + const [webId, setWebId] = useState(null); + const [issuer, setIssuer] = useState(null); + const [choices, setChoices] = useState([]); + const [error, setError] = useState(null); + const [isResolving, setIsResolving] = useState(false); useEffect(() => { - setDefaultIssuer(getLastIdp()); - setMounted(true); + const last = getLastLoginEntry(); + setEntry(last); + setRemembered(last); }, []); - const handleSubmit = (event: React.FormEvent) => { - const input = event.currentTarget.querySelector("input"); - if (input instanceof HTMLInputElement) { - saveLastIdp(input.value); + const options = useMemo( + () => + remembered + ? [{ label: remembered, value: remembered, secondaryLabel: "Last used" }, ...KNOWN_ISSUERS] + : KNOWN_ISSUERS, + [remembered], + ); + + const needs = webId && !issuer ? "issuer" : issuer && !webId ? "webid" : "either"; + + function finish(nextWebId: string | null, nextIssuer: string | null) { + setWebId(nextWebId); + setIssuer(nextIssuer); + + // Whichever half is still missing, the field relabels and asks for it. + if (!nextWebId || !nextIssuer) { + setEntry(""); + return; } - }; - // Render only after localStorage is read, so defaultIssuer is applied on - // SolidLoginPage's initial mount (it reads defaultIssuer only once). - if (!mounted) return null; + saveLastLoginEntry(nextWebId); + login({ webId: nextWebId, issuer: nextIssuer }); + router.replace("/"); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const value = entry.trim(); + if (!value) return; + + setError(null); + setIsResolving(true); + try { + const target = await identify(value); + + if (target.kind === "unknown") { + setError("That is not a WebID or a login server we could reach."); + return; + } + + if (target.kind === "issuer") { + finish(webId, target.issuer); + return; + } + + if (target.issuers.length > 1) { + setWebId(target.webId); + setChoices(target.issuers); + return; + } + + finish(target.webId, target.issuers[0] ?? issuer); + } finally { + setIsResolving(false); + } + } return ( -
- router.replace("/")} - defaultIssuer={defaultIssuer} - logo="/file-manager-logo.svg" - logoAlt="Solid File Manager Logo" - title="Sign in" - subtitle="to continue to Solid File Manager" - footerGitHubUrl="https://github.com/solid/solid-file-manager" - footerIssuesUrl="https://github.com/solid/solid-file-manager/issues/new" - /> -
- ) +
+
+
+ +

Sign in

+

+ to continue to Solid File Manager +

+
-} \ No newline at end of file +
+
+ + {/* A live region: the label and this text change when we + learn half the credentials and ask for the other. */} +

+ {error ? "" : HELPER_TEXT[needs]} +

+
+ + +
+ + {signedOutFrom && } + +
+ +
+
+ + 0} + issuers={choices} + onSelect={(choice) => { + setChoices([]); + finish(webId, choice); + }} + onClose={() => setChoices([])} + /> +
+ ); +} diff --git a/app/components/SignedOutNotice.tsx b/app/components/SignedOutNotice.tsx new file mode 100644 index 0000000..6262690 --- /dev/null +++ b/app/components/SignedOutNotice.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fetchDiscovery } from "../lib/auth/discovery"; + +interface SignedOutNoticeProps { + issuer: string; +} + +/** + * Signing out clears this app's session, but not the cookie at the login server + * that would sign the next person straight back in. We cannot end that session + * for them: the token provider discards the id_token, so there is no + * id_token_hint to send and no registered post-logout redirect to return from. + * So this offers the door rather than walking them through it. + */ +export default function SignedOutNotice({ issuer }: SignedOutNoticeProps) { + const [endSessionUrl, setEndSessionUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchDiscovery(issuer).then((document) => { + if (!cancelled) setEndSessionUrl(document?.end_session_endpoint ?? null); + }); + return () => { + cancelled = true; + }; + }, [issuer]); + + return ( +

+ Signed out. You may still be signed in at{" "} + {hostOf(issuer)} + {endSessionUrl && ( + <> + {" — "} + + sign out there too + (opens in a new tab) + + + )} +

+ ); +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/app/components/providers/SolidAuthProvider.tsx b/app/components/providers/SolidAuthProvider.tsx index 0e05f42..aa2c096 100644 --- a/app/components/providers/SolidAuthProvider.tsx +++ b/app/components/providers/SolidAuthProvider.tsx @@ -32,6 +32,9 @@ export interface SolidAuthContextValue { issuer: string | null; }; status: SolidAuthStatus; + // The issuer of the session just ended, so the login page can offer to sign + // out there too. In memory only: the offer is stale after a reload. + signedOutFrom: string | null; login: (credentials: SolidCredentials) => void; logout: () => Promise; } @@ -48,6 +51,7 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) () => null, ); const [status, setStatus] = useState("restoring"); + const [signedOutFrom, setSignedOutFrom] = useState(null); const buildManager = useCallback( () => @@ -86,17 +90,21 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) generationRef.current++; setCurrentSession(next); writeStoredCredentials(next); + setSignedOutFrom(null); setStatus("authenticated"); }, []); const logout = useCallback(async () => { const generation = ++generationRef.current; + const previousIssuer = getCurrentSession()?.issuer ?? null; + clearStoredCredentials(); setStatus("restoring"); resetAuthManager(); await buildManager(); if (generationRef.current !== generation) return; + setSignedOutFrom(previousIssuer); setStatus("anonymous"); }, [buildManager]); @@ -108,10 +116,11 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) issuer: credentials?.issuer ?? null, }, status, + signedOutFrom, login, logout, }), - [credentials, status, login, logout], + [credentials, status, signedOutFrom, login, logout], ); return ( diff --git a/app/lib/helpers/idpHistoryUtils.ts b/app/lib/helpers/idpHistoryUtils.ts deleted file mode 100644 index e4606de..0000000 --- a/app/lib/helpers/idpHistoryUtils.ts +++ /dev/null @@ -1,29 +0,0 @@ -const LAST_IDP_KEY = "solid-file-manager-last-idp"; - -export function getLastIdp(): string { - if (typeof window === "undefined") return ""; - - try { - return localStorage.getItem(LAST_IDP_KEY)?.trim() ?? ""; - } catch (error) { - console.warn( - "Could not read last identity provider from localStorage", - error, - ); - return ""; - } -} - -export function saveLastIdp(issuer: string): void { - if (typeof window === "undefined") return; - const value = issuer.trim(); - if (!value) return; - try { - localStorage.setItem(LAST_IDP_KEY, value); - } catch (error) { - console.warn( - "Could not save last identity provider to localStorage", - error, - ); - } -} diff --git a/app/lib/helpers/loginHistoryUtils.ts b/app/lib/helpers/loginHistoryUtils.ts new file mode 100644 index 0000000..7f52bea --- /dev/null +++ b/app/lib/helpers/loginHistoryUtils.ts @@ -0,0 +1,23 @@ +const LAST_ENTRY_KEY = "solid-file-manager-last-idp"; + +export function getLastLoginEntry(): string { + if (typeof window === "undefined") return ""; + + try { + return localStorage.getItem(LAST_ENTRY_KEY)?.trim() ?? ""; + } catch (error) { + console.warn("Could not read the last login entry from localStorage", error); + return ""; + } +} + +export function saveLastLoginEntry(entry: string): void { + if (typeof window === "undefined") return; + const value = entry.trim(); + if (!value) return; + try { + localStorage.setItem(LAST_ENTRY_KEY, value); + } catch (error) { + console.warn("Could not save the last login entry to localStorage", error); + } +} From 1f9e497492191a6faf5fa86288d5890597ba3e38 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 16:16:39 +0100 Subject: [PATCH 22/38] chore: drop usage of solid-react-component --- app/page.tsx | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 5ae4aab..95eea12 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,33 +1,17 @@ "use client"; import { Suspense } from "react"; -import { - SolidLoginNavigationProviderNext, - AuthGuard, -} from "solid-react-component/login/next"; -import LoadingSpinner from "./components/shared/LoadingSpinner"; +import AuthWrapper from "./components/AuthWrapper"; import FileManager from "./components/FileManager"; - -const loadingFallback = ( -
- -
-); - -function FileManagerContent() { - return ; -} +import FullPageLoader from "./components/shared/FullPageLoader"; export default function Home() { + // FileManager reads search params, which needs a Suspense boundary above it. return ( - - - - - - + }> + + + ); } From f985d82180039ac00f4eda337259de56c5ca9127 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 16:18:55 +0100 Subject: [PATCH 23/38] chore: drop ldo and solid-react component usage --- app/components/AuthWrapper.tsx | 156 +++------------------------------ 1 file changed, 11 insertions(+), 145 deletions(-) diff --git a/app/components/AuthWrapper.tsx b/app/components/AuthWrapper.tsx index 70dc0ca..d54ed9d 100644 --- a/app/components/AuthWrapper.tsx +++ b/app/components/AuthWrapper.tsx @@ -1,155 +1,21 @@ "use client"; -import { useEffect, useState, Suspense } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; -import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import LoadingSpinner from "./shared/LoadingSpinner"; +import { useEffect, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; +import FullPageLoader from "./shared/FullPageLoader"; -interface AuthWrapperProps { - children: React.ReactNode; -} - -// Check if there's any indication of a session in storage -function hasSessionInStorage(): boolean { - if (typeof window === "undefined") return false; - - try { - const keys = Object.keys(localStorage); - // Look for keys that might indicate a session exists - return keys.some(key => - key.includes("solidClientAuthn") || - key.includes("solid-auth") || - key.includes("oidc") || - key.includes("session") - ); - } catch { - return false; - } -} - -function AuthWrapperContent({ children }: AuthWrapperProps) { - const { session } = useSolidAuth(); - const searchParams = useSearchParams(); +export default function AuthWrapper({ children }: { children: ReactNode }) { + const { status } = useSolidAuth(); const router = useRouter(); - const pathname = usePathname(); - const [isCheckingSession, setIsCheckingSession] = useState(true); - const [hasSessionIndicator, setHasSessionIndicator] = useState(() => hasSessionInStorage()); - const [wasLoggedIn, setWasLoggedIn] = useState(false); - - // Check if we're in the middle of an OAuth callback - const isOAuthCallback = searchParams.has("code") || searchParams.has("state"); - const isLoginPage = pathname === "/login"; - - - const hasSessionData = !!(session.webId || session.sessionId || session.clientAppId); useEffect(() => { - if (session.isLoggedIn) { - setWasLoggedIn(true); - } - - // If we're in an OAuth callback, keep checking until session is established - // Don't redirect until session is confirmed - if (isOAuthCallback) { - setIsCheckingSession(true); - - if (session.isLoggedIn) { - setIsCheckingSession(false); - // Redirect to home after successful OAuth (remove OAuth params from URL) - const redirectTimer = setTimeout(() => { - if (typeof window !== "undefined") { - window.location.href = "/"; - } - }, 200); - return () => clearTimeout(redirectTimer); - } else { - // Session not yet established, keep waiting - // Set a timeout to prevent infinite waiting (max 10 seconds) - const maxWaitTimer = setTimeout(() => { - setIsCheckingSession(false); - }, 10000); - return () => clearTimeout(maxWaitTimer); - } - } - - - if (session.isLoggedIn) { - setIsCheckingSession(false); - if (isLoginPage) { - router.replace("/"); - } - return; - } - - - if (wasLoggedIn && !session.isLoggedIn) { - setIsCheckingSession(false); - if (!isLoginPage) { - router.replace("/login"); - } - return; - } + if (status === "anonymous") router.replace("/login"); + }, [status, router]); - // If we have session data (webId, sessionId, etc.) but isLoggedIn is false, - // the session is likely being restored - wait longer - // Otherwise, if no session data and no storage indicator, show login quickly - const shouldWaitForRestore = hasSessionData || hasSessionIndicator; - const checkTimer = setTimeout(() => { - setIsCheckingSession(false); + // Fail closed: children mount only once a session is confirmed, so nothing + // downstream can fetch before there is anything to authenticate with. + if (status !== "authenticated") return ; - if (!session.isLoggedIn && !isLoginPage && !isOAuthCallback) { - router.replace("/login"); - } - }, shouldWaitForRestore ? 2000 : 200); - - return () => clearTimeout(checkTimer); - }, [session.isLoggedIn, session.webId, session.sessionId, isOAuthCallback, hasSessionIndicator, hasSessionData, wasLoggedIn, isLoginPage, router]); - - - if (isCheckingSession || isOAuthCallback) { - return ( -
- -
- ); - } - - // If OAuth callback is on login page, we're still processing - show loading - // This handles the case where OAuth redirects back to /login - if (isOAuthCallback && isLoginPage) { - return ( -
- -
- ); - } - - // If not authenticated and not on login page, redirect will happen in useEffect - // If authenticated and on login page, redirect will happen in useEffect - // For now, just show children (or nothing if redirecting) - if (!session.isLoggedIn && !isLoginPage) { - return null; // Redirecting to login - } - - if (session.isLoggedIn && isLoginPage) { - return null; // Redirecting to home - } - - // User is authenticated and on correct page, show the app return <>{children}; } - - -export default function AuthWrapper({ children }: AuthWrapperProps) { - return ( - - -
- } - > - {children} - - ); -} From dd622bda14ff609e65067e6a6eb1193157973d08 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 16:19:17 +0100 Subject: [PATCH 24/38] feat: guard routes with the app's own auth state --- app/components/FileManager.tsx | 336 +++++++++++------------ app/components/shared/FullPageLoader.tsx | 11 + 2 files changed, 172 insertions(+), 175 deletions(-) create mode 100644 app/components/shared/FullPageLoader.tsx diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 04f6f82..3cab102 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -14,7 +14,6 @@ import { EyeIcon, ShareIcon, } from "@heroicons/react/24/outline"; -import AuthWrapper from "./AuthWrapper"; import Header from "./Header"; import Sidebar from "./Sidebar"; import Breadcrumb from "./Breadcrumb"; @@ -30,6 +29,7 @@ import FileUploadHandler from "./FileUploadHandler"; import ContextMenu, { ContextMenuAction } from "./ContextMenu"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; +import FullPageLoader from "./shared/FullPageLoader"; import ErrorDisplay from "./shared/ErrorDisplay"; import { useSolidStorages, useBrowseStorage } from "../lib/hooks"; import { @@ -775,205 +775,191 @@ export default function FileManager() { }; if (isLoadingStorages) { - return ( - -
- -
-
- ); + return ; } const isBrowsing = selectedStorageId && isLoadingFiles; if (storagesError) { return ( - - window.location.reload()} - /> - + window.location.reload()} + /> ); } if (browseError && selectedStorageId) { return ( - - { - setCurrentPath("/"); - }} - /> - + { + setCurrentPath("/"); + }} + /> ); } if (storages.length === 0) { return ( - -
-
-

No Storages Found

-

- Unable to discover any Solid storage roots from your WebID profile. -

-
+
+
+

No Storages Found

+

+ Unable to discover any Solid storage roots from your WebID profile. +

- +
); } return ( - -
-
setSidebarOpen(true)} - sidebarOpen={sidebarOpen} - /> -
- setSidebarOpen(false)} - activeTab="my-storages" - currentContainerUrl={containerUrlToBrowse} - onNewFolderClick={() => setShowNewFolderDialog(true)} - onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} - onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} - /> -
-
- -
- {isBrowsing ? ( -
- -
- ) : ( -
- -
- )} -
-
- - setShowNewFolderDialog(false)} +
+
setSidebarOpen(true)} + sidebarOpen={sidebarOpen} + /> +
+ setSidebarOpen(false)} + activeTab="my-storages" currentContainerUrl={containerUrlToBrowse} - onFolderCreated={handleFolderCreated} - /> - { - setShowRenameDialog(false); - setFileToRename(null); - }} - file={fileToRename} - onRenamed={handleRenamed} - /> - { - setShowPreviewModal(false); - setFileToPreview(null); - }} - file={fileToPreview} + onNewFolderClick={() => setShowNewFolderDialog(true)} + onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} + onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} /> - { - setShowMoveDialog(false); - setFileToMove(null); - }} - file={fileToMove} - availableFolders={availableFolders} - currentLocationUrl={getCurrentLocationUrl()} - onMoved={handleMoved} - /> - { - setShowDeleteDialog(false); - setFileToDelete(null); - }} - file={fileToDelete} - onConfirm={handleDeleteConfirm} - isDeleting={isDeleting} - /> - { - setShowShareDialog(false); - setFileToShare(null); - }} - file={fileToShare} - onShare={handleShareConfirm} - /> - setShowShareSuccessModal(false)} - resourceUrl={sharedResourceUrl} - resourceName={sharedResourceName} - onOpenInApp={(url) => { - updateUrl(url, true); - }} - /> - {isDragActive && ( -
-
-

Drop files or folders to upload

-

- They will be uploaded to the current folder -

+
+
+ +
+ {isBrowsing ? ( +
+ +
+ ) : ( +
+
+ )} +
+
+ + setShowNewFolderDialog(false)} + currentContainerUrl={containerUrlToBrowse} + onFolderCreated={handleFolderCreated} + /> + { + setShowRenameDialog(false); + setFileToRename(null); + }} + file={fileToRename} + onRenamed={handleRenamed} + /> + { + setShowPreviewModal(false); + setFileToPreview(null); + }} + file={fileToPreview} + /> + { + setShowMoveDialog(false); + setFileToMove(null); + }} + file={fileToMove} + availableFolders={availableFolders} + currentLocationUrl={getCurrentLocationUrl()} + onMoved={handleMoved} + /> + { + setShowDeleteDialog(false); + setFileToDelete(null); + }} + file={fileToDelete} + onConfirm={handleDeleteConfirm} + isDeleting={isDeleting} + /> + { + setShowShareDialog(false); + setFileToShare(null); + }} + file={fileToShare} + onShare={handleShareConfirm} + /> + setShowShareSuccessModal(false)} + resourceUrl={sharedResourceUrl} + resourceName={sharedResourceName} + onOpenInApp={(url) => { + updateUrl(url, true); + }} + /> + {isDragActive && ( +
+
+

Drop files or folders to upload

+

+ They will be uploaded to the current folder +

- )} - + )} + + + {contextMenuState && ( + - - {contextMenuState && ( - - )} -
- + )} +
); } diff --git a/app/components/shared/FullPageLoader.tsx b/app/components/shared/FullPageLoader.tsx new file mode 100644 index 0000000..bd04ccb --- /dev/null +++ b/app/components/shared/FullPageLoader.tsx @@ -0,0 +1,11 @@ +"use client"; + +import LoadingSpinner from "./LoadingSpinner"; + +export default function FullPageLoader({ text = "Loading..." }: { text?: string }) { + return ( +
+ +
+ ); +} From d8f71741bc4149b591667fafd01046598f7f63aa Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 16:19:46 +0100 Subject: [PATCH 25/38] fix: keep the remembered entry out of the suggestion list --- app/components/LoginPage.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index 398b8ba..4aac6ce 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -52,13 +52,14 @@ export default function LoginPage() { setRemembered(last); }, []); - const options = useMemo( - () => - remembered - ? [{ label: remembered, value: remembered, secondaryLabel: "Last used" }, ...KNOWN_ISSUERS] - : KNOWN_ISSUERS, - [remembered], - ); + const options = useMemo(() => { + if (!remembered) return KNOWN_ISSUERS; + return [ + { label: remembered, value: remembered, secondaryLabel: "Last used" }, + // The remembered entry is often one of the suggestions already. + ...KNOWN_ISSUERS.filter((option) => option.value !== remembered), + ]; + }, [remembered]); const needs = webId && !issuer ? "issuer" : issuer && !webId ? "webid" : "either"; From e22f4d9ed53767e6d1eef364b9e9fe7cbeb6e0d2 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 16:20:16 +0100 Subject: [PATCH 26/38] perf: check the profile before asking whether a URL is an issuer --- app/lib/auth/identify.ts | 16 ++++++++-------- app/login/page.tsx | 37 ++++++++++++++----------------------- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/app/lib/auth/identify.ts b/app/lib/auth/identify.ts index d14866b..a03afa3 100644 --- a/app/lib/auth/identify.ts +++ b/app/lib/auth/identify.ts @@ -17,16 +17,16 @@ export async function identify(input: string): Promise { const url = withScheme(input.trim()); if (!url) return { kind: "unknown" }; - const [issuers, discovery] = await Promise.all([ - probeProfile(url), - fetchDiscovery(url), - ]); - - // Order matters. A CSS pod root parses as Turtle with no issuer in it, so - // discovery has to win over an empty profile, or the root would be mistaken - // for a WebID. + // The profile goes first so a WebID that names its issuer never triggers the + // discovery request, which would 404 and litter the console on every login. + const issuers = await probeProfile(url); if (issuers?.length) return { kind: "webid", webId: url, issuers }; + + // A CSS pod root parses as Turtle with no issuer in it, so discovery has to + // win over an empty profile or the root would be mistaken for a WebID. + const discovery = await fetchDiscovery(url); if (discovery) return { kind: "issuer", issuer: discovery.issuer }; + if (issuers) return { kind: "webid", webId: url, issuers: [] }; return { kind: "unknown" }; } diff --git a/app/login/page.tsx b/app/login/page.tsx index eb59456..0b0973b 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,31 +1,22 @@ "use client"; -import { Suspense } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { - SolidLoginNavigationProviderNext, - AuthGuard, -} from "solid-react-component/login/next"; import LoginPage from "../components/LoginPage"; - -const loadingFallback = ( -
- Loading... -
-); +import FullPageLoader from "../components/shared/FullPageLoader"; +import { useSolidAuth } from "../lib/hooks/useSolidAuth"; export default function Login() { const router = useRouter(); - return ( - - - - - - - - ); -} + const { status } = useSolidAuth(); + + useEffect(() => { + if (status === "authenticated") router.replace("/"); + }, [status, router]); + // Hold the form back until we know there is no session to restore, so a + // returning user is not shown a sign-in form on their way through. + if (status !== "anonymous") return ; + + return ; +} From c5204d0cb9882566ae89b122244d4415647cbe8e Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 29 Jul 2026 16:34:57 +0100 Subject: [PATCH 27/38] feat: read auth state from the app's own provider --- app/components/ProfileIcon.tsx | 8 ++--- app/lib/hooks/useSolidStorages.ts | 53 ++++++++++++------------------- app/lib/hooks/useUserProfile.ts | 9 +++--- 3 files changed, 28 insertions(+), 42 deletions(-) diff --git a/app/components/ProfileIcon.tsx b/app/components/ProfileIcon.tsx index 472230b..e256563 100644 --- a/app/components/ProfileIcon.tsx +++ b/app/components/ProfileIcon.tsx @@ -1,12 +1,13 @@ "use client"; import { useState, useRef } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; -import { useUserProfile, useClickOutside } from "../lib/hooks"; +import { useRouter } from "next/navigation"; +import { useSolidAuth, useUserProfile, useClickOutside } from "../lib/hooks"; import { UserCircleIcon, ArrowRightStartOnRectangleIcon, PhoneIcon, BuildingOfficeIcon, BriefcaseIcon, GlobeAltIcon, ClipboardIcon } from "@heroicons/react/24/outline"; import toast from "react-hot-toast"; export default function ProfileIcon() { + const router = useRouter(); const { session, logout } = useSolidAuth(); const [showTooltip, setShowTooltip] = useState(false); const [showDropdown, setShowDropdown] = useState(false); @@ -26,8 +27,7 @@ export default function ProfileIcon() { const handleLogout = async () => { try { await logout(); - // Redirect to login page after logout - window.location.href = "/login"; + router.replace("/login"); } catch (error) { console.error("Logout failed:", error); toast.error("Failed to sign out"); diff --git a/app/lib/hooks/useSolidStorages.ts b/app/lib/hooks/useSolidStorages.ts index fe82892..3831ff8 100644 --- a/app/lib/hooks/useSolidStorages.ts +++ b/app/lib/hooks/useSolidStorages.ts @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; import { Parser, Store, NamedNode } from "n3"; +import { allowOrigin, getAuthFetch } from "../auth/manager"; import { fetchAndParseProfile } from "../helpers/profileUtils"; +import { useSolidAuth } from "./useSolidAuth"; // Storage predicates and types const PIM_STORAGE_TYPE = "http://www.w3.org/ns/pim/space#Storage"; @@ -83,8 +84,8 @@ async function discoverStorageViaTraversal( const parser = new Parser({ baseIRI: currentUrl }); const quads = parser.parse(content); store.addQuads(quads); - } catch (e) { - // Move up one level and continue + } catch { + // Not RDF we can read. Move up one level and continue. const parentUrl = currentUrl.substring(0, currentUrl.lastIndexOf('/', currentUrl.length - 2) + 1); if (parentUrl === currentUrl || parentUrl === `${url.origin}/`) { break; @@ -117,7 +118,7 @@ async function discoverStorageViaTraversal( } currentUrl = parentUrl; level++; - } catch (error) { + } catch { // If we can't fetch a container, try the parent const parentUrl = currentUrl.substring(0, currentUrl.lastIndexOf('/', currentUrl.length - 2) + 1); if (parentUrl === currentUrl || parentUrl === `${url.origin}/`) { @@ -128,9 +129,9 @@ async function discoverStorageViaTraversal( } } } catch (error) { - // Silent error handling + console.warn("Could not traverse the WebID hierarchy for a storage root", error); } - + return storageUrls; } @@ -142,6 +143,7 @@ async function discoverStorageViaTraversal( */ export function useSolidStorages(): UseSolidStoragesResult { const { session } = useSolidAuth(); + const { isLoggedIn, webId } = session; const [storages, setStorages] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -157,56 +159,41 @@ export function useSolidStorages(): UseSolidStoragesResult { setError(null); // Wait for authentication to complete - if (!session.isLoggedIn || !session.webId) { + if (!isLoggedIn || !webId) { setIsLoading(false); return; } - const webId = session.webId; - // Use shared profile fetching utility (with caching) const mainSubject = await fetchAndParseProfile(webId); - // Get storage roots using both pim:storage and solid:storage predicates const storageUrls: Set = mainSubject.storageUrls; - console.log("URLS", storageUrls) // Method 2: Hierarchical traversal (if no storage found via predicates) // Based on: https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts#L73-L104 if (storageUrls.size === 0) { try { - // LDO's session object has fetch property, but TypeScript types may not include it - // Use type assertion to access it, with fallback to regular fetch - const fetchFn = ('fetch' in session && typeof (session as any).fetch === 'function') - ? (session as any).fetch - : fetch; - const traversalStorages = await discoverStorageViaTraversal(webId, fetchFn); + const traversalStorages = await discoverStorageViaTraversal(webId, getAuthFetch()); traversalStorages.forEach(url => { if (!storageUrls.has(url)) { storageUrls.add(url); } }); } catch (err) { - // Silent error handling + console.warn("Storage traversal from the WebID failed", err); } } - // If still no storage found, try to infer from WebID + // Last resort: assume the pod is at the root of the WebID's own origin. if (storageUrls.size === 0) { - // Extract base URL from WebID - const webIdUrl = new URL(webId); - const baseUrl = `${webIdUrl.protocol}//${webIdUrl.host}/`; - - // For solidcommunity.net, storage is typically at the root - if (webId.includes("solidcommunity.net")) { - storageUrls.add(baseUrl); - } else { - // For other providers, try common patterns - storageUrls.add(baseUrl); - } + storageUrls.add(new URL(webId).origin + "/"); } + // A pod usually lives on a different origin from the WebID, so it has + // to be vouched for before anything will authenticate against it. + storageUrls.forEach((url) => allowOrigin(url)); + // Convert to SolidStorage format const discoveredStorages: SolidStorage[] = [...storageUrls].map((url) => { return { @@ -227,16 +214,16 @@ export function useSolidStorages(): UseSolidStoragesResult { } } - if (session.isLoggedIn && session.webId) { + if (isLoggedIn && webId) { fetchStorages(); } else { setIsLoading(false); } - + return () => { isMounted = false; }; - }, [session.isLoggedIn, session.webId]); + }, [isLoggedIn, webId]); return { storages, isLoading, error }; } diff --git a/app/lib/hooks/useUserProfile.ts b/app/lib/hooks/useUserProfile.ts index 2b215f0..95edf81 100644 --- a/app/lib/hooks/useUserProfile.ts +++ b/app/lib/hooks/useUserProfile.ts @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { useSolidAuth } from "@ldo/solid-react"; import { fetchAndParseProfile } from "../helpers/profileUtils"; +import { useSolidAuth } from "./useSolidAuth"; export interface UserProfile { name: string | null; @@ -26,6 +26,7 @@ interface UseUserProfileResult { */ export function useUserProfile(): UseUserProfileResult { const { session } = useSolidAuth(); + const { isLoggedIn, webId } = session; const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -37,13 +38,11 @@ export function useUserProfile(): UseUserProfileResult { setError(null); // Wait for authentication to complete - if (!session.isLoggedIn || !session.webId) { + if (!isLoggedIn || !webId) { setIsLoading(false); return; } - const webId = session.webId; - // Use shared profile fetching utility (with caching) const { name, email, photoUrl, phone, organization, role, title, website } = await fetchAndParseProfile(webId); @@ -70,7 +69,7 @@ export function useUserProfile(): UseUserProfileResult { } fetchProfile(); - }, []); + }, [isLoggedIn, webId]); return { profile, isLoading, error }; } From d03b5a496be89fefc7f3c8bbbc1df7b92947d105 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 09:42:27 +0100 Subject: [PATCH 28/38] refactor: route data calls through the reactive fetch --- app/components/FileManager.tsx | 11 +++++---- app/components/FileUploadHandler.tsx | 7 +++--- app/components/MoveDialog.tsx | 7 +++--- app/components/NewFolderDialog.tsx | 4 ++-- app/components/PreviewModal.tsx | 4 ++-- app/components/RenameDialog.tsx | 6 +++-- app/lib/helpers/acpUtils.ts | 10 ++++---- app/lib/helpers/contactUtils.ts | 18 +++++++------- app/lib/helpers/index.ts | 1 - app/lib/helpers/profileUtils.ts | 19 ++++++++------- app/lib/helpers/sessionUtils.ts | 35 ---------------------------- app/lib/hooks/useBrowseStorage.ts | 4 ++-- 12 files changed, 49 insertions(+), 77 deletions(-) delete mode 100644 app/lib/helpers/sessionUtils.ts diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 3cab102..7cec485 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -1,5 +1,7 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useState, useEffect, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { useSearchParams, useRouter } from "next/navigation"; @@ -34,7 +36,6 @@ import ErrorDisplay from "./shared/ErrorDisplay"; import { useSolidStorages, useBrowseStorage } from "../lib/hooks"; import { buildBreadcrumbItems, - getAuthenticatedSession, copyFileResource, copyFolderResource, downloadFile, @@ -325,7 +326,7 @@ export default function FileManager() { const toastId = toast.loading(`Copying "${file.name}"...`); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (file.type === "folder") { await copyFolderResource(file, fetchFn); } else { @@ -372,7 +373,7 @@ export default function FileManager() { ); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (fileToDelete.type === "folder") { await deleteFolderResource(fileToDelete.url, fetchFn); @@ -415,7 +416,7 @@ export default function FileManager() { ); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (file.type === "folder") { await downloadFolderAsZip(file.url, file.name, fetchFn); @@ -509,7 +510,7 @@ export default function FileManager() { let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { toast.error("Not authenticated"); return; diff --git a/app/components/FileUploadHandler.tsx b/app/components/FileUploadHandler.tsx index 41c2e9d..f99af02 100644 --- a/app/components/FileUploadHandler.tsx +++ b/app/components/FileUploadHandler.tsx @@ -1,9 +1,10 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useRef, useEffect } from "react"; import toast from "react-hot-toast"; import { - getAuthenticatedSession, uploadFilesToContainer, uploadFolderFilesToContainer, FolderUploadFile, @@ -49,7 +50,7 @@ export default function FileUploadHandler({ let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { toast.error("Not authenticated"); e.target.value = ""; @@ -102,7 +103,7 @@ export default function FileUploadHandler({ let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { toast.error("Not authenticated"); e.target.value = ""; diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index 672ab20..e1805d1 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -1,5 +1,7 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useState, useEffect } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; @@ -8,7 +10,6 @@ import toast from "react-hot-toast"; import { FileItemData } from "./FileItem"; import { moveFileResource, - getAuthenticatedSession, decodeResourceNameFromUrl, ensureTrailingSlash, } from "../lib/helpers"; @@ -94,7 +95,7 @@ export default function MoveDialog({ if (fileStorage) { const loadFolders = async () => { try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); const folders = await fetchAllFolders(fileStorage.url, fetchFn); // Include the storage root itself setAllFolders([fileStorage, ...folders]); @@ -125,7 +126,7 @@ export default function MoveDialog({ setIsMoving(true); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); await moveFileResource(file, selectedFolderUrl, fetchFn); toast.success(`Moved "${file.name}"`); diff --git a/app/components/NewFolderDialog.tsx b/app/components/NewFolderDialog.tsx index 873cc17..52dd9a8 100644 --- a/app/components/NewFolderDialog.tsx +++ b/app/components/NewFolderDialog.tsx @@ -6,7 +6,7 @@ import Button from "./shared/Button"; import Input from "./shared/Input"; import { createContainerAt, getSolidDataset, UrlString } from "@inrupt/solid-client"; import toast from "react-hot-toast"; -import { getAuthenticatedSession } from "../lib/helpers"; +import { getAuthFetch } from "../lib/auth/manager"; interface NewFolderDialogProps { isOpen: boolean; @@ -53,7 +53,7 @@ export default function NewFolderDialog({ setIsCreating(true); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); // Ensure the current container exists await getSolidDataset(currentContainerUrl, { fetch: fetchFn }); diff --git a/app/components/PreviewModal.tsx b/app/components/PreviewModal.tsx index e58264d..aff7c09 100644 --- a/app/components/PreviewModal.tsx +++ b/app/components/PreviewModal.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; import { getFile, UrlString } from "@inrupt/solid-client"; -import { getAuthenticatedSession } from "../lib/helpers"; +import { getAuthFetch } from "../lib/auth/manager"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; @@ -57,7 +57,7 @@ export default function PreviewModal({ setError(null); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); let fileBlob: Blob; diff --git a/app/components/RenameDialog.tsx b/app/components/RenameDialog.tsx index dd8f5f5..688cf89 100644 --- a/app/components/RenameDialog.tsx +++ b/app/components/RenameDialog.tsx @@ -1,5 +1,7 @@ "use client"; +import { getAuthFetch } from "../lib/auth/manager"; + import { useState, useEffect, useRef } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; @@ -7,7 +9,7 @@ import Input from "./shared/Input"; import { UrlString, getFile, overwriteFile, deleteFile, createContainerAt } from "@inrupt/solid-client"; import toast from "react-hot-toast"; import { FileItemData } from "./FileItem"; -import { getAuthenticatedSession, sanitizeResourceName, getParentContainerUrl, ensureTrailingSlash, copyFolderContents, deleteFolderResource } from "../lib/helpers"; +import { sanitizeResourceName, getParentContainerUrl, ensureTrailingSlash, copyFolderContents, deleteFolderResource } from "../lib/helpers"; interface RenameDialogProps { isOpen: boolean; @@ -56,7 +58,7 @@ export default function RenameDialog({ setIsRenaming(true); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); const sanitizedName = sanitizeResourceName(newName.trim()); const parentUrl = getParentContainerUrl(file.url); const parentWithSlash = ensureTrailingSlash(parentUrl); diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index 1cff865..ad18ed3 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -8,7 +8,7 @@ * N3.js: for creating/updating ACRs */ -import { getAuthenticatedSession } from "./sessionUtils"; +import { getAuthFetch } from "../auth/manager"; import { AcrDataset } from "@/app/lib/class/AcrDataset"; import type { DatasetCore } from "@rdfjs/types" import { AccessControlResource } from "@/app/lib/class/AccessControlResource" @@ -310,7 +310,7 @@ export async function shareResourceWithAcp( return; } - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const acrUrl = await getAcrUrl(resourceUrl, fetch); // Fetch existing ACR or create new one @@ -346,7 +346,7 @@ export async function verifyResourceAccess(resourceUrl: string): Promise<{ error?: string; }> { try { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const headResponse = await fetch(resourceUrl, { method: "HEAD", @@ -396,7 +396,7 @@ export async function getResourceAccessList(resourceUrl: string): Promise | null> { try { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const acrUrl = await getAcrUrl(resourceUrl, fetch); const response = await fetch(acrUrl, { @@ -465,7 +465,7 @@ export async function removeAccessFromResource( resourceUrl: string, webIdToRemove: string ): Promise { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const acrUrl = await getAcrUrl(resourceUrl, fetch); // Fetch the existing ACR diff --git a/app/lib/helpers/contactUtils.ts b/app/lib/helpers/contactUtils.ts index 2937854..e97d73b 100644 --- a/app/lib/helpers/contactUtils.ts +++ b/app/lib/helpers/contactUtils.ts @@ -1,5 +1,5 @@ import { fetchAndParseProfile } from "./profileUtils"; -import { getSession, getAuthenticatedSession } from "./sessionUtils"; +import { getAuthFetch, getCurrentSession } from "../auth/manager"; import { fromRdfJsDataset, saveSolidDatasetAt } from "@inrupt/solid-client"; export interface Contact { @@ -14,16 +14,16 @@ export interface Contact { * @returns Array of contacts with their WebID, name, and email */ export async function fetchUserContacts(): Promise { - const session = getSession(); + const session = getCurrentSession(); - if (!session.info.isLoggedIn || !session.info.webId) { + if (!session) { return []; } try { - const userWebId = session.info.webId; + const userWebId = session.webId; - const userProfile = await fetchAndParseProfile(session.info.webId); + const userProfile = await fetchAndParseProfile(userWebId); const contacts: Contact[] = []; @@ -62,14 +62,14 @@ export async function fetchUserContacts(): Promise { * @returns Promise that resolves when the contact is added */ export async function addContactToProfile(contactWebId: string): Promise { - const session = getSession(); + const session = getCurrentSession(); - if (!session.info.isLoggedIn || !session.info.webId) { + if (!session) { throw new Error("User is not logged in"); } - const userWebId = session.info.webId; - const { fetch } = getAuthenticatedSession(); + const userWebId = session.webId; + const fetch = getAuthFetch(); const profileUrl = userWebId.split('#')[0]; diff --git a/app/lib/helpers/index.ts b/app/lib/helpers/index.ts index e32dc7f..0af8db5 100644 --- a/app/lib/helpers/index.ts +++ b/app/lib/helpers/index.ts @@ -6,7 +6,6 @@ export * from "./urlUtils"; export * from "./breadcrumbUtils"; export * from "./fileTypeUtils"; export * from "./profileUtils"; -export * from "./sessionUtils"; export * from "./copyUtils"; export * from "./downloadUtils"; export * from "./deleteUtils"; diff --git a/app/lib/helpers/profileUtils.ts b/app/lib/helpers/profileUtils.ts index e0863be..87de8ad 100644 --- a/app/lib/helpers/profileUtils.ts +++ b/app/lib/helpers/profileUtils.ts @@ -1,5 +1,5 @@ import { Parser, Store, DataFactory } from "n3"; -import { getSession } from "./sessionUtils"; +import { getAuthFetch } from "../auth/manager"; import { WebIdDataset } from "@/app/lib/class/WebIdDataset"; import type { Agent } from "@/app/lib/class/Agent"; @@ -17,8 +17,7 @@ export async function fetchAndParseProfile(webId: string): Promise { return profileCache.get(webId)!; } - const session = getSession(); - const fetchFn = session.fetch || fetch; + const fetchFn = getAuthFetch(); // Try different Accept headers to get the profile const acceptHeaders = [ @@ -44,7 +43,8 @@ export async function fetchAndParseProfile(webId: string): Promise { content = await response.text(); break; } - } catch (err) { + } catch { + // This Accept header did not work; try the next one. continue; } } @@ -68,8 +68,10 @@ export async function fetchAndParseProfile(webId: string): Promise { const parser = new Parser({ baseIRI: baseUrl }); const quads = parser.parse(content); store.addQuads(quads); - } catch (e) { - // Silent error handling + } catch (error) { + // Genuinely JSON-LD, which we cannot parse. The store stays empty and the + // missing-subject check below reports it. + console.warn(`Could not parse ${webId} as Turtle`, error); } } @@ -77,8 +79,9 @@ export async function fetchAndParseProfile(webId: string): Promise { const mainSubject: Agent | undefined = webIdDataset.mainSubject; if (mainSubject === undefined) { -console.log("BOB") - throw new Error; // TODO: Handle properly + // mainSubject is located by its solid:oidcIssuer, so a profile without one + // reads as empty here even when it parsed fine. + throw new Error(`No WebID subject with an oidcIssuer found in ${webId}`); } // Cache the result diff --git a/app/lib/helpers/sessionUtils.ts b/app/lib/helpers/sessionUtils.ts deleted file mode 100644 index 955ea7b..0000000 --- a/app/lib/helpers/sessionUtils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getDefaultSession, Session } from "@inrupt/solid-client-authn-browser"; - -/** - * Gets the current Solid session and optionally validates authentication. - * - * @param requireAuth - If true, throws an error if the user is not authenticated. Defaults to true. - * @returns An object containing the session and authenticated fetch function. - * @throws Error if requireAuth is true and the user is not authenticated. - */ -export function getAuthenticatedSession(requireAuth: boolean = true): { - session: Session; - fetch: typeof fetch; -} { - const session = getDefaultSession(); - - if (requireAuth && !session.info.isLoggedIn) { - throw new Error("Not authenticated"); - } - - return { - session, - fetch: session.fetch || fetch, - }; -} - -/** - * Gets the current Solid session without requiring authentication. - * Useful for checking session state without throwing errors. - * - * @returns The current session. - */ -export function getSession(): Session { - return getDefaultSession(); -} - diff --git a/app/lib/hooks/useBrowseStorage.ts b/app/lib/hooks/useBrowseStorage.ts index f507317..0a3f9a3 100644 --- a/app/lib/hooks/useBrowseStorage.ts +++ b/app/lib/hooks/useBrowseStorage.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { getAuthenticatedSession } from "../helpers"; +import { getAuthFetch } from "../auth/manager"; import { getSolidDataset, toRdfJsDataset } from "@inrupt/solid-client"; import { FileItemData } from "../../components/FileItem"; import { ContainerDataset } from "../class/ContainerDataset"; @@ -35,7 +35,7 @@ export function useBrowseStorage(containerUrl: string | null, refreshKey?: numbe setIsLoading(true); setError(null); - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); // This is a cache-busting fetch wrapper for when refreshKey is provided // This ensures we get fresh data after uploads/deletes From b683d1cf01e44e3a3ac6b0692b74d315c989cda3 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 09:45:16 +0100 Subject: [PATCH 29/38] chore: drop ldo and solid-react component usage --- app/components/providers/LdoProvider.tsx | 12 - app/layout.tsx | 7 +- next.config.ts | 4 +- package-lock.json | 427 +---------------------- package.json | 5 +- 5 files changed, 15 insertions(+), 440 deletions(-) delete mode 100644 app/components/providers/LdoProvider.tsx diff --git a/app/components/providers/LdoProvider.tsx b/app/components/providers/LdoProvider.tsx deleted file mode 100644 index c506c09..0000000 --- a/app/components/providers/LdoProvider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { BrowserSolidLdoProvider } from "@ldo/solid-react"; - -export default function LdoProvider({ - children, -}: { - children: React.ReactNode; -}) { - return {children}; -} - diff --git a/app/layout.tsx b/app/layout.tsx index 763108f..60800b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Toaster from "./components/shared/Toaster"; -import LdoProvider from "./components/providers/LdoProvider"; import SolidAuthProvider from "./components/providers/SolidAuthProvider"; const geistSans = Geist({ @@ -36,10 +35,8 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > - - {children} - - + {children} + diff --git a/next.config.ts b/next.config.ts index d3dbf82..cb651cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - transpilePackages: ["solid-react-component"], -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index c2efec3..c066c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,6 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "^1.23.1", - "@inrupt/solid-client-authn-browser": "^3.1.1", - "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", "@solid/reactive-authentication": "github:solid-contrib/reactive-authentication#feat/dpop-session-cache-installable", "jszip": "^3.10.1", @@ -20,8 +18,7 @@ "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", - "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.2" + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", @@ -325,6 +322,7 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/@bergos/jsonparse/-/jsonparse-1.4.2.tgz", "integrity": "sha512-qUt0QNJjvg4s1zk+AuLM6s/zcsQ8MvGn7+1f0vPuxvpCYa08YtTryuDInngbEyW5fNGGYe2znKt61RMGd5HnXg==", + "dev": true, "engines": [ "node >= 0.2.0" ], @@ -6647,31 +6645,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@inrupt/oidc-client": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@inrupt/oidc-client/-/oidc-client-1.11.6.tgz", - "integrity": "sha512-1rCTk1T6pdm/7gKozutZutk7jwmYBADlnkGGoI5ypke099NOCa5KFXjkQpbjsps0PRkKZ+0EaR70XN5+xqmViA==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^7.4.1", - "base64-js": "^1.5.1", - "core-js": "^3.8.3", - "crypto-js": "^4.0.0", - "serialize-javascript": "^4.0.0" - } - }, - "node_modules/@inrupt/oidc-client-ext": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@inrupt/oidc-client-ext/-/oidc-client-ext-3.1.1.tgz", - "integrity": "sha512-vftKD2u5nufZTFkdUDMS3Uxj5xNQwArP11OFaALFkq6/3RwCAhe3lwOv8hNzL7Scv98T+KbAErBM0TwGGrS69g==", - "license": "MIT", - "dependencies": { - "@inrupt/oidc-client": "^1.11.6", - "@inrupt/solid-client-authn-core": "^3.1.1", - "jose": "^5.1.3", - "uuid": "^11.1.0" - } - }, "node_modules/@inrupt/solid-client": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@inrupt/solid-client/-/solid-client-1.23.1.tgz", @@ -6691,33 +6664,6 @@ "node": ">=14.0" } }, - "node_modules/@inrupt/solid-client-authn-browser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-browser/-/solid-client-authn-browser-3.1.1.tgz", - "integrity": "sha512-Wd7TREmvdhTp+Sk88ei3hlg54sG1fNqkkPkuS+2tDBkcsXaViRQAEugVyh5pWRkd1xSFKrEzftb7UYEG4mJ0CQ==", - "license": "MIT", - "dependencies": { - "@inrupt/oidc-client-ext": "^3.1.1", - "@inrupt/solid-client-authn-core": "^3.1.1", - "events": "^3.3.0", - "jose": "^5.1.3", - "uuid": "^11.1.0" - } - }, - "node_modules/@inrupt/solid-client-authn-core": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-3.1.1.tgz", - "integrity": "sha512-1oDSQCh/pVtPlTyvLQ2uwHo+hpLJF7izg82tjB+Ge8jqGYwkQyId0BrfncpCk//uJXxgRIcfAQp2MhXYbZo80Q==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0", - "jose": "^5.1.3", - "uuid": "^11.1.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0" - } - }, "node_modules/@inrupt/solid-client/node_modules/n3": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", @@ -6748,30 +6694,6 @@ "node": ">=12" } }, - "node_modules/@janeirodigital/interop-utils": { - "version": "1.0.0-rc.24", - "resolved": "https://registry.npmjs.org/@janeirodigital/interop-utils/-/interop-utils-1.0.0-rc.24.tgz", - "integrity": "sha512-mLOhitq6SyRSZi1DxrzTTgms7Mt0zgx/5KezkkyMBH3OYuYJBGPH6A93iBJl0wA5Ln90A9KnyiC7I/7+IUYhoQ==", - "license": "MIT", - "dependencies": { - "http-link-header": "^1.1.1", - "jsonld-streaming-parser": "^3.2.1", - "n3": "^1.17.1" - } - }, - "node_modules/@janeirodigital/interop-utils/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, "node_modules/@jeswr/prefixcc": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jeswr/prefixcc/-/prefixcc-1.2.1.tgz", @@ -6882,132 +6804,6 @@ } } }, - "node_modules/@ldo/connected": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/connected/-/connected-1.0.0-alpha.32.tgz", - "integrity": "sha512-zH9MHnPNA9fGRbWB5ON6iXg8XYgwdig03Cpg/5XhHiWi7Kvm5/yiZHVFWBQTxR2d6C0KkrCEp9jdJgyY8SDSqw==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/ldo": "^1.0.0-alpha.32", - "@ldo/rdf-utils": "^1.0.0-alpha.30" - } - }, - "node_modules/@ldo/connected-solid": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/connected-solid/-/connected-solid-1.0.0-alpha.32.tgz", - "integrity": "sha512-594i0GDwFeoqmDRcpQASuInOF1zYG+1qnBJEhyHQH5sspuIIPtskdXrTOC3tbt1zAl8wepf9tcVht6aUdwZjHA==", - "license": "MIT", - "dependencies": { - "@ldo/connected": "^1.0.0-alpha.32", - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/ldo": "^1.0.0-alpha.32", - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@solid-notifications/subscription": "^0.1.2", - "cross-fetch": "^3.1.6", - "http-link-header": "^1.1.1", - "ws": "^8.18.0" - } - }, - "node_modules/@ldo/dataset": { - "version": "1.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@ldo/dataset/-/dataset-1.0.0-alpha.30.tgz", - "integrity": "sha512-XKGtsOULCZ32AtNlGqNYGjaZADwJtIWuILmL12TWeWpjzSfEodpzudjx4Ux+SES56MflGVl5CNuazsyj9+/5Gg==", - "license": "MIT", - "dependencies": { - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@rdfjs/dataset": "^1.1.0", - "buffer": "^6.0.3", - "readable-stream": "^4.2.0" - } - }, - "node_modules/@ldo/jsonld-dataset-proxy": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/jsonld-dataset-proxy/-/jsonld-dataset-proxy-1.0.0-alpha.32.tgz", - "integrity": "sha512-ll8jOP6L6sCEce73PNkBZWhs+GmDE1vNsg/7fRv6eqROZJOhOqHTs74/qkvyOTzRVVOthIB0MMoRva4kE96r2Q==", - "license": "MIT", - "dependencies": { - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@ldo/subscribable-dataset": "^1.0.0-alpha.32", - "jsonld2graphobject": "^0.0.4" - } - }, - "node_modules/@ldo/ldo": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/ldo/-/ldo-1.0.0-alpha.32.tgz", - "integrity": "sha512-B5yEKAjpQA4VbXOv3faxYYxjgDZUSxTy4vCSATpVvGt96RxolJzewJ7ELl0C2KG0EANcWoHyUB0ac7oOJrmUCQ==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/jsonld-dataset-proxy": "^1.0.0-alpha.32", - "@ldo/subscribable-dataset": "^1.0.0-alpha.32", - "buffer": "^6.0.3", - "readable-stream": "^4.3.0" - } - }, - "node_modules/@ldo/rdf-utils": { - "version": "1.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@ldo/rdf-utils/-/rdf-utils-1.0.0-alpha.30.tgz", - "integrity": "sha512-nYCaf//tysYOhQfj1SmYTvuRzAK1VCENMOFYJlF0oNKIK/pEqXOkxFKt8yhkNEZ5e9BZ5ofLmGFeyj3OLiYivw==", - "license": "MIT", - "dependencies": { - "@rdfjs/data-model": "^1.2.0", - "n3": "^1.17.1", - "rdf-string": "^1.6.3" - } - }, - "node_modules/@ldo/rdf-utils/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@ldo/react": { - "version": "1.0.0-alpha.33", - "resolved": "https://registry.npmjs.org/@ldo/react/-/react-1.0.0-alpha.33.tgz", - "integrity": "sha512-DF/j1ZGtUIp7qo1ZQbHukgXrqdg3F7S0sLTt50Ua5no97YiorG/BXbRgrhy3tPn6azLkl4yGWPNuOm5JppA6Tg==", - "license": "MIT", - "dependencies": { - "@ldo/connected": "^1.0.0-alpha.32", - "@ldo/jsonld-dataset-proxy": "^1.0.0-alpha.32", - "@ldo/ldo": "^1.0.0-alpha.32", - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "@ldo/subscribable-dataset": "^1.0.0-alpha.32", - "cross-fetch": "^3.1.6" - } - }, - "node_modules/@ldo/solid-react": { - "version": "1.0.0-alpha.33", - "resolved": "https://registry.npmjs.org/@ldo/solid-react/-/solid-react-1.0.0-alpha.33.tgz", - "integrity": "sha512-H7GN2SGWHsX1N5NF2c/lSbaPK3MLBZXtK6GvrXdWkR3I6kuUaZE6PsYMgx38CmJuIfh9dK4Vbz2+a36J30YMcg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@inrupt/solid-client-authn-browser": "^3.0.0", - "@ldo/connected": "^1.0.0-alpha.32", - "@ldo/connected-solid": "^1.0.0-alpha.32", - "@ldo/react": "^1.0.0-alpha.33", - "cross-fetch": "^3.1.6" - } - }, - "node_modules/@ldo/subscribable-dataset": { - "version": "1.0.0-alpha.32", - "resolved": "https://registry.npmjs.org/@ldo/subscribable-dataset/-/subscribable-dataset-1.0.0-alpha.32.tgz", - "integrity": "sha512-E42L2tDRxqOx5vxLGG0HiuZ6SMdW9iothJcFREV4f92kE9KE+l+Ope9hpfAmRfyNMdAEbl1wvW2iCfyj/J5naw==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^1.0.0-alpha.30", - "@ldo/rdf-utils": "^1.0.0-alpha.30", - "uuid": "^11.1.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -7590,53 +7386,6 @@ "text-hex": "1.0.x" } }, - "node_modules/@solid-notifications/discovery": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@solid-notifications/discovery/-/discovery-0.1.2.tgz", - "integrity": "sha512-jkqV+Ceknw2XE0Vl/4O2BBFnkCZQhNDVt6B9nzbVD4T3aNhMlK/gZS6oNHqa23obgFNCtgFBmeeRKiN1/v8lcw==", - "license": "MIT", - "dependencies": { - "@janeirodigital/interop-utils": "^1.0.0-rc.24", - "n3": "^1.17.2" - } - }, - "node_modules/@solid-notifications/discovery/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@solid-notifications/subscription": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@solid-notifications/subscription/-/subscription-0.1.2.tgz", - "integrity": "sha512-XnnqNsLOIdUAzB11aROzfRiJLHJjTOaHMSrnn3teQRtE0BwpbnAJtzGG/m3JNUR+QqyjKkB3jfibxJjzvI/HQg==", - "license": "MIT", - "dependencies": { - "@janeirodigital/interop-utils": "^1.0.0-rc.24", - "@solid-notifications/discovery": "^0.1.2", - "n3": "^1.17.2" - } - }, - "node_modules/@solid-notifications/subscription/node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, "node_modules/@solid/access-token-verifier": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@solid/access-token-verifier/-/access-token-verifier-2.1.1.tgz", @@ -8444,6 +8193,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/http-link-header/-/http-link-header-1.0.7.tgz", "integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -8484,6 +8234,7 @@ "version": "1.5.15", "resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.15.tgz", "integrity": "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==", + "dev": true, "license": "MIT" }, "node_modules/@types/keygrip": { @@ -9548,6 +9299,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, "license": "MIT", "peer": true, "bin": { @@ -10623,17 +10375,6 @@ "node": ">= 0.8" } }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -10682,12 +10423,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -13311,6 +13046,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -13448,71 +13184,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonld-streaming-parser": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.4.0.tgz", - "integrity": "sha512-897CloyQgQidfkB04dLM5XaAXVX/cN9A2hvgHJo4y4jRhIpvg3KLMBBfcrswepV2N3T8c/Rp2JeFdWfVsbVZ7g==", - "license": "MIT", - "dependencies": { - "@bergos/jsonparse": "^1.4.0", - "@rdfjs/types": "*", - "@types/http-link-header": "^1.0.1", - "@types/readable-stream": "^2.3.13", - "buffer": "^6.0.3", - "canonicalize": "^1.0.1", - "http-link-header": "^1.0.2", - "jsonld-context-parser": "^2.4.0", - "rdf-data-factory": "^1.1.0", - "readable-stream": "^4.0.0" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/@types/readable-stream": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", - "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "safe-buffer": "~5.1.1" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/jsonld-context-parser": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", - "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", - "license": "MIT", - "dependencies": { - "@types/http-link-header": "^1.0.1", - "@types/node": "^18.0.0", - "cross-fetch": "^3.0.6", - "http-link-header": "^1.0.2", - "relative-to-absolute-iri": "^1.0.5" - }, - "bin": { - "jsonld-context-parse": "bin/jsonld-context-parse.js" - } - }, - "node_modules/jsonld-streaming-parser/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/jsonld-streaming-parser/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/jsonld-streaming-serializer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/jsonld-streaming-serializer/-/jsonld-streaming-serializer-3.0.1.tgz", @@ -13549,58 +13220,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, - "node_modules/jsonld2graphobject": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/jsonld2graphobject/-/jsonld2graphobject-0.0.4.tgz", - "integrity": "sha512-7siWYw9/EaD9lWyMbHr2uLMy8kbNVyOtDlsAWJUlUjVfXpcJcwLN6f0qeNt0ySV4fDoAJOjJXNvo7V/McrubAg==", - "license": "MIT", - "dependencies": { - "@rdfjs/types": "^1.0.1", - "@types/jsonld": "^1.5.6", - "jsonld-context-parser": "^2.1.5", - "uuid": "^8.3.2" - } - }, - "node_modules/jsonld2graphobject/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/jsonld2graphobject/node_modules/jsonld-context-parser": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", - "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", - "license": "MIT", - "dependencies": { - "@types/http-link-header": "^1.0.1", - "@types/node": "^18.0.0", - "cross-fetch": "^3.0.6", - "http-link-header": "^1.0.2", - "relative-to-absolute-iri": "^1.0.5" - }, - "bin": { - "jsonld-context-parse": "bin/jsonld-context-parse.js" - } - }, - "node_modules/jsonld2graphobject/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/jsonld2graphobject/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -15279,15 +14898,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -15320,6 +14930,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz", "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "dev": true, "license": "MIT", "dependencies": { "@rdfjs/types": "^1.0.0" @@ -15721,6 +15332,7 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.3.tgz", "integrity": "sha512-HIVwQ2gOqf+ObsCLSUAGFZMIl3rh9uGcRf1KbM85UDhKqP+hy6qj7Vz8FKt3GA54RiThqK3mNcr66dm1LP0+6g==", + "dev": true, "license": "MIT", "dependencies": { "@rdfjs/types": "*", @@ -16161,6 +15773,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/relative-to-absolute-iri/-/relative-to-absolute-iri-1.0.7.tgz", "integrity": "sha512-Xjyl4HmIzg2jzK/Un2gELqbcE8Fxy85A/aLSHE6PE/3+OGsFwmKVA1vRyGaz6vLWSqLDMHA+5rjD/xbibSQN1Q==", + "dev": true, "license": "MIT" }, "node_modules/require-directory": { @@ -16390,15 +16003,6 @@ "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16723,17 +16327,6 @@ "node": ">=10" } }, - "node_modules/solid-react-component": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/solid-react-component/-/solid-react-component-0.2.8.tgz", - "integrity": "sha512-Tn3oA1ctUsmOKH1TLNzyRpRt/w8URZpG5W+8rS9AdLsBQOY1/7pJ/zoJXnrRxWIXSplgk/F/4rRQffzFE5230g==", - "deprecated": "Moved to @solid/react-component", - "license": "MIT", - "peerDependencies": { - "@ldo/solid-react": ">=1.0.0-alpha.33", - "react": ">=18.0.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17808,6 +17401,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -18069,6 +17663,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 390839b..a58d0a7 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,6 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "^1.23.1", - "@inrupt/solid-client-authn-browser": "^3.1.1", - "@ldo/solid-react": "^1.0.0-alpha.33", "@rdfjs/wrapper": "^0.33.0", "@solid/reactive-authentication": "github:solid-contrib/reactive-authentication#feat/dpop-session-cache-installable", "jszip": "^3.10.1", @@ -24,8 +22,7 @@ "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", - "react-hot-toast": "^2.6.0", - "solid-react-component": "^0.2.2" + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", From 6430bff2d9fa68aa4750e62be0b41617f9c88919 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 10:09:40 +0100 Subject: [PATCH 30/38] fix: sign in before leaving the login page --- app/components/LoginPage.tsx | 31 ++++++++++++++--- .../providers/SolidAuthProvider.tsx | 5 +-- app/lib/auth/discovery.ts | 1 + app/lib/auth/manager.ts | 5 +-- app/lib/auth/userinfo.ts | 34 +++++++++++++++++++ app/lib/helpers/contactUtils.ts | 11 +++--- 6 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 app/lib/auth/userinfo.ts diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index 4aac6ce..3f34a58 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { identify } from "../lib/auth/identify"; +import { getAuthFetch, setCurrentSession } from "../lib/auth/manager"; +import { resolveWebId } from "../lib/auth/userinfo"; import { getLastLoginEntry, saveLastLoginEntry, @@ -63,16 +65,33 @@ export default function LoginPage() { const needs = webId && !issuer ? "issuer" : issuer && !webId ? "webid" : "either"; - function finish(nextWebId: string | null, nextIssuer: string | null) { + async function finish(nextWebId: string | null, nextIssuer: string | null) { setWebId(nextWebId); setIssuer(nextIssuer); // Whichever half is still missing, the field relabels and asks for it. if (!nextWebId || !nextIssuer) { + setCurrentSession(null); setEntry(""); return; } + // Sign in here, while the submitting click is still the active gesture, so + // the popup is not blocked and the session is cached before the file + // manager mounts and fires a dozen requests. + const authenticated = (await resolveWebId(nextIssuer)) !== null; + setCurrentSession({ webId: nextWebId, issuer: nextIssuer }); + + if (!authenticated) { + // No userinfo endpoint. Read the profile instead, which is protected + // on the servers that lack one. + try { + await getAuthFetch()(nextWebId); + } catch (error) { + console.warn("Could not sign in before entering the app", error); + } + } + saveLastLoginEntry(nextWebId); login({ webId: nextWebId, issuer: nextIssuer }); router.replace("/"); @@ -94,7 +113,10 @@ export default function LoginPage() { } if (target.kind === "issuer") { - finish(webId, target.issuer); + // Sign in on the issuer alone where the server can tell us who + // that turned out to be; otherwise fall through to asking. + const known = webId ?? (await resolveWebId(target.issuer)); + await finish(known, target.issuer); return; } @@ -104,7 +126,7 @@ export default function LoginPage() { return; } - finish(target.webId, target.issuers[0] ?? issuer); + await finish(target.webId, target.issuers[0] ?? issuer); } finally { setIsResolving(false); } @@ -174,7 +196,8 @@ export default function LoginPage() { issuers={choices} onSelect={(choice) => { setChoices([]); - finish(webId, choice); + setIsResolving(true); + void finish(webId, choice).finally(() => setIsResolving(false)); }} onClose={() => setChoices([])} /> diff --git a/app/components/providers/SolidAuthProvider.tsx b/app/components/providers/SolidAuthProvider.tsx index aa2c096..09142bc 100644 --- a/app/components/providers/SolidAuthProvider.tsx +++ b/app/components/providers/SolidAuthProvider.tsx @@ -35,7 +35,8 @@ export interface SolidAuthContextValue { // The issuer of the session just ended, so the login page can offer to sign // out there too. In memory only: the offer is stale after a reload. signedOutFrom: string | null; - login: (credentials: SolidCredentials) => void; + + login: (credentials: { webId: string; issuer: string }) => void; logout: () => Promise; } @@ -86,7 +87,7 @@ export default function SolidAuthProvider({ children }: { children: ReactNode }) restore(); }, [buildManager]); - const login = useCallback((next: SolidCredentials) => { + const login = useCallback((next: { webId: string; issuer: string }) => { generationRef.current++; setCurrentSession(next); writeStoredCredentials(next); diff --git a/app/lib/auth/discovery.ts b/app/lib/auth/discovery.ts index 75e0602..b99d46c 100644 --- a/app/lib/auth/discovery.ts +++ b/app/lib/auth/discovery.ts @@ -1,6 +1,7 @@ export interface DiscoveryDocument { issuer: string; end_session_endpoint?: string; + userinfo_endpoint?: string; } const TIMEOUT_MS = 8000; diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts index 961840c..b869109 100644 --- a/app/lib/auth/manager.ts +++ b/app/lib/auth/manager.ts @@ -1,7 +1,8 @@ import type { GetCodeCallback, TokenProvider } from "@solid/reactive-authentication"; export interface SolidCredentials { - webId: string; + // Null only while an issuer-first sign-in is asking userinfo who the user is. + webId: string | null; issuer: string; } @@ -58,7 +59,7 @@ export function getCurrentSession(): SolidCredentials | null { export function setCurrentSession(next: SolidCredentials | null): void { currentSession = next; // Reading the profile is the first thing anything does with an identity. - if (next) allowOrigin(next.webId); + if (next?.webId) allowOrigin(next.webId); for (const listener of sessionListeners) listener(); } diff --git a/app/lib/auth/userinfo.ts b/app/lib/auth/userinfo.ts new file mode 100644 index 0000000..24577e3 --- /dev/null +++ b/app/lib/auth/userinfo.ts @@ -0,0 +1,34 @@ +import { fetchDiscovery } from "./discovery"; +import { allowOrigin, getAuthFetch, setCurrentSession } from "./manager"; + +/** + * Asks an authorization server who the user is, so signing in with only an + * issuer still yields the WebID the file manager needs to find a pod. + * + * Returns null when the server has no userinfo endpoint — CSS-backed servers do + * not publish one — leaving the login page to ask for the WebID instead. + */ +export async function resolveWebId(issuer: string): Promise { + const document = await fetchDiscovery(issuer); + const endpoint = document?.userinfo_endpoint; + if (!endpoint) return null; + + // The request has to carry a token, so the server's own origin needs + // vouching, and getIssuer reads the issuer back out of the session. + allowOrigin(endpoint); + setCurrentSession({ webId: null, issuer }); + + try { + const response = await getAuthFetch()(endpoint, { + headers: { accept: "application/json" }, + }); + if (!response.ok) return null; + + const claims: unknown = await response.json(); + const webId = (claims as { webid?: unknown }).webid; + return typeof webId === "string" ? webId : null; + } catch (error) { + console.warn(`Could not read a WebID from ${endpoint}`, error); + return null; + } +} diff --git a/app/lib/helpers/contactUtils.ts b/app/lib/helpers/contactUtils.ts index e97d73b..6606b97 100644 --- a/app/lib/helpers/contactUtils.ts +++ b/app/lib/helpers/contactUtils.ts @@ -14,15 +14,13 @@ export interface Contact { * @returns Array of contacts with their WebID, name, and email */ export async function fetchUserContacts(): Promise { - const session = getCurrentSession(); + const userWebId = getCurrentSession()?.webId; - if (!session) { + if (!userWebId) { return []; } try { - const userWebId = session.webId; - const userProfile = await fetchAndParseProfile(userWebId); const contacts: Contact[] = []; @@ -62,13 +60,12 @@ export async function fetchUserContacts(): Promise { * @returns Promise that resolves when the contact is added */ export async function addContactToProfile(contactWebId: string): Promise { - const session = getCurrentSession(); + const userWebId = getCurrentSession()?.webId; - if (!session) { + if (!userWebId) { throw new Error("User is not logged in"); } - const userWebId = session.webId; const fetch = getAuthFetch(); const profileUrl = userWebId.split('#')[0]; From cc3f428e2ad28cbc724f21a1dc26b77c20adb063 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 10:49:17 +0100 Subject: [PATCH 31/38] perf: attach credentials before the first request --- app/lib/auth/manager.ts | 45 +++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts index b869109..1827539 100644 --- a/app/lib/auth/manager.ts +++ b/app/lib/auth/manager.ts @@ -90,19 +90,38 @@ async function createFetch(options: AuthManagerOptions) { "@solid/reactive-authentication" ); - const manager = new ReactiveFetchManager([ - originGated( - new DPoPTokenProvider(options.callbackUri, options.getCode, async () => { - const session = getCurrentSession(); - if (!session) throw new Error("No issuer known: sign in first"); - return new URL(session.issuer); - }), - ), - ]); - - // manager.fetch returns a newly bound function on every read, which React - // would treat as a changed dependency. - authFetch = manager.fetch; + const provider = originGated( + new DPoPTokenProvider(options.callbackUri, options.getCode, async () => { + const session = getCurrentSession(); + if (!session) throw new Error("No issuer known: sign in first"); + return new URL(session.issuer); + }), + ); + + const manager = new ReactiveFetchManager([provider]); + + // Both read once and return a function, so we can bind them once and keep + // the result around. + const reactiveFetch = manager.fetch; + const plainFetch = globalThis.fetch.bind(globalThis); + + authFetch = async (input, init) => { + const request = new Request(input, init); + + if (getCurrentSession() && isAllowed(request.url)) { + try { + return await plainFetch(await provider.upgrade(request.clone())); + } catch (error) { + // Cancelled or unreachable. Send it bare rather than handing it to the + // manager, which would start a second flow for the same request. + console.warn("Could not attach credentials up front", error); + return plainFetch(request); + } + } + + return reactiveFetch(request); + }; + return authFetch; } From 3c848b9ca2466066552253dd7b24fb9967afe77a Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 14:27:25 +0100 Subject: [PATCH 32/38] fix: sync the browse URL without a route navigation --- app/components/LoginPage.tsx | 3 --- app/components/ProfileIcon.tsx | 3 --- app/lib/auth/discovery.ts | 35 +++++++++++++++++++++------------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index 3f34a58..a58f204 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import Image from "next/image"; -import { useRouter } from "next/navigation"; import { identify } from "../lib/auth/identify"; import { getAuthFetch, setCurrentSession } from "../lib/auth/manager"; import { resolveWebId } from "../lib/auth/userinfo"; @@ -37,7 +36,6 @@ const HELPER_TEXT = { } as const; export default function LoginPage() { - const router = useRouter(); const { login, signedOutFrom } = useSolidAuth(); const [entry, setEntry] = useState(""); @@ -94,7 +92,6 @@ export default function LoginPage() { saveLastLoginEntry(nextWebId); login({ webId: nextWebId, issuer: nextIssuer }); - router.replace("/"); } async function handleSubmit(event: FormEvent) { diff --git a/app/components/ProfileIcon.tsx b/app/components/ProfileIcon.tsx index e256563..1ee4cd5 100644 --- a/app/components/ProfileIcon.tsx +++ b/app/components/ProfileIcon.tsx @@ -1,13 +1,11 @@ "use client"; import { useState, useRef } from "react"; -import { useRouter } from "next/navigation"; import { useSolidAuth, useUserProfile, useClickOutside } from "../lib/hooks"; import { UserCircleIcon, ArrowRightStartOnRectangleIcon, PhoneIcon, BuildingOfficeIcon, BriefcaseIcon, GlobeAltIcon, ClipboardIcon } from "@heroicons/react/24/outline"; import toast from "react-hot-toast"; export default function ProfileIcon() { - const router = useRouter(); const { session, logout } = useSolidAuth(); const [showTooltip, setShowTooltip] = useState(false); const [showDropdown, setShowDropdown] = useState(false); @@ -27,7 +25,6 @@ export default function ProfileIcon() { const handleLogout = async () => { try { await logout(); - router.replace("/login"); } catch (error) { console.error("Logout failed:", error); toast.error("Failed to sign out"); diff --git a/app/lib/auth/discovery.ts b/app/lib/auth/discovery.ts index b99d46c..1eb763b 100644 --- a/app/lib/auth/discovery.ts +++ b/app/lib/auth/discovery.ts @@ -6,33 +6,42 @@ export interface DiscoveryDocument { const TIMEOUT_MS = 8000; -const cache = new Map(); +// Holds the in-flight request, not just the result, so concurrent callers share +// one round trip instead of each firing their own. +const cache = new Map>(); /** * Reads an authorization server's OIDC metadata. Returns null when the URL is * not an issuer, the login page uses it to tell an issuer apart from a WebID. */ -export async function fetchDiscovery( - issuer: string, -): Promise { +export function fetchDiscovery(issuer: string): Promise { const url = discoveryUrl(issuer); - if (!url) return null; + if (!url) return Promise.resolve(null); - const cached = cache.get(url); - if (cached) return cached; + let pending = cache.get(url); + if (!pending) { + pending = read(url); + cache.set(url, pending); + void pending.then( + (document) => { + if (!document) cache.delete(url); + }, + () => cache.delete(url), + ); + } + + return pending; +} + +async function read(url: string): Promise { try { const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); if (!response.ok) return null; const document: unknown = await response.json(); - if (!isDiscoveryDocument(document)) return null; - - cache.set(url, document); - return document; + return isDiscoveryDocument(document) ? document : null; } catch { - // Unreachable, blocked by CORS, timed out, or not JSON. For the caller - // these all mean the same thing, so only successes are worth caching. return null; } } From 1cc266b52e11d3ac67ddf673d5868037d295a879 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 14:27:54 +0100 Subject: [PATCH 33/38] fix: give each auth redirect a single owner --- app/components/FileManager.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 7cec485..afcc191 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -173,10 +173,14 @@ export default function FileManager() { setSelectedStorageId(matchingStorage.id); setCurrentPath(urlParam === matchingStorage.url ? "/" : urlParam); + // Sync the address bar without navigating. if (typeof window !== "undefined") { const params = new URLSearchParams(); params.set("url", safeEncodeUrl(urlParam)); - router.replace(`/?${params.toString()}`, { scroll: false }); + const search = `?${params.toString()}`; + if (window.location.search !== search) { + window.history.replaceState(null, "", `/${search}`); + } } setIsInitialized(true); @@ -187,7 +191,7 @@ export default function FileManager() { } setIsInitialized(true); - }, [searchParams, storages, isLoadingStorages, isInitialized, router]); + }, [searchParams, storages, isLoadingStorages, isInitialized]); useEffect(() => { return () => { From c3da6f1dec5dcfe9887d750e35d11cd8703f5425 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 14:53:22 +0100 Subject: [PATCH 34/38] fix: report a failure to attach credentials as an error --- app/lib/auth/manager.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts index 1827539..15e9322 100644 --- a/app/lib/auth/manager.ts +++ b/app/lib/auth/manager.ts @@ -112,9 +112,7 @@ async function createFetch(options: AuthManagerOptions) { try { return await plainFetch(await provider.upgrade(request.clone())); } catch (error) { - // Cancelled or unreachable. Send it bare rather than handing it to the - // manager, which would start a second flow for the same request. - console.warn("Could not attach credentials up front", error); + reportUpgradeFailure(request.url, error); return plainFetch(request); } } @@ -125,6 +123,20 @@ async function createFetch(options: AuthManagerOptions) { return authFetch; } +const reportedFailures = new Set(); + +// A failed upgrade means the request goes out unauthenticated and will almost +// certainly come back 401, which reads as a permissions problem rather than the +// configuration error it usually is. Report each distinct reason once: these +// repeat per request, and a hundred identical lines hide the one that matters. +function reportUpgradeFailure(url: string, error: unknown): void { + const reason = error instanceof Error ? error.message : String(error); + if (reportedFailures.has(reason)) return; + + reportedFailures.add(reason); + console.error(`Sending ${url} without credentials: ${reason}`, error); +} + export function getAuthFetch(): typeof globalThis.fetch { if (authFetch === null) { throw new Error( @@ -141,5 +153,6 @@ export function resetAuthManager(): void { authFetch = null; initialization = null; allowedOrigins.clear(); + reportedFailures.clear(); setCurrentSession(null); } From b249937cf25cf6975401e680c805543d801f6c7a Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 30 Jul 2026 15:18:35 +0100 Subject: [PATCH 35/38] chore: remove the reactive-auth spike harness --- app/spike/instruments.ts | 189 ------------------------------ app/spike/page.tsx | 245 --------------------------------------- 2 files changed, 434 deletions(-) delete mode 100644 app/spike/instruments.ts delete mode 100644 app/spike/page.tsx diff --git a/app/spike/instruments.ts b/app/spike/instruments.ts deleted file mode 100644 index c253366..0000000 --- a/app/spike/instruments.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Measurement instrumentation for the reactive-authentication spike. - * - * Wraps global fetch and window.open so we can count exactly what one - * authenticated request costs: how many OIDC round-trips, how many popups. - * - * Throwaway — lives under app/spike/ and is deleted with it. - */ - -export type RequestClass = "discovery" | "registration" | "token" | "resource"; - -export interface LogEntry { - seq: number; - method: string; - url: string; - requestClass: RequestClass; - status: number | null; - ms: number; - error?: string; -} - -export interface Summary { - total: number; - discovery: number; - registration: number; - token: number; - resource: number; - popupsOpened: number; - popupsBlocked: number; - totalMs: number; -} - -const DISCOVERY_PATH = "/.well-known/openid-configuration"; - -let installed = false; -let pristineFetch: typeof globalThis.fetch; -let nativeOpen: typeof window.open; - -let seq = 0; -let log: LogEntry[] = []; -let popupsOpened = 0; -let popupsBlocked = 0; -let summaryCache: Summary | null = null; - -/** - * Endpoints learned from discovery responses, used to classify later requests. - * Deliberately not cleared by resetLog() so classification stays accurate - * across experiments. - */ -const learned = new Map(); - -const listeners = new Set<() => void>(); - -function notify(): void { - summaryCache = null; - for (const listener of listeners) { - listener(); - } -} - -function stripQuery(url: string): string { - const index = url.indexOf("?"); - return index === -1 ? url : url.slice(0, index); -} - -function urlOf(input: RequestInfo | URL): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.href; - return input.url; -} - -function methodOf(input: RequestInfo | URL, init?: RequestInit): string { - if (init?.method) return init.method.toUpperCase(); - if (input instanceof Request) return input.method.toUpperCase(); - return "GET"; -} - -function classify(url: string): RequestClass { - if (url.includes(DISCOVERY_PATH)) return "discovery"; - return learned.get(stripQuery(url)) ?? "resource"; -} - -/** Reads endpoint URLs out of a discovery document so we can label what follows. */ -async function learnEndpoints(response: Response): Promise { - try { - const config = await response.clone().json(); - if (typeof config.registration_endpoint === "string") { - learned.set(stripQuery(config.registration_endpoint), "registration"); - } - if (typeof config.token_endpoint === "string") { - learned.set(stripQuery(config.token_endpoint), "token"); - } - } catch { - // Unreadable discovery document; classification falls back to "resource". - } -} - -async function instrumentedFetch( - input: RequestInfo | URL, - init?: RequestInit, -): Promise { - const url = urlOf(input); - const start = performance.now(); - const entry: LogEntry = { - seq: ++seq, - method: methodOf(input, init), - url, - requestClass: "resource", - status: null, - ms: 0, - }; - - try { - const response = await pristineFetch(input, init); - if (url.includes(DISCOVERY_PATH)) { - await learnEndpoints(response); - } - entry.status = response.status; - entry.requestClass = classify(url); - return response; - } catch (error) { - entry.error = error instanceof Error ? error.message : String(error); - throw error; - } finally { - entry.ms = Math.round(performance.now() - start); - log = log.concat(entry); - notify(); - } -} - -function instrumentedOpen( - ...args: Parameters -): Window | null { - const opened = nativeOpen(...args); - if (opened === null) { - popupsBlocked++; - } else { - popupsOpened++; - } - notify(); - return opened; -} - -/** Installs the wrappers. Idempotent, and a no-op during server rendering. */ -export function installInstrument(): void { - if (installed || typeof window === "undefined") return; - installed = true; - - pristineFetch = globalThis.fetch.bind(globalThis); - globalThis.fetch = instrumentedFetch; - - nativeOpen = window.open.bind(window); - window.open = instrumentedOpen; -} - -export function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -export function getLog(): readonly LogEntry[] { - return log; -} - -export function getSummary(): Summary { - if (summaryCache) return summaryCache; - - summaryCache = { - total: log.length, - discovery: log.filter((e) => e.requestClass === "discovery").length, - registration: log.filter((e) => e.requestClass === "registration").length, - token: log.filter((e) => e.requestClass === "token").length, - resource: log.filter((e) => e.requestClass === "resource").length, - popupsOpened, - popupsBlocked, - totalMs: log.reduce((sum, e) => sum + e.ms, 0), - }; - return summaryCache; -} - -export function resetLog(): void { - log = []; - seq = 0; - popupsOpened = 0; - popupsBlocked = 0; - notify(); -} diff --git a/app/spike/page.tsx b/app/spike/page.tsx deleted file mode 100644 index 7e834cd..0000000 --- a/app/spike/page.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client"; - -import { useCallback, useState, useSyncExternalStore } from "react"; -import { allowOrigin, getAuthFetch } from "../lib/auth/manager"; -import { useSolidAuth } from "../lib/hooks/useSolidAuth"; -import { - getLog, - getSummary, - installInstrument, - resetLog, - subscribe, - type LogEntry, - type Summary, -} from "./instruments"; - -// Runs at import time so the manager, built in SolidAuthProvider's effect, -// captures the instrumented fetch. Load /spike directly rather than navigating -// to it from another route. -installInstrument(); - -const DEFAULT_WEB_ID = "https://id.inrupt.com/preciousoritsedere"; -const DEFAULT_ISSUER = "https://login.inrupt.com"; -const DEFAULT_RESOURCE = "https://storage.inrupt.com/0260f9c0-5d12-446e-abe2-7ab651343d77/"; - -/** Kept deliberately small — each 401 may register a new client on a real server. */ -const BATCH = 5; - -/** Must exceed the browser's ~5s transient activation window. */ -const NO_GESTURE_DELAY_MS = 6000; - -const EMPTY_LOG: readonly LogEntry[] = []; -const EMPTY_SUMMARY: Summary = { - total: 0, - discovery: 0, - registration: 0, - token: 0, - resource: 0, - popupsOpened: 0, - popupsBlocked: 0, - totalMs: 0, -}; - -export default function SpikePage() { - const { session, status, login, logout } = useSolidAuth(); - - const [webId, setWebId] = useState(DEFAULT_WEB_ID); - const [issuer, setIssuer] = useState(DEFAULT_ISSUER); - const [resourceUrl, setResourceUrl] = useState(DEFAULT_RESOURCE); - const [result, setResult] = useState(""); - const [running, setRunning] = useState(false); - - const log = useSyncExternalStore(subscribe, getLog, () => EMPTY_LOG); - const summary = useSyncExternalStore(subscribe, getSummary, () => EMPTY_SUMMARY); - - const fetchResource = useCallback( - async (vouch = true) => { - if (vouch) allowOrigin(resourceUrl); - // no-store keeps the HTTP cache from hiding a 401 -> auth -> 200 cycle. - const response = await getAuthFetch()(resourceUrl, { cache: "no-store" }); - await response.arrayBuffer(); - return response.status; - }, - [resourceUrl], - ); - - const run = useCallback(async (label: string, experiment: () => Promise) => { - setRunning(true); - resetLog(); - setResult(`running ${label}…`); - const started = performance.now(); - try { - await experiment(); - setResult(`${label} — done in ${Math.round(performance.now() - started)}ms`); - } catch (error) { - setResult(`${label} — failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - setRunning(false); - } - }, []); - - const experiments: { key: string; label: string; go: () => Promise }[] = [ - { - key: "A", - label: "A. fetch once", - go: async () => { - await fetchResource(); - }, - }, - { - key: "B", - label: `B. fetch ${BATCH}x sequentially`, - go: async () => { - for (let i = 0; i < BATCH; i++) { - await fetchResource(); - } - }, - }, - { - key: "C", - label: `C. fetch ${BATCH}x in parallel`, - go: async () => { - await Promise.all(Array.from({ length: BATCH }, () => fetchResource())); - }, - }, - { - key: "D", - label: `D. fetch after ${NO_GESTURE_DELAY_MS / 1000}s (no gesture)`, - go: async () => { - await new Promise((resolve) => setTimeout(resolve, NO_GESTURE_DELAY_MS)); - await fetchResource(); - }, - }, - { - key: "F", - // Signing in vouches for the WebID's origin only. On providers where - // the pod lives elsewhere, the pod stays unvouched until something - // calls allowOrigin for it. - label: "F. origin gate (no vouch)", - go: async () => { - await logout(); - login({ webId, issuer }); - const httpStatus = await fetchResource(false); - if (httpStatus !== 401) { - throw new Error(`expected 401 for an unvouched origin, got ${httpStatus}`); - } - }, - }, - ]; - - return ( -
-

reactive-authentication spike

- - - - - -
- - - - {status} — {session.webId ?? "no identity"} - -
- -
- {experiments.map((experiment) => ( - - ))} - -
- -

{result}

- -
- - - 1} /> - 1} /> - - 0} /> - -
- - - - - - - - - - - - - - {log.map((entry) => ( - - - - - - - - - ))} - -
#methodclassstatusmsurl
{entry.seq}{entry.method}{entry.requestClass}{entry.error ? "err" : entry.status}{entry.ms}{entry.error ?? entry.url}
-
- ); -} - -function Field({ - label, - value, - onChange, -}: { - label: string; - value: string; - onChange: (value: string) => void; -}) { - return ( - - ); -} - -function Stat({ label, value, warn }: { label: string; value: number; warn?: boolean }) { - return ( -
-
{label}:
-
{value}
-
- ); -} From a66259ca557a2a4c0d5ea392556843f1dffb55ca Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 31 Jul 2026 12:39:16 +0100 Subject: [PATCH 36/38] feat: sign in with only an issuer --- app/components/LoginPage.tsx | 64 +++++++++++++++++++++++++----------- app/lib/auth/identify.ts | 45 ++++++++++++++++++++----- app/lib/auth/manager.ts | 55 +++++++++++++++++++++++++++---- app/lib/auth/userinfo.ts | 34 ------------------- package-lock.json | 4 +-- package.json | 2 +- 6 files changed, 133 insertions(+), 71 deletions(-) delete mode 100644 app/lib/auth/userinfo.ts diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index a58f204..29d7f8c 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -3,8 +3,13 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import Image from "next/image"; import { identify } from "../lib/auth/identify"; -import { getAuthFetch, setCurrentSession } from "../lib/auth/manager"; -import { resolveWebId } from "../lib/auth/userinfo"; +import { fetchDiscovery } from "../lib/auth/discovery"; +import { + allowOrigin, + getAuthenticatedWebId, + getAuthFetch, + setCurrentSession, +} from "../lib/auth/manager"; import { getLastLoginEntry, saveLastLoginEntry, @@ -63,12 +68,33 @@ export default function LoginPage() { const needs = webId && !issuer ? "issuer" : issuer && !webId ? "webid" : "either"; + /** + * Nothing authenticates until a request to a vouched origin comes back 401, + * and which URL does that differs by server: a protected profile or pod root + * on CSS, the userinfo endpoint on ESS. Try each until a flow has run. + */ + async function authenticate(candidateWebId: string | null, candidateIssuer: string) { + const discovery = await fetchDiscovery(candidateIssuer); + const probes = [candidateWebId, discovery?.userinfo_endpoint, candidateIssuer]; + + for (const probe of probes) { + if (!probe) continue; + if (await getAuthenticatedWebId(candidateIssuer)) return; + + try { + await getAuthFetch()(probe); + } catch (error) { + console.warn(`Could not sign in via ${probe}`, error); + } + } + } + async function finish(nextWebId: string | null, nextIssuer: string | null) { setWebId(nextWebId); setIssuer(nextIssuer); - // Whichever half is still missing, the field relabels and asks for it. - if (!nextWebId || !nextIssuer) { + if (!nextIssuer) { + // Nowhere to sign in to yet; the field relabels and asks for one. setCurrentSession(null); setEntry(""); return; @@ -77,21 +103,24 @@ export default function LoginPage() { // Sign in here, while the submitting click is still the active gesture, so // the popup is not blocked and the session is cached before the file // manager mounts and fires a dozen requests. - const authenticated = (await resolveWebId(nextIssuer)) !== null; setCurrentSession({ webId: nextWebId, issuer: nextIssuer }); + allowOrigin(nextIssuer); + await authenticate(nextWebId, nextIssuer); - if (!authenticated) { - // No userinfo endpoint. Read the profile instead, which is protected - // on the servers that lack one. - try { - await getAuthFetch()(nextWebId); - } catch (error) { - console.warn("Could not sign in before entering the app", error); - } + // Solid-OIDC puts the WebID in the id_token, so an issuer on its own is + // enough: sign in first, then ask who that turned out to be. + const confirmedWebId = nextWebId ?? (await getAuthenticatedWebId(nextIssuer)); + + if (!confirmedWebId) { + setCurrentSession(null); + setEntry(""); + setError("That login server did not say who you are. Please enter your WebID."); + return; } - saveLastLoginEntry(nextWebId); - login({ webId: nextWebId, issuer: nextIssuer }); + setWebId(confirmedWebId); + saveLastLoginEntry(confirmedWebId); + login({ webId: confirmedWebId, issuer: nextIssuer }); } async function handleSubmit(event: FormEvent) { @@ -110,10 +139,7 @@ export default function LoginPage() { } if (target.kind === "issuer") { - // Sign in on the issuer alone where the server can tell us who - // that turned out to be; otherwise fall through to asking. - const known = webId ?? (await resolveWebId(target.issuer)); - await finish(known, target.issuer); + await finish(webId, target.issuer); return; } diff --git a/app/lib/auth/identify.ts b/app/lib/auth/identify.ts index a03afa3..8494a2d 100644 --- a/app/lib/auth/identify.ts +++ b/app/lib/auth/identify.ts @@ -1,6 +1,7 @@ import { DataFactory, Parser, Store } from "n3"; import { WebIdDataset } from "@/app/lib/class/WebIdDataset"; import { fetchDiscovery } from "./discovery"; +import { LOOPBACK_HOSTS } from "./manager"; const TIMEOUT_MS = 8000; @@ -14,31 +15,59 @@ export type LoginTarget = * questions at once rather than guessing from the URL's shape */ export async function identify(input: string): Promise { - const url = withScheme(input.trim()); - if (!url) return { kind: "unknown" }; + for (const url of candidateUrls(input)) { + const target = await identifyOne(url); + if (target.kind !== "unknown") return target; + } + return { kind: "unknown" }; +} +async function identifyOne(url: string): Promise { // The profile goes first so a WebID that names its issuer never triggers the // discovery request, which would 404 and litter the console on every login. const issuers = await probeProfile(url); if (issuers?.length) return { kind: "webid", webId: url, issuers }; + // An issuer identifier carries no fragment (OIDC Discovery §3), so a WebID + // ending in #me is not worth asking about — the request would 404, or 401 on + // a server that protects the whole path. + // // A CSS pod root parses as Turtle with no issuer in it, so discovery has to // win over an empty profile or the root would be mistaken for a WebID. - const discovery = await fetchDiscovery(url); + const discovery = url.includes("#") ? null : await fetchDiscovery(url); if (discovery) return { kind: "issuer", issuer: discovery.issuer }; if (issuers) return { kind: "webid", webId: url, issuers: [] }; return { kind: "unknown" }; } -function withScheme(input: string): string | null { - if (!input) return null; - const candidate = /^https?:\/\//i.test(input) ? input : `https://${input}`; +/** + * The URLs worth trying for one input. + * + * A bare host gets https, except on loopback: a local Community Solid Server + * serves plain HTTP, so defaulting to TLS there just fails to connect. Both + * schemes are tried for loopback, http first, whichever was typed. + */ +function candidateUrls(input: string): string[] { + const trimmed = input.trim(); + if (!trimmed) return []; + + const scheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + + let url: URL; try { - return new URL(candidate).href; + url = new URL(scheme); } catch { - return null; + return []; } + + if (!LOOPBACK_HOSTS.has(url.hostname)) return [url.href]; + + const insecure = new URL(url.href); + insecure.protocol = "http:"; + const secure = new URL(url.href); + secure.protocol = "https:"; + return [insecure.href, secure.href]; } /** diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts index 15e9322..7f0975b 100644 --- a/app/lib/auth/manager.ts +++ b/app/lib/auth/manager.ts @@ -13,6 +13,7 @@ export interface AuthManagerOptions { let authFetch: typeof globalThis.fetch | null = null; let initialization: Promise | null = null; +let sessionWebId: ((issuer: string) => Promise) | null = null; // Who we are currently authenticating as. Held here rather than in React so // that plain modules — the session facade, the profile and contact helpers — @@ -25,6 +26,8 @@ const sessionListeners = new Set<() => void>(); // credentials go only to origins named here. const allowedOrigins = new Set(); +export const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + export function allowOrigin(url: string): void { let origin: string; try { @@ -86,20 +89,42 @@ export function initAuthManager( async function createFetch(options: AuthManagerOptions) { // Kept out of the top-level imports because the package declares classes extending // HTMLElement at import time, which throws during server rendering. - const { DPoPTokenProvider, ReactiveFetchManager } = await import( - "@solid/reactive-authentication" - ); + const { DPoPTokenProvider, InsecureConfiguration, ReactiveFetchManager } = + await import("@solid/reactive-authentication"); - const provider = originGated( - new DPoPTokenProvider(options.callbackUri, options.getCode, async () => { + let insecureAllowed = false; + + const tokenProvider = new DPoPTokenProvider( + options.callbackUri, + options.getCode, + async () => { const session = getCurrentSession(); if (!session) throw new Error("No issuer known: sign in first"); - return new URL(session.issuer); - }), + + const issuer = new URL(session.issuer); + + // oauth4webapi refuses http:// issuers outright, which puts a local + // Community Solid Server out of reach. Its opt-out is a global switch, so + // it is flipped only for a loopback host and only once: a remote issuer + // must never be able to drop to plain HTTP. + if ( + !insecureAllowed && + issuer.protocol === "http:" && + LOOPBACK_HOSTS.has(issuer.hostname) + ) { + InsecureConfiguration.allow(); + insecureAllowed = true; + } + + return issuer; + }, ); + const provider = originGated(tokenProvider); const manager = new ReactiveFetchManager([provider]); + sessionWebId = (issuer) => tokenProvider.webId(new URL(issuer)); + // Both read once and return a function, so we can bind them once and keep // the result around. const reactiveFetch = manager.fetch; @@ -137,6 +162,21 @@ function reportUpgradeFailure(url: string, error: unknown): void { console.error(`Sending ${url} without credentials: ${reason}`, error); } +/** + * The WebID the issuer asserted in its id_token, once a flow has completed for + * it. Null before that, and asking never starts one — so signing in with only an + * issuer means authenticating first, then asking who that turned out to be. + */ +export async function getAuthenticatedWebId(issuer: string): Promise { + if (sessionWebId === null) return null; + + try { + return (await sessionWebId(issuer)) ?? null; + } catch { + return null; + } +} + export function getAuthFetch(): typeof globalThis.fetch { if (authFetch === null) { throw new Error( @@ -152,6 +192,7 @@ export function getAuthFetch(): typeof globalThis.fetch { export function resetAuthManager(): void { authFetch = null; initialization = null; + sessionWebId = null; allowedOrigins.clear(); reportedFailures.clear(); setCurrentSession(null); diff --git a/app/lib/auth/userinfo.ts b/app/lib/auth/userinfo.ts deleted file mode 100644 index 24577e3..0000000 --- a/app/lib/auth/userinfo.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { fetchDiscovery } from "./discovery"; -import { allowOrigin, getAuthFetch, setCurrentSession } from "./manager"; - -/** - * Asks an authorization server who the user is, so signing in with only an - * issuer still yields the WebID the file manager needs to find a pod. - * - * Returns null when the server has no userinfo endpoint — CSS-backed servers do - * not publish one — leaving the login page to ask for the WebID instead. - */ -export async function resolveWebId(issuer: string): Promise { - const document = await fetchDiscovery(issuer); - const endpoint = document?.userinfo_endpoint; - if (!endpoint) return null; - - // The request has to carry a token, so the server's own origin needs - // vouching, and getIssuer reads the issuer back out of the session. - allowOrigin(endpoint); - setCurrentSession({ webId: null, issuer }); - - try { - const response = await getAuthFetch()(endpoint, { - headers: { accept: "application/json" }, - }); - if (!response.ok) return null; - - const claims: unknown = await response.json(); - const webId = (claims as { webid?: unknown }).webid; - return typeof webId === "string" ? webId : null; - } catch (error) { - console.warn(`Could not read a WebID from ${endpoint}`, error); - return null; - } -} diff --git a/package-lock.json b/package-lock.json index c066c71..0d12881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "^1.23.1", "@rdfjs/wrapper": "^0.33.0", - "@solid/reactive-authentication": "github:solid-contrib/reactive-authentication#feat/dpop-session-cache-installable", + "@solid/reactive-authentication": "github:PreciousOritsedere/reactive-authentication", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", @@ -7570,7 +7570,7 @@ }, "node_modules/@solid/reactive-authentication": { "version": "0.1.5", - "resolved": "git+ssh://git@github.com/solid-contrib/reactive-authentication.git#0e6149e8a64489cbee5434f3d39891e1b8fbf31f", + "resolved": "git+ssh://git@github.com/PreciousOritsedere/reactive-authentication.git#9c654b371529853670247d1236ea97d8f8c58bf0", "license": "MIT", "dependencies": { "@rdfjs/wrapper": "^0.34", diff --git a/package.json b/package.json index a58d0a7..8824f95 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "^1.23.1", "@rdfjs/wrapper": "^0.33.0", - "@solid/reactive-authentication": "github:solid-contrib/reactive-authentication#feat/dpop-session-cache-installable", + "@solid/reactive-authentication": "github:PreciousOritsedere/reactive-authentication", "jszip": "^3.10.1", "n3": "^2.0.1", "next": "16.0.7", From fc2657912cd67a34ec07bd2f13e6017eb8d5688a Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 31 Jul 2026 13:46:15 +0100 Subject: [PATCH 37/38] fix: stop the login field and CSS script implying the wrong defaults --- app/components/LoginPage.tsx | 1 - package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/LoginPage.tsx b/app/components/LoginPage.tsx index 29d7f8c..5af6740 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -181,7 +181,6 @@ export default function LoginPage() { options={options} error={error ?? undefined} disabled={isResolving} - placeholder="https://" aria-describedby="login-entry-help" /> {/* A live region: the label and this text change when we diff --git a/package.json b/package.json index 8824f95..ba520d3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build:next": "next build", "start": "next start", "start:dev": "concurrently \"npm run start:css\" \"npm run dev\"", - "start:css": "npx @solid/community-server --port 3000 --loggingLevel info --config .cssconfig.json --rootFilePath data/ --seedConfig seed-config.json", + "start:css": "npx @solid/community-server --port ${CSS_PORT:-3000} --loggingLevel info --config .cssconfig.json --rootFilePath data/ --seedConfig seed-config.json", "lint": "eslint" }, "dependencies": { From 911bafa5d585235c3a3df1347d21a4abbdd0fa4d Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 31 Jul 2026 13:46:44 +0100 Subject: [PATCH 38/38] fix: clear the lint backlog --- app/components/DeleteConfirmDialog.tsx | 2 +- app/components/FileItemMenu.tsx | 34 +++++++++++------------- app/components/FileList.tsx | 2 -- app/components/FileManager.tsx | 7 ++--- app/components/FileUploadHandler.tsx | 6 +++-- app/components/Header.tsx | 4 +-- app/components/NewMenuButton.tsx | 2 -- app/components/PreviewModal.tsx | 11 +++++--- app/components/ProfileIcon.tsx | 3 +++ app/components/ShareSuccessModal.tsx | 12 --------- app/components/Sidebar.tsx | 3 --- app/components/shared/Input.tsx | 7 +++-- app/components/shared/LoadingSpinner.tsx | 2 -- app/lib/helpers/acpUtils.ts | 5 ++-- app/lib/helpers/dragDropUtils.ts | 21 ++++++++++----- app/lib/helpers/errorUtils.ts | 15 +++++++++++ app/lib/helpers/uploadUtils.ts | 5 ++-- app/lib/helpers/urlUtils.ts | 6 ++--- app/lib/types/webkitdirectory.d.ts | 12 +++++++++ 19 files changed, 90 insertions(+), 69 deletions(-) create mode 100644 app/lib/helpers/errorUtils.ts create mode 100644 app/lib/types/webkitdirectory.d.ts diff --git a/app/components/DeleteConfirmDialog.tsx b/app/components/DeleteConfirmDialog.tsx index fa1ed95..d3e8bcc 100644 --- a/app/components/DeleteConfirmDialog.tsx +++ b/app/components/DeleteConfirmDialog.tsx @@ -55,7 +55,7 @@ export default function DeleteConfirmDialog({

Are you sure you want to delete{" "} - "{file.name}"? + “{file.name}”?

{file.type === "folder" && (

diff --git a/app/components/FileItemMenu.tsx b/app/components/FileItemMenu.tsx index 0986ab1..440b29b 100644 --- a/app/components/FileItemMenu.tsx +++ b/app/components/FileItemMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef } from "react"; import Button from "./shared/Button"; import { EllipsisVerticalIcon, @@ -15,6 +15,20 @@ import { import { useClickOutside } from "../lib/hooks"; import { FileItemData } from "./FileItem"; +/** Roughly how tall the menu renders; used to decide which way it opens. */ +const ESTIMATED_MENU_HEIGHT = 250; + +/** Opens upward only when the menu would not fit below but would above. */ +function placementFor(button: HTMLElement | null): "top" | "bottom" { + if (!button) return "bottom"; + + const { top, bottom } = button.getBoundingClientRect(); + const spaceBelow = window.innerHeight - bottom; + return spaceBelow < ESTIMATED_MENU_HEIGHT && top > ESTIMATED_MENU_HEIGHT + ? "top" + : "bottom"; +} + interface FileItemMenuProps { file: FileItemData; onRename?: (file: FileItemData) => void; @@ -49,23 +63,6 @@ export default function FileItemMenu({ refs: [menuRef, menuButtonRef], }); - useEffect(() => { - if (showMenu && menuButtonRef.current) { - const buttonRect = menuButtonRef.current.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const spaceBelow = viewportHeight - buttonRect.bottom; - const spaceAbove = buttonRect.top; - const estimatedMenuHeight = 250; // Approximate height of the menu - - // If there's not enough space below but enough space above, show menu above - if (spaceBelow < estimatedMenuHeight && spaceAbove > estimatedMenuHeight) { - setMenuPosition("top"); - } else { - setMenuPosition("bottom"); - } - } - }, [showMenu]); - const handleAction = (action: ((file: FileItemData) => void) | undefined) => { if (action) { action(file); @@ -143,6 +140,7 @@ export default function FileItemMenu({ aria-expanded={showMenu} onClick={(e) => { e.stopPropagation(); + if (!showMenu) setMenuPosition(placementFor(menuButtonRef.current)); setShowMenu(!showMenu); }} className={position === "top-right" ? "bg-white/90 hover:bg-white shadow-sm" : ""} diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index 74362ef..26d6750 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -7,7 +7,6 @@ import EmptyState from "./shared/EmptyState"; interface FileListProps { files: FileItemData[]; - currentPath: string; onFileSelect: (file: FileItemData) => void; onFileDoubleClick: (file: FileItemData) => void; onFileRename?: (file: FileItemData) => void; @@ -25,7 +24,6 @@ const VIEW_STORAGE_KEY = "solid-file-manager-view"; export default function FileList({ files, - currentPath, onFileSelect, onFileDoubleClick, onFileRename, diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index afcc191..3f87ef9 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -44,7 +44,6 @@ import { deleteFolderResource, uploadFilesToContainer, uploadFolderFilesToContainer, - FolderUploadFile, processDragDropItems, hasFiles as hasFilesInDrag, isUnsupportedFolderDrag, @@ -516,6 +515,7 @@ export default function FileManager() { try { fetchFn = getAuthFetch(); } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); return; } @@ -830,14 +830,12 @@ export default function FileManager() { >

setSidebarOpen(true)} - sidebarOpen={sidebarOpen} />
setSidebarOpen(false)} activeTab="my-storages" - currentContainerUrl={containerUrlToBrowse} onNewFolderClick={() => setShowNewFolderDialog(true)} onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} @@ -857,7 +855,6 @@ export default function FileManager() {
setShowNewFolderDialog(false)} - currentContainerUrl={containerUrlToBrowse} onFolderCreated={handleFolderCreated} /> ({ file, - relativePath: (file as any).webkitRelativePath || file.name, + relativePath: file.webkitRelativePath || file.name, })) .filter((item) => item.relativePath && item.relativePath.length > 0); @@ -164,7 +166,7 @@ export default function FileUploadHandler({ void; - sidebarOpen?: boolean; } -export default function Header({ onMenuClick, sidebarOpen = false }: HeaderProps) { +export default function Header({ onMenuClick }: HeaderProps) { const [searchQuery, setSearchQuery] = useState(""); return ( diff --git a/app/components/NewMenuButton.tsx b/app/components/NewMenuButton.tsx index 7aad943..5d3efea 100644 --- a/app/components/NewMenuButton.tsx +++ b/app/components/NewMenuButton.tsx @@ -6,14 +6,12 @@ import { useClickOutside } from "../lib/hooks"; import Button from "./shared/Button"; interface NewMenuButtonProps { - currentContainerUrl: string | null; onNewFolderClick?: () => void; onFileUploadClick?: () => void; onFolderUploadClick?: () => void; } export default function NewMenuButton({ - currentContainerUrl, onNewFolderClick, onFileUploadClick, onFolderUploadClick, diff --git a/app/components/PreviewModal.tsx b/app/components/PreviewModal.tsx index aff7c09..240c895 100644 --- a/app/components/PreviewModal.tsx +++ b/app/components/PreviewModal.tsx @@ -1,10 +1,14 @@ "use client"; +/* eslint-disable @next/next/no-img-element -- these render remote pod URLs, so + next/image would need every possible pod host in remotePatterns */ + import { useState, useEffect, useRef } from "react"; import Modal from "./shared/Modal"; import Button from "./shared/Button"; import { getFile, UrlString } from "@inrupt/solid-client"; import { getAuthFetch } from "../lib/auth/manager"; +import { httpStatusOf } from "../lib/helpers/errorUtils"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; @@ -67,9 +71,8 @@ export default function PreviewModal({ fileBlob = await getFile(file.url as UrlString, { fetch: fetchFn }); // Get the content-type from the blob's type property (set from HTTP response header) actualMimeType = fileBlob.type ? fileBlob.type.split(";")[0].trim() : ""; - } catch (getFileError: any) { - - const statusCode = getFileError?.response?.status; + } catch (getFileError) { + const statusCode = httpStatusOf(getFileError); const errorMessage = getFileError instanceof Error ? getFileError.message : String(getFileError); if (statusCode === 501 || @@ -94,7 +97,7 @@ export default function PreviewModal({ try { fileBlob = await response.blob(); actualMimeType = contentType.split(";")[0].trim(); - } catch (blobError) { + } catch { setFileType("other"); setIsLoading(false); return; diff --git a/app/components/ProfileIcon.tsx b/app/components/ProfileIcon.tsx index 1ee4cd5..daad491 100644 --- a/app/components/ProfileIcon.tsx +++ b/app/components/ProfileIcon.tsx @@ -1,5 +1,8 @@ "use client"; +/* eslint-disable @next/next/no-img-element -- these render remote pod URLs, so + next/image would need every possible pod host in remotePatterns */ + import { useState, useRef } from "react"; import { useSolidAuth, useUserProfile, useClickOutside } from "../lib/hooks"; import { UserCircleIcon, ArrowRightStartOnRectangleIcon, PhoneIcon, BuildingOfficeIcon, BriefcaseIcon, GlobeAltIcon, ClipboardIcon } from "@heroicons/react/24/outline"; diff --git a/app/components/ShareSuccessModal.tsx b/app/components/ShareSuccessModal.tsx index 2e0012c..b25d56c 100644 --- a/app/components/ShareSuccessModal.tsx +++ b/app/components/ShareSuccessModal.tsx @@ -32,18 +32,6 @@ export default function ShareSuccessModal({ } }; - // Safely extract origin from URL - const getServerOrigin = () => { - if (!resourceUrl) return ""; - try { - return new URL(resourceUrl).origin; - } catch { - return ""; - } - }; - - const serverOrigin = getServerOrigin(); - return ( void; activeTab?: string; - currentContainerUrl?: string | null; onNewFolderClick?: () => void; onFileUploadClick?: () => void; onFolderUploadClick?: () => void; @@ -21,7 +20,6 @@ export default function Sidebar({ isOpen = true, onClose, activeTab = "my-storages", - currentContainerUrl, onNewFolderClick, onFileUploadClick, onFolderUploadClick, @@ -79,7 +77,6 @@ export default function Sidebar({
{ label?: string; @@ -20,7 +20,10 @@ const Input = forwardRef(function Input({ id, ...props }, ref) { - const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`; + // useId, not a random value: a fresh id each render breaks the label's htmlFor + // pairing and never matches what the server rendered. + const generatedId = useId(); + const inputId = id || generatedId; const hasError = !!error; const baseInputClasses = diff --git a/app/components/shared/LoadingSpinner.tsx b/app/components/shared/LoadingSpinner.tsx index 8de61cf..a8b1412 100644 --- a/app/components/shared/LoadingSpinner.tsx +++ b/app/components/shared/LoadingSpinner.tsx @@ -1,7 +1,5 @@ "use client"; -import { SVGProps } from "react"; - interface LoadingSpinnerProps { size?: "sm" | "md" | "lg"; className?: string; diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index ad18ed3..a69c10b 100644 --- a/app/lib/helpers/acpUtils.ts +++ b/app/lib/helpers/acpUtils.ts @@ -9,6 +9,7 @@ */ import { getAuthFetch } from "../auth/manager"; +import { httpStatusOf } from "./errorUtils"; import { AcrDataset } from "@/app/lib/class/AcrDataset"; import type { DatasetCore } from "@rdfjs/types" import { AccessControlResource } from "@/app/lib/class/AccessControlResource" @@ -107,7 +108,7 @@ export async function detectServerAuthMethod( } return 'unknown'; - } catch (error) { + } catch { return 'unknown'; } } @@ -154,7 +155,7 @@ async function fetchAcr(acrUrl: string, fetchFn: typeof fetch): Promise Promise; +}; + interface FileSystemHandle { readonly kind: "file" | "directory"; readonly name: string; @@ -68,9 +76,10 @@ export async function processDragDropItems( for (const item of items) { // Check if File System Access API is available - if ((item as any).getAsFileSystemHandle) { + const handleSource = item as FileSystemAccessItem; + if (handleSource.getAsFileSystemHandle) { try { - const handle = (await (item as any).getAsFileSystemHandle()) as FileSystemHandle | null; + const handle = await handleSource.getAsFileSystemHandle(); if (handle && handle.kind === "directory") { hasDirectories = true; // Recursively read directory contents @@ -99,7 +108,7 @@ export async function processDragDropItems( // First pass: collect all relative paths and identify folder names Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as any).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath) { allRelativePaths.add(relativePath); // If path contains "/", extract the folder name (first part) @@ -114,7 +123,7 @@ export async function processDragDropItems( // Second pass: categorize files Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as any).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath && relativePath.includes("/")) { // File inside a folder folderFiles.push({ file, relativePath }); @@ -164,7 +173,7 @@ export function isUnsupportedFolderDrag(event: React.DragEvent): bo const allRelativePaths = new Set(); Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as any).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath) { allRelativePaths.add(relativePath); } @@ -174,7 +183,7 @@ export function isUnsupportedFolderDrag(event: React.DragEvent): bo return ( dataTransferFiles.length === 1 && allRelativePaths.size === 0 && - !(dataTransferFiles[0] as any).webkitRelativePath + !dataTransferFiles[0].webkitRelativePath ); } diff --git a/app/lib/helpers/errorUtils.ts b/app/lib/helpers/errorUtils.ts new file mode 100644 index 0000000..ab6a972 --- /dev/null +++ b/app/lib/helpers/errorUtils.ts @@ -0,0 +1,15 @@ +/** + * The HTTP status an error carries, or undefined when it carries none. + * + * @inrupt/solid-client reports the status on the error itself in some paths and + * under `response` in others, which is why callers kept reaching for `any`. + */ +export function httpStatusOf(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) return undefined; + + const direct = (error as { status?: unknown }).status; + if (typeof direct === "number") return direct; + + const nested = (error as { response?: { status?: unknown } }).response?.status; + return typeof nested === "number" ? nested : undefined; +} diff --git a/app/lib/helpers/uploadUtils.ts b/app/lib/helpers/uploadUtils.ts index 60243fa..d533b6b 100644 --- a/app/lib/helpers/uploadUtils.ts +++ b/app/lib/helpers/uploadUtils.ts @@ -1,6 +1,7 @@ // This helper file handles uploading files and folders to Solid containers. import { createContainerAt, overwriteFile, UrlString } from "@inrupt/solid-client"; import { ensureTrailingSlash, sanitizeResourceName } from "."; +import { httpStatusOf } from "./errorUtils"; import toast from "react-hot-toast"; export interface FolderUploadFile { @@ -125,8 +126,8 @@ async function ensureFolderExists( try { await createContainerAt(folderUrl as UrlString, { fetch: fetchFn }); - } catch (error: any) { - const statusCode = error?.response?.status; + } catch (error) { + const statusCode = httpStatusOf(error); const errorMessage = error instanceof Error ? error.message : String(error); if (statusCode === 409 || diff --git a/app/lib/helpers/urlUtils.ts b/app/lib/helpers/urlUtils.ts index 4834d2f..7c57886 100644 --- a/app/lib/helpers/urlUtils.ts +++ b/app/lib/helpers/urlUtils.ts @@ -15,12 +15,12 @@ export function extractNameFromUrl(url: string): string { try { name = decodeURIComponent(name); - } catch (e) { + } catch { // Keep original name if decoding fails } return name; - } catch (e) { + } catch { // If URL parsing fails, try to extract from the string directly const parts = url.split("/").filter(Boolean); const lastPart = parts[parts.length - 1] || url; @@ -41,7 +41,7 @@ export function extractNameFromUrl(url: string): string { export function resolveUrl(url: string, baseUrl: string): string { try { return new URL(url, baseUrl).href; - } catch (e) { + } catch { return url; } } diff --git a/app/lib/types/webkitdirectory.d.ts b/app/lib/types/webkitdirectory.d.ts new file mode 100644 index 0000000..0204a14 --- /dev/null +++ b/app/lib/types/webkitdirectory.d.ts @@ -0,0 +1,12 @@ +import "react"; + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the name must match React's own declaration for the merge to apply + interface InputHTMLAttributes { + /** + * Non-standard, but supported everywhere that matters: lets a file input + * pick a whole directory. React does not declare it. + */ + webkitdirectory?: string; + } +}