From 83465424fea4d046f14f3f6e761a2cf1f849fcf3 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:03:30 +0100 Subject: [PATCH 1/6] build: replace the auth stack with @solid/reactive-authentication Drops @inrupt/solid-client-authn-browser along with the @ldo/solid-react and solid-react-component wrappers in favour of @solid/reactive-authentication, which drives sign-in from custom elements rather than a React context we do not control. The authorization code flow runs in a popup, so it needs a static callback page to hand the code back to the opener. With solid-react-component gone there is nothing left for next.config to transpile, and the local Community Solid Server port becomes configurable so it can move off 3000 when that port is already taken. --- next.config.ts | 4 +- package-lock.json | 533 ++++++++++--------------------------------- package.json | 8 +- public/callback.html | 22 ++ 4 files changed, 143 insertions(+), 424 deletions(-) create mode 100644 public/callback.html 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 1c3e287..7c2f669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,14 @@ "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:PreciousOritsedere/reactive-authentication", "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.8" + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", @@ -324,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" ], @@ -6646,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", @@ -6690,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", @@ -6747,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", @@ -6881,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", @@ -7589,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", @@ -7818,6 +7568,29 @@ "url": "https://github.com/sponsors/rubensworks/" } }, + "node_modules/@solid/reactive-authentication": { + "version": "0.1.5", + "resolved": "git+ssh://git@github.com/PreciousOritsedere/reactive-authentication.git#9c654b371529853670247d1236ea97d8f8c58bf0", + "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", @@ -8081,6 +7854,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", @@ -8420,6 +8257,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": "*" @@ -8460,6 +8298,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": { @@ -9524,6 +9363,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": { @@ -10599,17 +10439,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", @@ -10658,12 +10487,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", @@ -10942,6 +10765,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", @@ -13278,6 +13110,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" @@ -13415,71 +13248,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", @@ -13516,58 +13284,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", @@ -14712,6 +14428,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", @@ -15237,15 +14962,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", @@ -15278,6 +14994,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" @@ -15679,6 +15396,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": "*", @@ -16119,6 +15837,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": { @@ -16348,15 +16067,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", @@ -16681,17 +16391,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", @@ -17766,6 +17465,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" @@ -18027,6 +17727,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 cf94c34..ba520d3 100644 --- a/package.json +++ b/package.json @@ -9,22 +9,20 @@ "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": { "@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:PreciousOritsedere/reactive-authentication", "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.8" + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", 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 ff6195939761a943dff15b7f66dc87b3c5adbf4d Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:03:31 +0100 Subject: [PATCH 2/6] feat: add the reactive auth core The auth manager owns the token provider and the authenticated fetch, and keeps the current session outside React so plain modules read the same value the UI renders. It attaches credentials only to origins the app has opted in, because the DPoP provider answers any 401 from any host and this app fetches URLs it does not control. Sign-in accepts either a WebID or a login server, so identify() asks the profile and OIDC discovery both rather than guessing from the shape of the URL. --- app/lib/auth/custom-elements.d.ts | 18 +++ app/lib/auth/discovery.ts | 67 ++++++++++ app/lib/auth/elements.ts | 17 +++ app/lib/auth/identify.ts | 104 ++++++++++++++++ app/lib/auth/manager.ts | 199 ++++++++++++++++++++++++++++++ 5 files changed, 405 insertions(+) create mode 100644 app/lib/auth/custom-elements.d.ts create mode 100644 app/lib/auth/discovery.ts create mode 100644 app/lib/auth/elements.ts create mode 100644 app/lib/auth/identify.ts create mode 100644 app/lib/auth/manager.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; + } + } +} diff --git a/app/lib/auth/discovery.ts b/app/lib/auth/discovery.ts new file mode 100644 index 0000000..1eb763b --- /dev/null +++ b/app/lib/auth/discovery.ts @@ -0,0 +1,67 @@ +export interface DiscoveryDocument { + issuer: string; + end_session_endpoint?: string; + userinfo_endpoint?: string; +} + +const TIMEOUT_MS = 8000; + +// 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 function fetchDiscovery(issuer: string): Promise { + const url = discoveryUrl(issuer); + if (!url) return Promise.resolve(null); + + 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(); + return isDiscoveryDocument(document) ? document : null; + } catch { + 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/elements.ts b/app/lib/auth/elements.ts new file mode 100644 index 0000000..408afb8 --- /dev/null +++ b/app/lib/auth/elements.ts @@ -0,0 +1,17 @@ +let registration: Promise | null = null; + +// 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 run in the browser: the element module extends HTMLElement at import time.", + ); + } + + registration ??= import("@solid/reactive-authentication/registerElements").then( + () => undefined, + ); + return registration; +} diff --git a/app/lib/auth/identify.ts b/app/lib/auth/identify.ts new file mode 100644 index 0000000..8494a2d --- /dev/null +++ b/app/lib/auth/identify.ts @@ -0,0 +1,104 @@ +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; + +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 { + 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 = 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" }; +} + +/** + * 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 { + url = new URL(scheme); + } catch { + 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]; +} + +/** + * 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; + } +} diff --git a/app/lib/auth/manager.ts b/app/lib/auth/manager.ts new file mode 100644 index 0000000..7f0975b --- /dev/null +++ b/app/lib/auth/manager.ts @@ -0,0 +1,199 @@ +import type { GetCodeCallback, TokenProvider } from "@solid/reactive-authentication"; + +export interface SolidCredentials { + // Null only while an issuer-first sign-in is asking userinfo who the user is. + webId: string | null; + issuer: string; +} + +export interface AuthManagerOptions { + callbackUri: string; + getCode: GetCodeCallback; +} + +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 — +// 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. +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 { + 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 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?.webId) 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 { + 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 because the package declares classes extending + // HTMLElement at import time, which throws during server rendering. + const { DPoPTokenProvider, InsecureConfiguration, ReactiveFetchManager } = + await import("@solid/reactive-authentication"); + + 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"); + + 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; + 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) { + reportUpgradeFailure(request.url, error); + return plainFetch(request); + } + } + + return reactiveFetch(request); + }; + + 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); +} + +/** + * 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( + "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; + sessionWebId = null; + allowedOrigins.clear(); + reportedFailures.clear(); + setCurrentSession(null); +} From 1838adf75de7055b8a2c2ed989bac3e97fd8a9b7 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:03:31 +0100 Subject: [PATCH 3/6] feat: add SolidAuthProvider and the useSolidAuth hook SolidAuthProvider mounts the authorization code element, restores a stored session on load and exposes login and logout to the tree, taking LdoProvider's place in the root layout. Components reach it through useSolidAuth, which throws when called outside the provider so a missing wrapper fails loudly instead of quietly rendering as signed out. --- app/components/providers/LdoProvider.tsx | 12 -- .../providers/SolidAuthProvider.tsx | 171 ++++++++++++++++++ app/layout.tsx | 6 +- app/lib/hooks/index.ts | 1 + app/lib/hooks/useSolidAuth.ts | 15 ++ 5 files changed, 190 insertions(+), 15 deletions(-) delete mode 100644 app/components/providers/LdoProvider.tsx create mode 100644 app/components/providers/SolidAuthProvider.tsx create mode 100644 app/lib/hooks/useSolidAuth.ts 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/components/providers/SolidAuthProvider.tsx b/app/components/providers/SolidAuthProvider.tsx new file mode 100644 index 0000000..09142bc --- /dev/null +++ b/app/components/providers/SolidAuthProvider.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { AuthorizationCodeFlow } from "@solid/reactive-authentication"; +import { ensureElementsRegistered } from "@/app/lib/auth/elements"; +import { + getCurrentSession, + initAuthManager, + resetAuthManager, + setCurrentSession, + subscribeToSession, + type SolidCredentials, +} from "@/app/lib/auth/manager"; + +const STORAGE_KEY = "solid-file-manager-session"; + +export type SolidAuthStatus = "restoring" | "anonymous" | "authenticated"; + +export interface SolidAuthContextValue { + session: { + isLoggedIn: boolean; + webId: string | null; + 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: { webId: string; issuer: string }) => void; + logout: () => Promise; +} + +export const SolidAuthContext = createContext(null); + +export default function SolidAuthProvider({ children }: { children: ReactNode }) { + const codeUiRef = useRef(null); + const generationRef = useRef(0); + + const credentials = useSyncExternalStore( + subscribeToSession, + getCurrentSession, + () => null, + ); + const [status, setStatus] = useState("restoring"); + const [signedOutFrom, setSignedOutFrom] = useState(null); + + 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); + }, + }), + [], + ); + + 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) setCurrentSession(stored); + setStatus(stored ? "authenticated" : "anonymous"); + } + + restore(); + }, [buildManager]); + + const login = useCallback((next: { webId: string; issuer: string }) => { + 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]); + + const value = useMemo( + () => ({ + session: { + isLoggedIn: status === "authenticated", + webId: credentials?.webId ?? null, + issuer: credentials?.issuer ?? null, + }, + status, + signedOutFrom, + login, + logout, + }), + [credentials, status, signedOutFrom, 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); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index f4b86b1..60800b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ 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({ variable: "--font-geist-sans", @@ -34,10 +34,10 @@ export default function RootLayout({ - + {children} - + ); 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 3ccadaecb791f31de8248093a016b8e19a4e5b4f Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:03:31 +0100 Subject: [PATCH 4/6] feat: rebuild the login page around WebID-or-issuer entry One field now takes either a WebID or a login server and works out which it is, so what we remember between visits is the entry the user typed rather than a choice from a list of identity providers. A profile may name several issuers, so IssuerPickerDialog asks which to use only when the choice is genuinely ambiguous. Signing out here cannot end the session at the login server, so SignedOutNotice offers that door rather than leaving the next person signed straight back in. --- app/components/IssuerPickerDialog.tsx | 53 +++++ app/components/LoginPage.tsx | 250 ++++++++++++++++++++--- app/components/SignedOutNotice.tsx | 58 ++++++ app/components/shared/FullPageLoader.tsx | 11 + app/components/shared/LoadingSpinner.tsx | 2 - app/lib/helpers/idpHistoryUtils.ts | 29 --- app/lib/helpers/loginHistoryUtils.ts | 23 +++ app/login/page.tsx | 37 ++-- 8 files changed, 376 insertions(+), 87 deletions(-) create mode 100644 app/components/IssuerPickerDialog.tsx create mode 100644 app/components/SignedOutNotice.tsx create mode 100644 app/components/shared/FullPageLoader.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 3be84d6..5af6740 100644 --- a/app/components/LoginPage.tsx +++ b/app/components/LoginPage.tsx @@ -1,44 +1,228 @@ "use client"; -import { useSyncExternalStore } from "react"; -import { useRouter } from "next/navigation"; -import { SolidLoginPage } from "solid-react-component/login/next"; -import { getLastIdp, saveLastIdp } from "../lib/helpers/idpHistoryUtils"; -function subscribeNoop() { - return () => { }; -} +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import Image from "next/image"; +import { identify } from "../lib/auth/identify"; +import { fetchDiscovery } from "../lib/auth/discovery"; +import { + allowOrigin, + getAuthenticatedWebId, + getAuthFetch, + setCurrentSession, +} from "../lib/auth/manager"; +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 isClient = useSyncExternalStore(subscribeNoop, () => true, () => false); - const defaultIssuer = useSyncExternalStore( - subscribeNoop, - () => getLastIdp(), - () => "", - ); + 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(() => { + const last = getLastLoginEntry(); + setEntry(last); + setRemembered(last); + }, []); + + 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 handleSubmit = (event: React.FormEvent) => { - const input = event.currentTarget.querySelector("input"); - if (input instanceof HTMLInputElement) { - saveLastIdp(input.value); + 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); + + if (!nextIssuer) { + // Nowhere to sign in to yet; the field relabels and asks for one. + setCurrentSession(null); + setEntry(""); + return; } - }; - // Client-only render so SolidLoginPage reads defaultIssuer on first mount. - if (!isClient) return null; + // 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. + setCurrentSession({ webId: nextWebId, issuer: nextIssuer }); + allowOrigin(nextIssuer); + await authenticate(nextWebId, nextIssuer); + + // 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; + } + + setWebId(confirmedWebId); + saveLastLoginEntry(confirmedWebId); + login({ webId: confirmedWebId, issuer: nextIssuer }); + } + + 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") { + await finish(webId, target.issuer); + return; + } + + if (target.issuers.length > 1) { + setWebId(target.webId); + setChoices(target.issuers); + return; + } + + await 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 +

+
+ +
+
+ + {/* 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([]); + setIsResolving(true); + void finish(webId, choice).finally(() => setIsResolving(false)); + }} + 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/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 ( +
+ +
+ ); +} 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/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); + } +} 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 c474f058eafad473d30e3ca3eb22b7039d2066af Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:03:32 +0100 Subject: [PATCH 5/6] refactor: route data access through the reactive fetch Every helper that talked to a pod went through getAuthenticatedSession(), which existed only to reach into the inrupt session. They now take the authenticated fetch from the auth manager, so that facade goes away entirely. Agent gains oidcIssuers because the login page has to read the issuers a profile advertises before there is any session at all. --- app/lib/class/Agent.ts | 6 +++ app/lib/helpers/acpUtils.ts | 10 ++--- app/lib/helpers/contactUtils.ts | 51 +++++++++++++++++++++++--- app/lib/helpers/dragDropUtils.ts | 31 ++++++++-------- app/lib/helpers/index.ts | 1 - app/lib/helpers/profileUtils.ts | 19 ++++++---- app/lib/helpers/sessionUtils.ts | 35 ------------------ app/lib/helpers/urlUtils.ts | 6 +-- app/lib/hooks/useBrowseStorage.ts | 4 +- app/lib/hooks/useSolidStorages.ts | 61 ++++++++++--------------------- app/lib/hooks/useUserProfile.ts | 9 ++--- 11 files changed, 112 insertions(+), 121 deletions(-) delete mode 100644 app/lib/helpers/sessionUtils.ts 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 } diff --git a/app/lib/helpers/acpUtils.ts b/app/lib/helpers/acpUtils.ts index 9f0d189..1833a27 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"; @@ -325,7 +325,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 @@ -361,7 +361,7 @@ export async function verifyResourceAccess(resourceUrl: string): Promise<{ error?: string; }> { try { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const headResponse = await fetch(resourceUrl, { method: "HEAD", @@ -421,7 +421,7 @@ export async function getResourceAccessList( accessModes: string[]; }> | null> { try { - const { fetch } = getAuthenticatedSession(); + const fetch = getAuthFetch(); const acrUrl = await getAcrUrl(resourceUrl, fetch); const response = await fetch(acrUrl, { @@ -498,7 +498,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 67ebf61..84e2189 100644 --- a/app/lib/helpers/contactUtils.ts +++ b/app/lib/helpers/contactUtils.ts @@ -1,5 +1,7 @@ import { fetchAndParseProfile } from "./profileUtils"; -import { getSession } from "./sessionUtils"; +import { getAuthFetch, getCurrentSession } from "../auth/manager"; +import { fromRdfJsDataset, saveSolidDatasetAt } from "@inrupt/solid-client"; + export interface Contact { webId: string; @@ -13,16 +15,19 @@ 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; + if (!userWebId) { + return []; + } - const userProfile = await fetchAndParseProfile(session.info.webId); + const userProfile = await fetchAndParseProfile(userWebId); const contacts: Contact[] = []; @@ -54,3 +59,39 @@ export async function fetchUserContacts(): Promise { return []; } } + +/** + * Adds a contact (WebID) to the user's profile using foaf:knows relationship + * @param contactWebId - The WebID of the contact to add + * @returns Promise that resolves when the contact is added + */ +export async function addContactToProfile(contactWebId: string): Promise { + const session = getCurrentSession(); + + if (!session) { + throw new Error("User is not logged in"); + } + + const userWebId = session.webId; + if (!userWebId) { + throw new Error("User WebID is not available"); + } + const fetch = getAuthFetch(); + + const profileUrl = userWebId.split('#')[0]; + + try { + // Fetch the user's profile dataset + // Get the main subject (usually WebID or WebID#me) + const mainSubject = await fetchAndParseProfile(profileUrl); + + // Add the foaf:knows relationship + mainSubject.knows.add(contactWebId); + + // Save the updated dataset + await saveSolidDatasetAt(profileUrl, fromRdfJsDataset(mainSubject.dataset), { fetch }); + } catch (error) { + console.error("Failed to add contact to profile:", error); + throw error; + } +} diff --git a/app/lib/helpers/dragDropUtils.ts b/app/lib/helpers/dragDropUtils.ts index 345c07e..68758eb 100644 --- a/app/lib/helpers/dragDropUtils.ts +++ b/app/lib/helpers/dragDropUtils.ts @@ -2,6 +2,14 @@ import { FolderUploadFile } from "./uploadUtils"; // Type definitions for File System Access API +/** + * The File System Access API's addition to DataTransferItem, which lib.dom does + * not declare yet. It is what makes a dropped folder readable. + */ +type FileSystemAccessItem = DataTransferItem & { + getAsFileSystemHandle?: () => Promise; +}; + interface FileSystemHandle { readonly kind: "file" | "directory"; readonly name: string; @@ -17,14 +25,6 @@ interface FileSystemDirectoryHandle extends FileSystemHandle { values(): AsyncIterableIterator; } -type DataTransferItemWithHandle = DataTransferItem & { - getAsFileSystemHandle?: () => Promise; -}; - -type FileWithRelativePath = File & { - webkitRelativePath?: string; -}; - // Helper function to recursively read directory contents using File System Access API async function readDirectoryRecursive( directoryHandle: FileSystemDirectoryHandle, @@ -75,11 +75,10 @@ export async function processDragDropItems( let hasDirectories = false; for (const item of items) { - const itemWithHandle = item as DataTransferItemWithHandle; - // Check if File System Access API is available - if (itemWithHandle.getAsFileSystemHandle) { + const handleSource = item as FileSystemAccessItem; + if (handleSource.getAsFileSystemHandle) { try { - const handle = await itemWithHandle.getAsFileSystemHandle(); + const handle = await handleSource.getAsFileSystemHandle(); if (handle && handle.kind === "directory") { hasDirectories = true; // Recursively read directory contents @@ -108,7 +107,7 @@ export async function processDragDropItems( // First pass: collect all relative paths and identify folder names Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as FileWithRelativePath).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath) { allRelativePaths.add(relativePath); // If path contains "/", extract the folder name (first part) @@ -123,7 +122,7 @@ export async function processDragDropItems( // Second pass: categorize files Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as FileWithRelativePath).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath && relativePath.includes("/")) { // File inside a folder folderFiles.push({ file, relativePath }); @@ -173,7 +172,7 @@ export function isUnsupportedFolderDrag(event: React.DragEvent): bo const allRelativePaths = new Set(); Array.from(dataTransferFiles).forEach((file) => { - const relativePath = (file as FileWithRelativePath).webkitRelativePath || ""; + const relativePath = file.webkitRelativePath || ""; if (relativePath) { allRelativePaths.add(relativePath); } @@ -183,7 +182,7 @@ export function isUnsupportedFolderDrag(event: React.DragEvent): bo return ( dataTransferFiles.length === 1 && allRelativePaths.size === 0 && - !(dataTransferFiles[0] as FileWithRelativePath).webkitRelativePath + !dataTransferFiles[0].webkitRelativePath ); } diff --git a/app/lib/helpers/index.ts b/app/lib/helpers/index.ts index 0256562..23b75e3 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/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/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 diff --git a/app/lib/hooks/useSolidStorages.ts b/app/lib/hooks/useSolidStorages.ts index 6cca340..dba990b 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"; @@ -90,12 +91,9 @@ 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 - const parentUrl = currentUrl.substring( - 0, - currentUrl.lastIndexOf("/", currentUrl.length - 2) + 1, - ); + } 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; } @@ -137,7 +135,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, @@ -151,7 +149,7 @@ async function discoverStorageViaTraversal( } } } catch (error) { - // Silent error handling + console.warn("Could not traverse the WebID hierarchy for a storage root", error); } return storageUrls; @@ -165,6 +163,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); @@ -180,61 +179,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 - type SessionWithFetch = typeof session & { fetch?: typeof fetch }; - const sessionWithFetch = session as SessionWithFetch; - const fetchFn = - typeof sessionWithFetch.fetch === "function" - ? sessionWithFetch.fetch.bind(sessionWithFetch) - : fetch; - const traversalStorages = await discoverStorageViaTraversal( - webId, - fetchFn, - ); - traversalStorages.forEach((url) => { + 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) => { @@ -259,7 +238,7 @@ export function useSolidStorages(): UseSolidStoragesResult { } } - if (session.isLoggedIn && session.webId) { + if (isLoggedIn && webId) { fetchStorages(); } else { setIsLoading(false); @@ -268,7 +247,7 @@ export function useSolidStorages(): UseSolidStoragesResult { 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 7f855446f20d19e0d6d15389be1f28dd2db56a2b Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 6 Aug 2026 10:03:32 +0100 Subject: [PATCH 6/6] refactor: guard routes with the app's own auth state AuthWrapper replaces solid-react-component's AuthGuard on the protected routes and fails closed: children mount only once a session is confirmed, so nothing below can fetch before there is anything to authenticate with. The components underneath read the WebID and the authenticated fetch directly instead of from the removed provider. --- app/components/AuthWrapper.tsx | 153 +----------- app/components/FileList.tsx | 2 - app/components/FileManager.tsx | 354 +++++++++++++-------------- app/components/FileUploadHandler.tsx | 22 +- app/components/Header.tsx | 4 +- app/components/MoveDialog.tsx | 7 +- app/components/NewFolderDialog.tsx | 4 +- app/components/NewMenuButton.tsx | 2 - app/components/PreviewModal.tsx | 7 +- app/components/ProfileIcon.tsx | 8 +- app/components/RenameDialog.tsx | 6 +- app/components/ShareSuccessModal.tsx | 12 - app/components/Sidebar.tsx | 3 - app/lib/types/webkitdirectory.d.ts | 12 + app/page.tsx | 30 +-- 15 files changed, 232 insertions(+), 394 deletions(-) create mode 100644 app/lib/types/webkitdirectory.d.ts diff --git a/app/components/AuthWrapper.tsx b/app/components/AuthWrapper.tsx index e95b16f..d54ed9d 100644 --- a/app/components/AuthWrapper.tsx +++ b/app/components/AuthWrapper.tsx @@ -1,152 +1,21 @@ "use client"; -import { useEffect, useState, useRef, 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); - 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 [sessionCheckComplete, setSessionCheckComplete] = useState(false); - const [oauthWaitExpired, setOauthWaitExpired] = useState(false); - const [hasSessionIndicator] = useState(() => hasSessionInStorage()); - const wasLoggedInRef = useRef(false); - - const isOAuthCallback = searchParams.has("code") || searchParams.has("state"); - const isLoginPage = pathname === "/login"; - const hasSessionData = !!(session.webId || session.sessionId || session.clientAppId); - - useEffect(() => { - if (session.isLoggedIn) { - wasLoggedInRef.current = true; - } - }, [session.isLoggedIn]); - - useEffect(() => { - if (!isOAuthCallback) { - return; - } - - if (session.isLoggedIn) { - const redirectTimer = setTimeout(() => { - if (typeof window !== "undefined") { - window.location.href = "/"; - } - }, 200); - return () => clearTimeout(redirectTimer); - } - - const maxWaitTimer = setTimeout(() => { - setOauthWaitExpired(true); - setSessionCheckComplete(true); - }, 10000); - return () => clearTimeout(maxWaitTimer); - }, [isOAuthCallback, session.isLoggedIn]); useEffect(() => { - if (isOAuthCallback) { - return; - } - - if (session.isLoggedIn) { - const timer = setTimeout(() => { - setSessionCheckComplete(true); - if (isLoginPage) { - router.replace("/"); - } - }, 0); - return () => clearTimeout(timer); - } - - if (wasLoggedInRef.current && !session.isLoggedIn) { - const timer = setTimeout(() => { - setSessionCheckComplete(true); - if (!isLoginPage) { - router.replace("/login"); - } - }, 0); - return () => clearTimeout(timer); - } - - const shouldWaitForRestore = hasSessionData || hasSessionIndicator; - const checkTimer = setTimeout(() => { - setSessionCheckComplete(true); - - if (!session.isLoggedIn && !isLoginPage) { - router.replace("/login"); - } - }, shouldWaitForRestore ? 2000 : 200); - - return () => clearTimeout(checkTimer); - }, [ - session.isLoggedIn, - session.webId, - session.sessionId, - isOAuthCallback, - hasSessionIndicator, - hasSessionData, - isLoginPage, - router, - ]); + if (status === "anonymous") router.replace("/login"); + }, [status, router]); - const showLoading = - (isOAuthCallback && !session.isLoggedIn && !oauthWaitExpired) || - !sessionCheckComplete; - - if (showLoading) { - return ( -
- -
- ); - } - - if (!session.isLoggedIn && !isLoginPage) { - return null; - } - - if (session.isLoggedIn && isLoginPage) { - return null; - } + // 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 ; return <>{children}; } - -export default function AuthWrapper({ children }: AuthWrapperProps) { - return ( - - - - } - > - {children} - - ); -} 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 04f6f82..3f87ef9 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"; @@ -14,7 +16,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,11 +31,11 @@ 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 { buildBreadcrumbItems, - getAuthenticatedSession, copyFileResource, copyFolderResource, downloadFile, @@ -43,7 +44,6 @@ import { deleteFolderResource, uploadFilesToContainer, uploadFolderFilesToContainer, - FolderUploadFile, processDragDropItems, hasFiles as hasFilesInDrag, isUnsupportedFolderDrag, @@ -172,10 +172,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); @@ -186,7 +190,7 @@ export default function FileManager() { } setIsInitialized(true); - }, [searchParams, storages, isLoadingStorages, isInitialized, router]); + }, [searchParams, storages, isLoadingStorages, isInitialized]); useEffect(() => { return () => { @@ -325,7 +329,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 +376,7 @@ export default function FileManager() { ); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); if (fileToDelete.type === "folder") { await deleteFolderResource(fileToDelete.url, fetchFn); @@ -415,7 +419,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,8 +513,9 @@ export default function FileManager() { let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); + fetchFn = getAuthFetch(); } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); return; } @@ -775,205 +780,188 @@ 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(true)} + /> +
+ setSidebarOpen(false)} + activeTab="my-storages" + onNewFolderClick={() => setShowNewFolderDialog(true)} + onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} + onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} /> -
- setSidebarOpen(false)} - activeTab="my-storages" - currentContainerUrl={containerUrlToBrowse} - onNewFolderClick={() => setShowNewFolderDialog(true)} - onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} - onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} - /> -
-
- +
+
+ +
+ {isBrowsing ? ( +
+
- {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 -

+ ) : ( +
+
+ )} +
+
+ + setShowNewFolderDialog(false)} + 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/FileUploadHandler.tsx b/app/components/FileUploadHandler.tsx index 398566d..bf01aa7 100644 --- a/app/components/FileUploadHandler.tsx +++ b/app/components/FileUploadHandler.tsx @@ -1,17 +1,15 @@ "use client"; -import { useRef, useEffect, type InputHTMLAttributes } from "react"; +import { getAuthFetch } from "../lib/auth/manager"; + +import { useRef, useEffect } from "react"; import toast from "react-hot-toast"; import { - getAuthenticatedSession, uploadFilesToContainer, uploadFolderFilesToContainer, FolderUploadFile, } from "../lib/helpers"; -type FileWithRelativePath = File & { - webkitRelativePath?: string; -} interface FileUploadHandlerProps { currentContainerUrl: string | null; onUploadComplete?: () => void; @@ -52,8 +50,9 @@ export default function FileUploadHandler({ let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); - } catch { + fetchFn = getAuthFetch(); + } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); e.target.value = ""; return; @@ -105,8 +104,9 @@ export default function FileUploadHandler({ let fetchFn: typeof fetch; try { - ({ fetch: fetchFn } = getAuthenticatedSession()); - } catch { + fetchFn = getAuthFetch(); + } catch (error) { + console.error("No authenticated fetch available", error); toast.error("Not authenticated"); e.target.value = ""; return; @@ -115,7 +115,7 @@ export default function FileUploadHandler({ const folderFiles: FolderUploadFile[] = Array.from(files) .map((file) => ({ file, - relativePath: (file as FileWithRelativePath).webkitRelativePath || file.name, + relativePath: file.webkitRelativePath || file.name, })) .filter((item) => item.relativePath && item.relativePath.length > 0); @@ -166,7 +166,7 @@ export default function FileUploadHandler({ )} + webkitdirectory="" multiple className="hidden" onChange={handleFolderChange} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index dcd219b..37c1e4d 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import Image from "next/image"; -import Button from "./shared/Button"; import Input from "./shared/Input"; import ProfileIcon from "./ProfileIcon"; import { @@ -12,10 +11,9 @@ import { interface HeaderProps { onMenuClick?: () => 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/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/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 84596ad..185f864 100644 --- a/app/components/PreviewModal.tsx +++ b/app/components/PreviewModal.tsx @@ -1,10 +1,13 @@ "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 { getAuthenticatedSession } from "../lib/helpers"; +import { getAuthFetch } from "../lib/auth/manager"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; import { getHttpStatus } from "../lib/helpers"; @@ -58,7 +61,7 @@ export default function PreviewModal({ setError(null); try { - const { fetch: fetchFn } = getAuthenticatedSession(); + const fetchFn = getAuthFetch(); let fileBlob: Blob; diff --git a/app/components/ProfileIcon.tsx b/app/components/ProfileIcon.tsx index 472230b..daad491 100644 --- a/app/components/ProfileIcon.tsx +++ b/app/components/ProfileIcon.tsx @@ -1,8 +1,10 @@ "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 } from "@ldo/solid-react"; -import { useUserProfile, useClickOutside } from "../lib/hooks"; +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"; @@ -26,8 +28,6 @@ export default function ProfileIcon() { const handleLogout = async () => { try { await logout(); - // Redirect to login page after logout - window.location.href = "/login"; } catch (error) { console.error("Logout failed:", error); toast.error("Failed to sign out"); 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/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({
{ + /** + * Non-standard, but supported everywhere that matters: lets a file input + * pick a whole directory. React does not declare it. + */ + webkitdirectory?: string; + } +} 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 ( - - - - - - + }> + + + ); }