From dc00e4d9f489a85283ef0a4402c32a1f2b8a3bd6 Mon Sep 17 00:00:00 2001 From: Brion Date: Mon, 19 Oct 2026 22:44:58 +0530 Subject: [PATCH] Move the quickstarts to the samples repository and add playgrounds The framework quickstarts now live in thunder-id/samples and use the published packages. The E2E suite fetches them through tests/e2e/scripts/prepare-samples.ts and installs them against tarballs of this workspace's builds. SDK releases no longer attach a quickstart archive. playgrounds/react and playgrounds/express take their place for exercising the SDKs from source: capability checks, a component explorer, hooks and token inspection for React, and an API explorer with an SDK trace for Express. Refs thunder-id/thunderid#5904 Refs thunder-id/thunderid#5919 Signed-off-by: Brion --- .github/actions/pack-sample/action.yml | 12 +- .github/actions/run-e2e-suite/action.yml | 51 +- .github/workflows/release.yml | 70 +- AGENTS.md | 11 +- README.md | 10 + eslint.config.js | 2 +- playgrounds/express/.env.example | 32 + playgrounds/express/.gitignore | 4 + playgrounds/express/.prettierignore | 4 + playgrounds/express/README.md | 71 + playgrounds/express/eslint.config.js | 30 + playgrounds/express/package.json | 32 + playgrounds/express/prettier.config.js | 6 + playgrounds/express/public/app.js | 404 ++++ playgrounds/express/public/favicon.svg | 1 + playgrounds/express/public/index.html | 179 ++ playgrounds/express/public/styles.css | 897 ++++++++ playgrounds/express/src/catalog.ts | 200 ++ playgrounds/express/src/config.ts | 39 + playgrounds/express/src/explorer.ts | 243 +++ playgrounds/express/src/helpers.ts | 17 + playgrounds/express/src/server.ts | 194 ++ playgrounds/express/src/trace.ts | 314 +++ playgrounds/express/tsconfig.eslint.json | 8 + playgrounds/express/tsconfig.json | 22 + playgrounds/express/turbo.json | 9 + playgrounds/react/.env.example | 22 + playgrounds/react/.gitignore | 5 + playgrounds/react/README.md | 70 + playgrounds/react/eslint.config.js | 11 + playgrounds/react/index.html | 18 + playgrounds/react/package.json | 35 + playgrounds/react/prettier.config.js | 6 + playgrounds/react/public/favicon.svg | 1 + playgrounds/react/src/App.tsx | 29 + .../react/src/components/ErrorBoundary.tsx | 45 + playgrounds/react/src/components/EventLog.tsx | 36 + playgrounds/react/src/components/Icons.tsx | 152 ++ .../react/src/components/SdkObserver.tsx | 67 + .../react/src/components/SessionCard.tsx | 155 ++ playgrounds/react/src/components/TopBar.tsx | 95 + playgrounds/react/src/config.ts | 39 + playgrounds/react/src/lib/events.ts | 81 + playgrounds/react/src/lib/health.ts | 64 + playgrounds/react/src/lib/inspect.ts | 31 + playgrounds/react/src/lib/jwt.ts | 47 + playgrounds/react/src/lib/router.ts | 25 + playgrounds/react/src/lib/sdk.ts | 39 + playgrounds/react/src/lib/session.ts | 99 + playgrounds/react/src/lib/user.ts | 38 + playgrounds/react/src/main.tsx | 25 + playgrounds/react/src/pages/Hooks.tsx | 217 ++ playgrounds/react/src/pages/Overview.tsx | 338 +++ playgrounds/react/src/pages/Tokens.tsx | 204 ++ playgrounds/react/src/pages/capabilities.ts | 489 +++++ .../react/src/pages/explorer/Explorer.tsx | 199 ++ .../react/src/pages/explorer/StoryWidgets.tsx | 83 + .../react/src/pages/explorer/Workbench.tsx | 295 +++ playgrounds/react/src/pages/explorer/a11y.ts | 235 +++ .../react/src/pages/explorer/stories.tsx | 1030 +++++++++ playgrounds/react/src/pages/explorer/types.ts | 41 + playgrounds/react/src/styles.css | 1280 ++++++++++++ playgrounds/react/src/vite-env.d.ts | 4 + playgrounds/react/tsconfig.json | 21 + playgrounds/react/vite.config.ts | 17 + pnpm-lock.yaml | 1844 +---------------- pnpm-workspace.yaml | 26 +- samples/browser/quickstart/.env.example | 5 - samples/browser/quickstart/.stackblitzrc | 4 - samples/browser/quickstart/README.md | 37 - samples/browser/quickstart/index.html | 12 - samples/browser/quickstart/package.json | 18 - .../quickstart/scripts/prepare-dev.cjs | 34 - samples/browser/quickstart/src/auth.js | 18 - .../browser/quickstart/src/components/nav.js | 171 -- .../src/components/profileFields.js | 305 --- samples/browser/quickstart/src/main.js | 101 - .../browser/quickstart/src/pages/account.js | 307 --- samples/browser/quickstart/src/pages/home.js | 321 --- samples/browser/quickstart/src/pages/token.js | 118 -- samples/browser/quickstart/src/style.css | 1595 -------------- samples/browser/quickstart/vite.config.js | 7 - samples/express/quickstart/.env.example | 11 - samples/express/quickstart/.stackblitzrc | 4 - samples/express/quickstart/README.md | 99 - samples/express/quickstart/index.mjs | 406 ---- samples/express/quickstart/lib/auth.mjs | 47 - .../express/quickstart/lib/expressLogo.mjs | 10 - samples/express/quickstart/lib/layout.mjs | 96 - .../express/quickstart/lib/thunderMark.mjs | 8 - samples/express/quickstart/package.json | 16 - ...Express-Quickstart.postman_collection.json | 62 - samples/express/quickstart/public/styles.css | 991 --------- .../quickstart/scripts/prepare-dev.cjs | 34 - samples/nextjs/quickstart/.env.example | 20 - samples/nextjs/quickstart/.stackblitzrc | 4 - samples/nextjs/quickstart/README.md | 117 -- .../nextjs/quickstart/app/account/page.tsx | 212 -- samples/nextjs/quickstart/app/actions.ts | 19 - .../app/api/auth/backchannel-logout/route.ts | 7 - .../app/components/ConfigNotice.tsx | 121 -- .../quickstart/app/components/HeroCtas.tsx | 16 - .../nextjs/quickstart/app/components/Nav.tsx | 102 - .../quickstart/app/components/ThunderMark.tsx | 36 - .../app/components/icons/NextLogo.tsx | 17 - samples/nextjs/quickstart/app/globals.css | 1521 -------------- samples/nextjs/quickstart/app/layout.tsx | 47 - samples/nextjs/quickstart/app/not-found.tsx | 13 - samples/nextjs/quickstart/app/page.tsx | 264 --- samples/nextjs/quickstart/app/signin/page.tsx | 30 - samples/nextjs/quickstart/app/signup/page.tsx | 27 - samples/nextjs/quickstart/app/token/page.tsx | 143 -- samples/nextjs/quickstart/next-env.d.ts | 6 - samples/nextjs/quickstart/next.config.ts | 5 - samples/nextjs/quickstart/package.json | 24 - samples/nextjs/quickstart/proxy.ts | 13 - .../nextjs/quickstart/scripts/prepare-dev.cjs | 93 - samples/nextjs/quickstart/tsconfig.json | 21 - samples/node/quickstart/.env.example | 14 - samples/node/quickstart/.stackblitzrc | 4 - samples/node/quickstart/README.md | 79 - samples/node/quickstart/index.mjs | 42 - samples/node/quickstart/lib/AuthService.mjs | 33 - .../node/quickstart/lib/InventoryService.mjs | 29 - samples/node/quickstart/lib/ui.mjs | 89 - samples/node/quickstart/package.json | 16 - .../node/quickstart/scripts/prepare-dev.cjs | 34 - samples/nuxt/quickstart/.env.example | 20 - samples/nuxt/quickstart/.stackblitzrc | 4 - samples/nuxt/quickstart/README.md | 119 -- samples/nuxt/quickstart/app/app.vue | 28 - samples/nuxt/quickstart/app/assets/styles.css | 1397 ------------- .../nuxt/quickstart/app/components/AppNav.vue | 82 - .../app/components/ConfigNotice.vue | 112 - samples/nuxt/quickstart/app/pages/account.vue | 162 -- samples/nuxt/quickstart/app/pages/index.vue | 234 --- samples/nuxt/quickstart/app/pages/signin.vue | 24 - samples/nuxt/quickstart/app/pages/signup.vue | 24 - samples/nuxt/quickstart/app/pages/token.vue | 125 -- samples/nuxt/quickstart/nuxt.config.ts | 11 - samples/nuxt/quickstart/package.json | 18 - .../nuxt/quickstart/scripts/prepare-dev.cjs | 88 - samples/react/quickstart/.env.example | 5 - samples/react/quickstart/.stackblitzrc | 4 - samples/react/quickstart/README.md | 36 - samples/react/quickstart/index.html | 13 - samples/react/quickstart/package.json | 23 - .../react/quickstart/scripts/prepare-dev.cjs | 34 - samples/react/quickstart/src/App.css | 1069 ---------- samples/react/quickstart/src/App.jsx | 22 - .../src/components/ConfigNotice.jsx | 164 -- .../quickstart/src/components/HeroCtas.jsx | 15 - .../react/quickstart/src/components/Nav.jsx | 111 - .../quickstart/src/components/ThunderMark.jsx | 10 - .../src/components/icons/ReactLogo.jsx | 16 - samples/react/quickstart/src/index.css | 9 - samples/react/quickstart/src/main.jsx | 24 - .../quickstart/src/pages/AccountPage.jsx | 187 -- .../react/quickstart/src/pages/HomePage.jsx | 254 --- .../quickstart/src/pages/TokenDebugPage.jsx | 139 -- samples/react/quickstart/vite.config.js | 6 - samples/vue/quickstart/.env.example | 5 - samples/vue/quickstart/.stackblitzrc | 4 - samples/vue/quickstart/README.md | 36 - samples/vue/quickstart/index.html | 13 - samples/vue/quickstart/package.json | 20 - .../vue/quickstart/scripts/prepare-dev.cjs | 34 - samples/vue/quickstart/src/App.vue | 40 - .../src/components/ConfigNotice.vue | 157 -- samples/vue/quickstart/src/components/Nav.vue | 85 - samples/vue/quickstart/src/main.js | 8 - .../vue/quickstart/src/pages/AccountPage.vue | 146 -- samples/vue/quickstart/src/pages/HomePage.vue | 249 --- .../quickstart/src/pages/TokenDebugPage.vue | 118 -- samples/vue/quickstart/src/style.css | 1103 ---------- samples/vue/quickstart/vite.config.js | 6 - tests/e2e/.gitignore | 3 + tests/e2e/.prettierignore | 3 + tests/e2e/constants/sample-apps.ts | 36 +- tests/e2e/pages/browser-quickstart.page.ts | 6 +- tests/e2e/pages/express-quickstart.page.ts | 6 +- tests/e2e/run-e2e.sh | 114 +- tests/e2e/scripts/prepare-samples.ts | 119 ++ .../client-credentials.spec.ts | 8 +- tests/e2e/tsconfig.json | 2 +- tests/e2e/utils/jwt/index.ts | 2 +- 186 files changed, 8746 insertions(+), 16641 deletions(-) create mode 100644 playgrounds/express/.env.example create mode 100644 playgrounds/express/.gitignore create mode 100644 playgrounds/express/.prettierignore create mode 100644 playgrounds/express/README.md create mode 100644 playgrounds/express/eslint.config.js create mode 100644 playgrounds/express/package.json create mode 100644 playgrounds/express/prettier.config.js create mode 100644 playgrounds/express/public/app.js create mode 100644 playgrounds/express/public/favicon.svg create mode 100644 playgrounds/express/public/index.html create mode 100644 playgrounds/express/public/styles.css create mode 100644 playgrounds/express/src/catalog.ts create mode 100644 playgrounds/express/src/config.ts create mode 100644 playgrounds/express/src/explorer.ts create mode 100644 playgrounds/express/src/helpers.ts create mode 100644 playgrounds/express/src/server.ts create mode 100644 playgrounds/express/src/trace.ts create mode 100644 playgrounds/express/tsconfig.eslint.json create mode 100644 playgrounds/express/tsconfig.json create mode 100644 playgrounds/express/turbo.json create mode 100644 playgrounds/react/.env.example create mode 100644 playgrounds/react/.gitignore create mode 100644 playgrounds/react/README.md create mode 100644 playgrounds/react/eslint.config.js create mode 100644 playgrounds/react/index.html create mode 100644 playgrounds/react/package.json create mode 100644 playgrounds/react/prettier.config.js create mode 100644 playgrounds/react/public/favicon.svg create mode 100644 playgrounds/react/src/App.tsx create mode 100644 playgrounds/react/src/components/ErrorBoundary.tsx create mode 100644 playgrounds/react/src/components/EventLog.tsx create mode 100644 playgrounds/react/src/components/Icons.tsx create mode 100644 playgrounds/react/src/components/SdkObserver.tsx create mode 100644 playgrounds/react/src/components/SessionCard.tsx create mode 100644 playgrounds/react/src/components/TopBar.tsx create mode 100644 playgrounds/react/src/config.ts create mode 100644 playgrounds/react/src/lib/events.ts create mode 100644 playgrounds/react/src/lib/health.ts create mode 100644 playgrounds/react/src/lib/inspect.ts create mode 100644 playgrounds/react/src/lib/jwt.ts create mode 100644 playgrounds/react/src/lib/router.ts create mode 100644 playgrounds/react/src/lib/sdk.ts create mode 100644 playgrounds/react/src/lib/session.ts create mode 100644 playgrounds/react/src/lib/user.ts create mode 100644 playgrounds/react/src/main.tsx create mode 100644 playgrounds/react/src/pages/Hooks.tsx create mode 100644 playgrounds/react/src/pages/Overview.tsx create mode 100644 playgrounds/react/src/pages/Tokens.tsx create mode 100644 playgrounds/react/src/pages/capabilities.ts create mode 100644 playgrounds/react/src/pages/explorer/Explorer.tsx create mode 100644 playgrounds/react/src/pages/explorer/StoryWidgets.tsx create mode 100644 playgrounds/react/src/pages/explorer/Workbench.tsx create mode 100644 playgrounds/react/src/pages/explorer/a11y.ts create mode 100644 playgrounds/react/src/pages/explorer/stories.tsx create mode 100644 playgrounds/react/src/pages/explorer/types.ts create mode 100644 playgrounds/react/src/styles.css create mode 100644 playgrounds/react/src/vite-env.d.ts create mode 100644 playgrounds/react/tsconfig.json create mode 100644 playgrounds/react/vite.config.ts delete mode 100644 samples/browser/quickstart/.env.example delete mode 100644 samples/browser/quickstart/.stackblitzrc delete mode 100644 samples/browser/quickstart/README.md delete mode 100644 samples/browser/quickstart/index.html delete mode 100644 samples/browser/quickstart/package.json delete mode 100644 samples/browser/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/browser/quickstart/src/auth.js delete mode 100644 samples/browser/quickstart/src/components/nav.js delete mode 100644 samples/browser/quickstart/src/components/profileFields.js delete mode 100644 samples/browser/quickstart/src/main.js delete mode 100644 samples/browser/quickstart/src/pages/account.js delete mode 100644 samples/browser/quickstart/src/pages/home.js delete mode 100644 samples/browser/quickstart/src/pages/token.js delete mode 100644 samples/browser/quickstart/src/style.css delete mode 100644 samples/browser/quickstart/vite.config.js delete mode 100644 samples/express/quickstart/.env.example delete mode 100644 samples/express/quickstart/.stackblitzrc delete mode 100644 samples/express/quickstart/README.md delete mode 100644 samples/express/quickstart/index.mjs delete mode 100644 samples/express/quickstart/lib/auth.mjs delete mode 100644 samples/express/quickstart/lib/expressLogo.mjs delete mode 100644 samples/express/quickstart/lib/layout.mjs delete mode 100644 samples/express/quickstart/lib/thunderMark.mjs delete mode 100644 samples/express/quickstart/package.json delete mode 100644 samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json delete mode 100644 samples/express/quickstart/public/styles.css delete mode 100644 samples/express/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/nextjs/quickstart/.env.example delete mode 100644 samples/nextjs/quickstart/.stackblitzrc delete mode 100644 samples/nextjs/quickstart/README.md delete mode 100644 samples/nextjs/quickstart/app/account/page.tsx delete mode 100644 samples/nextjs/quickstart/app/actions.ts delete mode 100644 samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts delete mode 100644 samples/nextjs/quickstart/app/components/ConfigNotice.tsx delete mode 100644 samples/nextjs/quickstart/app/components/HeroCtas.tsx delete mode 100644 samples/nextjs/quickstart/app/components/Nav.tsx delete mode 100644 samples/nextjs/quickstart/app/components/ThunderMark.tsx delete mode 100644 samples/nextjs/quickstart/app/components/icons/NextLogo.tsx delete mode 100644 samples/nextjs/quickstart/app/globals.css delete mode 100644 samples/nextjs/quickstart/app/layout.tsx delete mode 100644 samples/nextjs/quickstart/app/not-found.tsx delete mode 100644 samples/nextjs/quickstart/app/page.tsx delete mode 100644 samples/nextjs/quickstart/app/signin/page.tsx delete mode 100644 samples/nextjs/quickstart/app/signup/page.tsx delete mode 100644 samples/nextjs/quickstart/app/token/page.tsx delete mode 100644 samples/nextjs/quickstart/next-env.d.ts delete mode 100644 samples/nextjs/quickstart/next.config.ts delete mode 100644 samples/nextjs/quickstart/package.json delete mode 100644 samples/nextjs/quickstart/proxy.ts delete mode 100644 samples/nextjs/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/nextjs/quickstart/tsconfig.json delete mode 100644 samples/node/quickstart/.env.example delete mode 100644 samples/node/quickstart/.stackblitzrc delete mode 100644 samples/node/quickstart/README.md delete mode 100644 samples/node/quickstart/index.mjs delete mode 100644 samples/node/quickstart/lib/AuthService.mjs delete mode 100644 samples/node/quickstart/lib/InventoryService.mjs delete mode 100644 samples/node/quickstart/lib/ui.mjs delete mode 100644 samples/node/quickstart/package.json delete mode 100644 samples/node/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/nuxt/quickstart/.env.example delete mode 100644 samples/nuxt/quickstart/.stackblitzrc delete mode 100644 samples/nuxt/quickstart/README.md delete mode 100644 samples/nuxt/quickstart/app/app.vue delete mode 100644 samples/nuxt/quickstart/app/assets/styles.css delete mode 100644 samples/nuxt/quickstart/app/components/AppNav.vue delete mode 100644 samples/nuxt/quickstart/app/components/ConfigNotice.vue delete mode 100644 samples/nuxt/quickstart/app/pages/account.vue delete mode 100644 samples/nuxt/quickstart/app/pages/index.vue delete mode 100644 samples/nuxt/quickstart/app/pages/signin.vue delete mode 100644 samples/nuxt/quickstart/app/pages/signup.vue delete mode 100644 samples/nuxt/quickstart/app/pages/token.vue delete mode 100644 samples/nuxt/quickstart/nuxt.config.ts delete mode 100644 samples/nuxt/quickstart/package.json delete mode 100644 samples/nuxt/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/react/quickstart/.env.example delete mode 100644 samples/react/quickstart/.stackblitzrc delete mode 100644 samples/react/quickstart/README.md delete mode 100644 samples/react/quickstart/index.html delete mode 100644 samples/react/quickstart/package.json delete mode 100644 samples/react/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/react/quickstart/src/App.css delete mode 100644 samples/react/quickstart/src/App.jsx delete mode 100644 samples/react/quickstart/src/components/ConfigNotice.jsx delete mode 100644 samples/react/quickstart/src/components/HeroCtas.jsx delete mode 100644 samples/react/quickstart/src/components/Nav.jsx delete mode 100644 samples/react/quickstart/src/components/ThunderMark.jsx delete mode 100644 samples/react/quickstart/src/components/icons/ReactLogo.jsx delete mode 100644 samples/react/quickstart/src/index.css delete mode 100644 samples/react/quickstart/src/main.jsx delete mode 100644 samples/react/quickstart/src/pages/AccountPage.jsx delete mode 100644 samples/react/quickstart/src/pages/HomePage.jsx delete mode 100644 samples/react/quickstart/src/pages/TokenDebugPage.jsx delete mode 100644 samples/react/quickstart/vite.config.js delete mode 100644 samples/vue/quickstart/.env.example delete mode 100644 samples/vue/quickstart/.stackblitzrc delete mode 100644 samples/vue/quickstart/README.md delete mode 100644 samples/vue/quickstart/index.html delete mode 100644 samples/vue/quickstart/package.json delete mode 100644 samples/vue/quickstart/scripts/prepare-dev.cjs delete mode 100644 samples/vue/quickstart/src/App.vue delete mode 100644 samples/vue/quickstart/src/components/ConfigNotice.vue delete mode 100644 samples/vue/quickstart/src/components/Nav.vue delete mode 100644 samples/vue/quickstart/src/main.js delete mode 100644 samples/vue/quickstart/src/pages/AccountPage.vue delete mode 100644 samples/vue/quickstart/src/pages/HomePage.vue delete mode 100644 samples/vue/quickstart/src/pages/TokenDebugPage.vue delete mode 100644 samples/vue/quickstart/src/style.css delete mode 100644 samples/vue/quickstart/vite.config.js create mode 100644 tests/e2e/scripts/prepare-samples.ts diff --git a/.github/actions/pack-sample/action.yml b/.github/actions/pack-sample/action.yml index b995f706..0a573d62 100644 --- a/.github/actions/pack-sample/action.yml +++ b/.github/actions/pack-sample/action.yml @@ -6,18 +6,16 @@ description: > inputs: sdk: - description: SDK slug matching the samples/ subdirectory (e.g. react, browser, node) + description: SDK slug used to name the zip file (e.g. better-auth) required: true version: description: Version being released (e.g. 1.2.3), used to name the zip file required: true path: description: > - Sample app directory, relative to the repo root. Defaults to samples//quickstart; - pass this for a sample that doesn't follow that convention (e.g. an integration sample - under samples/integrations//). - required: false - default: '' + Sample app directory, relative to the repo root (e.g. samples/integrations//). + The quickstarts live in thunder-id/samples, which releases them itself. + required: true outputs: archive: @@ -41,7 +39,7 @@ runs: PACK_DIR="$(mktemp -d)" trap 'rm -rf "$PACK_DIR"' EXIT - cd "${SAMPLE_PATH:-samples/${SDK}/quickstart}" + cd "${SAMPLE_PATH}" pnpm pack --pack-destination "${PACK_DIR}" cd "${DEST_DIR}" diff --git a/.github/actions/run-e2e-suite/action.yml b/.github/actions/run-e2e-suite/action.yml index 69461d92..cc5e8b59 100644 --- a/.github/actions/run-e2e-suite/action.yml +++ b/.github/actions/run-e2e-suite/action.yml @@ -1,7 +1,8 @@ name: Run E2E Suite description: > - Installs and starts a local ThunderID release, imports the sample apps' OAuth2 clients, starts - all six sample apps against the workspace's own SDK packages, and runs the Playwright E2E suite + Installs and starts a local ThunderID release, imports the sample apps' OAuth2 clients, fetches + the quickstarts from thunder-id/samples, starts all six against the workspace's own SDK packages, + and runs the Playwright E2E suite against them. Shared by pr-builder.yml's label-gated `e2e` job and nightly.yml's scheduled run against the latest ThunderID release β€” the two differ only in trigger, not in what they run. Requires the repo to already be checked out (a local composite action can't check itself out). @@ -44,10 +45,16 @@ runs: - name: πŸ”¨ Build SDK Packages shell: bash - # Sample apps consume workspace:* SDK packages β€” this is what actually puts the PR's - # changes under test. + # prepare-samples.ts packs these builds into the sample apps β€” this is what actually puts + # the PR's changes under test. run: pnpm build + - name: πŸ“₯ Fetch Sample Apps + shell: bash + # Copies each quickstart from thunder-id/samples into tests/e2e/.samples and pins its + # @thunderid/* dependencies to tarballs of the packages built above. + run: node tests/e2e/scripts/prepare-samples.ts + - name: πŸš€ Install, Set up, and Start ThunderID (Latest, via npx) id: install shell: bash @@ -211,12 +218,10 @@ runs: shell: bash # Each app needs its own OAuth2 client wired in before it starts (see the OAuth2 clients # imported above). The apps' own `prepare-dev.cjs --flow=redirect` helper looks like the - # natural fit for nextjs/nuxt but isn't: it rewrites the sample's checked-in package.json - # (workspace:* -> latest, defeating the point of testing this PR's own SDK changes) and - # blanks any value matching its own placeholders β€” including the literal string - # `https://localhost:8090`, which is also our real server URL. Writing each .env directly - # sidesteps both; variable names come from each app's own scripts/prepare-dev.cjs - # (native-flow-only vars for nextjs/nuxt are omitted). + # natural fit for nextjs/nuxt but isn't: it blanks any value matching its own placeholders, + # including the literal string `https://localhost:8090`, which is also our real server URL. + # Writing each .env directly sidesteps that; variable names come from each app's own + # scripts/prepare-dev.cjs (native-flow-only vars for nextjs/nuxt are omitted). run: | NEXTJS_SECRET=$(openssl rand -base64 32) NUXT_SECRET=$(openssl rand -base64 32) @@ -226,32 +231,32 @@ runs: printf '%s\n' "$@" > "$file" } - write_env samples/browser/quickstart/.env \ + write_env tests/e2e/.samples/quickstarts/browser/.env \ "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_BROWSER" - write_env samples/react/quickstart/.env \ + write_env tests/e2e/.samples/quickstarts/react/.env \ "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_REACT" - write_env samples/vue/quickstart/.env \ + write_env tests/e2e/.samples/quickstarts/vue/.env \ "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_VUE" - write_env samples/express/quickstart/.env \ + write_env tests/e2e/.samples/quickstarts/express/.env \ "THUNDERID_BASE_URL=https://localhost:8090" \ "THUNDERID_CLIENT_ID=JS_SDK_E2E_EXPRESS" \ "THUNDERID_CLIENT_SECRET=e2e-express-secret" \ "NODE_TLS_REJECT_UNAUTHORIZED=0" - write_env samples/nextjs/quickstart/.env \ + write_env tests/e2e/.samples/quickstarts/nextjs/.env \ "NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \ "NEXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NEXTJS" \ "THUNDERID_CLIENT_SECRET=e2e-nextjs-secret" \ "THUNDERID_SECRET=${NEXTJS_SECRET}" \ "NODE_TLS_REJECT_UNAUTHORIZED=0" - write_env samples/nuxt/quickstart/.env \ + write_env tests/e2e/.samples/quickstarts/nuxt/.env \ "NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \ "NUXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NUXT" \ "THUNDERID_CLIENT_SECRET=e2e-nuxt-secret" \ @@ -261,12 +266,12 @@ runs: - name: πŸš€ Start Sample Apps shell: bash run: | - ( cd samples/browser/quickstart && pnpm exec vite --port 5173 & ) - ( cd samples/react/quickstart && pnpm exec vite --port 5174 & ) - ( cd samples/vue/quickstart && pnpm exec vite --port 5175 & ) - ( cd samples/nextjs/quickstart && pnpm exec next dev -p 3001 & ) - ( cd samples/nuxt/quickstart && pnpm exec nuxt dev --port 3002 & ) - ( cd samples/express/quickstart && pnpm exec node --env-file-if-exists=.env index.mjs & ) + ( cd tests/e2e/.samples/quickstarts/browser && pnpm exec vite --port 5173 & ) + ( cd tests/e2e/.samples/quickstarts/react && pnpm exec vite --port 5174 & ) + ( cd tests/e2e/.samples/quickstarts/vue && pnpm exec vite --port 5175 & ) + ( cd tests/e2e/.samples/quickstarts/nextjs && pnpm exec next dev -p 3001 & ) + ( cd tests/e2e/.samples/quickstarts/nuxt && pnpm exec nuxt dev --port 3002 & ) + ( cd tests/e2e/.samples/quickstarts/express && pnpm exec node --env-file-if-exists=.env index.mjs & ) for url in http://localhost:5173 http://localhost:5174 http://localhost:5175 http://localhost:3000 http://localhost:3001 http://localhost:3002; do ready=0 for i in $(seq 1 60); do @@ -299,7 +304,7 @@ runs: NEXTJS_APP_URL: http://localhost:3001 NUXT_APP_URL: http://localhost:3002 EXPRESS_APP_URL: http://localhost:3000 - # node/quickstart has no page to drive β€” client-credentials.spec.ts spawns it directly + # quickstarts/node has no page to drive β€” client-credentials.spec.ts spawns it directly # with `env: process.env`, so its credentials just need to be here, not a .env file. THUNDERID_BASE_URL: https://localhost:8090 THUNDERID_CLIENT_ID: JS_SDK_E2E_NODE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ccf2092..4a223a79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -330,21 +330,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: browser - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/browser/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Browser SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes release-node: name: 🟒 Release @thunderid/node @@ -403,21 +395,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: node - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/node/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Node SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes # ── Level 2: depends on browser / node ───────────────────────────────────── release-express: @@ -477,21 +461,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: express - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/express/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Express SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes release-react: name: βš›οΈ Release @thunderid/react @@ -550,21 +526,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: react - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/react/v${{ steps.bump.outputs.version }}" \ --title "ThunderID React SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes release-vue: name: 🟩 Release @thunderid/vue @@ -623,21 +591,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: vue - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/vue/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Vue SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes # ── Level 3: depends on react / node+react / browser+node+vue ────────────── release-react-router: @@ -827,21 +787,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: nextjs - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/nextjs/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Next.js SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes release-nuxt: name: πŸ’š Release @thunderid/nuxt @@ -900,18 +852,10 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: nuxt - version: ${{ steps.bump.outputs.version }} - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/nuxt/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Nuxt SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes diff --git a/AGENTS.md b/AGENTS.md index 796dcbf2..17713347 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,11 @@ pnpm + Turborepo monorepo of TypeScript SDKs under `packages/`: a framework-agnostic core (`@thunderid/javascript`), a browser layer (`@thunderid/browser`), a server layer (`@thunderid/node`), and framework SDKs built on top of those (`react`, `vue`, `nuxt`, `nextjs`, `express`, `react-router`, -`tanstack-router`). `samples//quickstart` directories contain standalone demo apps β€” not part of -any published package. +`tanstack-router`). The per-framework quickstarts live in +[thunder-id/samples](https://github.com/thunder-id/samples) and depend on the published packages; the E2E +suite fetches them into `tests/e2e/.samples` and wires them to this workspace's builds +(`tests/e2e/scripts/prepare-samples.ts`). `samples/integrations/` holds integration samples that are not +part of any published package. ## SDK development specification @@ -112,5 +115,7 @@ packages/ express/ Express middleware react-router/ React Router integration tanstack-router/ TanStack Router integration -samples/ Standalone quickstart demo apps, one per framework +playgrounds/ Playground apps that exercise the SDKs from source (not published) +samples/ Integration samples (quickstarts live in thunder-id/samples) +tests/e2e/ Playwright suite driving the quickstarts against a real ThunderID server ``` diff --git a/README.md b/README.md index ee766c17..b9bd49f4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,16 @@ JavaScript SDKs for ThunderID. Provides authentication and user management for b | [`@thunderid/tanstack-router`](packages/tanstack-router) | ![npm](https://img.shields.io/npm/v/@thunderid/tanstack-router) | TanStack Router integration | | [`@thunderid/better-auth`](packages/better-auth) | ![npm](https://img.shields.io/npm/v/@thunderid/better-auth) | Better Auth provider helper | +## Playgrounds + +Apps under [`playgrounds/`](playgrounds) exercise the SDKs from the source in this workspace. Build the SDKs with +`pnpm build`, then start one: + +| Playground | Run | What it shows | +|---|---|---| +| [Express](playgrounds/express) | `pnpm --filter @thunderid/playground-express dev` | API explorer for the `@thunderid/express` handlers and protected routes, with an SDK trace | +| [React](playgrounds/react) | `pnpm --filter @thunderid/playground-react dev` | Capability checks, a component explorer for the `@thunderid/react` components, and live hooks and tokens | + ## Contributing Please refer to the [Contributing Guide](https://thunderid.dev/community/overview) for the different ways to contribute to this project and the relevant guidelines. diff --git a/eslint.config.js b/eslint.config.js index 4eddf2d9..c10c2852 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import thunderIdPlugin from '@thunderid/eslint-plugin'; export default [ { - ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**'], + ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**', 'tests/e2e/.samples/**'], }, ...thunderIdPlugin.configs.typescript, { diff --git a/playgrounds/express/.env.example b/playgrounds/express/.env.example new file mode 100644 index 00000000..0d7c7a8e --- /dev/null +++ b/playgrounds/express/.env.example @@ -0,0 +1,32 @@ +# Copy to .env and fill in the values of the application you registered in ThunderID. + +# ThunderID server. +THUNDERID_BASE_URL=https://localhost:8090 + +# OAuth2 client of the application. The client secret is required for a confidential client. +THUNDERID_CLIENT_ID= +THUNDERID_CLIENT_SECRET= + +# Must match the redirect URIs registered for the application. They default to APP_URL/login and +# APP_URL/logout, which are the routes handleSignIn() and handleSignOut() are mounted on. +THUNDERID_AFTER_SIGN_IN_URL=http://localhost:3000/login +THUNDERID_AFTER_SIGN_OUT_URL=http://localhost:3000/logout + +# Scopes to request, separated by spaces. +THUNDERID_SCOPES=openid profile email + +# Only for POST /flow/sign-in (handleFlow()): the application ID, and the flow secret if the +# application requires one. +THUNDERID_APPLICATION_ID= +THUNDERID_FLOW_SECRET= + +# Scope that GET /api/admin/users requires in the access token. +PLAYGROUND_ADMIN_SCOPE=system + +# Where the playground listens. +PORT=3000 +APP_URL=http://localhost:3000 + +# A local ThunderID server uses a self-signed certificate, which Node rejects. For local +# development only, turn certificate checks off: +# NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/playgrounds/express/.gitignore b/playgrounds/express/.gitignore new file mode 100644 index 00000000..327f74d5 --- /dev/null +++ b/playgrounds/express/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env +.env.* +!.env.example diff --git a/playgrounds/express/.prettierignore b/playgrounds/express/.prettierignore new file mode 100644 index 00000000..99b0b518 --- /dev/null +++ b/playgrounds/express/.prettierignore @@ -0,0 +1,4 @@ +/dist +/build +/node_modules +/coverage diff --git a/playgrounds/express/README.md b/playgrounds/express/README.md new file mode 100644 index 00000000..320015fa --- /dev/null +++ b/playgrounds/express/README.md @@ -0,0 +1,71 @@ +# Express playground + +A playground for `@thunderid/express`, built against the SDK source in this workspace. It runs an Express server that +mounts the SDK's handlers and a set of example protected routes, and serves an API explorer for calling them. + +## What it covers + +| Route | Uses | +| -------------------------- | ----------------------------------------------- | +| `GET /login` | `handleSignIn()` | +| `GET /logout` | `handleSignOut()` | +| `POST /flow/sign-in` | `handleFlow()` (embedded sign-in flow) | +| `POST /backchannel-logout` | `handleBackchannelLogout()` | +| `GET /api/session` | `client.isSignedIn()` | +| `GET /api/me` | `protect()`, `client.getUser()` | +| `GET /api/id-token` | `protect()`, `client.getDecodedIdToken()` | +| `POST /api/token/refresh` | `client.refreshAccessToken()` | +| `GET /api/public` | nothing (baseline) | +| `GET /api/orders`, `/:id` | `protect()` | +| `GET /api/admin/users` | `protect()`, a scope check, `client.getUsers()` | + +Every route sits behind `thunderID()`, which attaches the client to `req.thunderIDAuth`. + +The API explorer at the playground's root lets you: + +- pick a route and **send it as** an anonymous caller (no cookie), your signed-in session, or a copy of your session + whose access token expired a minute ago; +- read the response status, body and headers; +- follow the **SDK trace**: the middleware that ran, the client methods they called, the requests the SDK made to + ThunderID and the SDK's own log lines; +- copy the request as cURL and read the handler's source. + +## Run it against a local ThunderID server + +1. Start ThunderID (by default at `https://localhost:8090`). +2. In the ThunderID Console, create an application with an OAuth2 client: + - redirect URI `http://localhost:3000/login`, post-logout redirect URI `http://localhost:3000/logout`; + - grant types `authorization_code` and, for `POST /api/token/refresh`, `refresh_token`; + - a confidential client (note the client secret). +3. Build the SDKs and configure the playground, from the repository root: + + ```bash + pnpm install + pnpm build + cp playgrounds/express/.env.example playgrounds/express/.env + ``` + + Fill in `THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`. A local ThunderID server uses a self-signed + certificate, so for local development also uncomment `NODE_TLS_REJECT_UNAUTHORIZED=0`. + +4. Start the playground: + + ```bash + pnpm --filter @thunderid/playground-express dev + ``` + +5. Open http://localhost:3000, select **Sign in**, and come back to the explorer. + +Node runs `src/server.ts` directly, so the playground needs Node.js 22.18 or later and has no build output. `pnpm build` +type-checks it against the workspace SDK, and `pnpm --filter @thunderid/express build` picks up SDK changes on the next +restart. + +Without a ThunderID server, the explorer still runs: the top bar shows ThunderID as unreachable and the routes that call +it answer with the SDK's errors, which the trace shows. + +## How the trace works + +`@thunderid/express` emits no debug events, so the playground assembles the trace itself (`src/trace.ts`): it routes the +SDK logger through its `formatter` hook, wraps each route's middleware and handlers, wraps the client methods they call, +and wraps `fetch`. Requests sent from the explorer carry an `x-playground-trace` header; the steps recorded while +serving them go back with the response. diff --git a/playgrounds/express/eslint.config.js b/playgrounds/express/eslint.config.js new file mode 100644 index 00000000..f329cc06 --- /dev/null +++ b/playgrounds/express/eslint.config.js @@ -0,0 +1,30 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import thunderIdPlugin from '@thunderid/eslint-plugin'; + +export default [ + { + ignores: ['node_modules/**'], + }, + ...thunderIdPlugin.configs.typescript, + { + // Node runs src/server.ts directly (type stripping), which needs the `.ts` extension on + // relative imports. + files: ['src/**/*.ts'], + rules: { + 'import-x/extensions': 'off', + }, + }, + { + // The explorer UI is plain browser JavaScript, served as is. + files: ['public/**/*.js'], + languageOptions: { + globals: Object.fromEntries( + ['URL', 'URLSearchParams', 'document', 'fetch', 'history', 'location', 'navigator', 'setTimeout', 'window'].map( + (name) => [name, 'readonly'], + ), + ), + }, + }, +]; diff --git a/playgrounds/express/package.json b/playgrounds/express/package.json new file mode 100644 index 00000000..932fed5d --- /dev/null +++ b/playgrounds/express/package.json @@ -0,0 +1,32 @@ +{ + "name": "@thunderid/playground-express", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Playground for exercising every @thunderid/express capability, with an API explorer UI.", + "scripts": { + "dev": "node --env-file-if-exists=.env --watch src/server.ts", + "start": "node --env-file-if-exists=.env src/server.ts", + "build": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .js,.ts,.cjs,.mjs", + "lint:fix": "eslint . --fix --ext .js,.ts,.cjs,.mjs", + "format:check": "prettier --check --cache .", + "format:fix": "prettier --write --cache ." + }, + "dependencies": { + "@thunderid/express": "workspace:*", + "cookie-parser": "^1.4.7", + "express": "^5.2.1" + }, + "devDependencies": { + "@thunderid/eslint-plugin": "catalog:", + "@thunderid/prettier-config": "catalog:", + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.3", + "@types/node": "catalog:", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:" + } +} diff --git a/playgrounds/express/prettier.config.js b/playgrounds/express/prettier.config.js new file mode 100644 index 00000000..f054cd50 --- /dev/null +++ b/playgrounds/express/prettier.config.js @@ -0,0 +1,6 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import config from '@thunderid/prettier-config'; + +export default config; diff --git a/playgrounds/express/public/app.js b/playgrounds/express/public/app.js new file mode 100644 index 00000000..9d739e6b --- /dev/null +++ b/playgrounds/express/public/app.js @@ -0,0 +1,404 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +const GROUPS = ['Authentication', 'Session', 'Tokens', 'Protected routes']; + +const state = { + curlCopied: false, + identity: 'anonymous', + meta: null, + results: {}, + routeId: null, + sending: false, + showCurl: false, + tab: 'body', +}; + +const $ = (id) => document.getElementById(id); + +const el = (tag, props = {}, children = []) => { + const node = document.createElement(tag); + Object.entries(props).forEach(([key, value]) => { + if (value === undefined || value === null || value === false) return; + if (key === 'text') node.textContent = value; + else if (key === 'onClick') node.addEventListener('click', value); + else if (key === 'className') node.className = value; + else node.setAttribute(key, value === true ? '' : value); + }); + children.forEach((child) => node.append(child)); + return node; +}; + +const LOCK_ICON = + ''; + +const currentRoute = () => state.meta?.routes.find((route) => route.id === state.routeId); + +const initials = (name) => + name + .split(/[\s@._-]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0].toUpperCase()) + .join(''); + +const formatSize = (bytes) => (bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`); + +const maskCookie = (text) => text.replace(/(Cookie: [^=]+=)([0-9a-f-]{8})[0-9a-f-]+/gi, '$1$2...'); + +const identities = () => { + const session = state.meta?.session ?? {present: false}; + const name = session.name ?? 'Signed in'; + const usable = session.present && session.hasTokens; + const reason = 'Sign in first: this identity uses your playground session.'; + return [ + {color: 'rgba(255,255,255,0.4)', id: 'anonymous', label: 'Anonymous', sub: 'no session cookie'}, + { + color: '#34d399', + disabled: !usable, + id: 'session', + label: name, + reason, + sub: usable ? (session.signedIn ? 'your session' : 'your session, token expired') : 'sign in first', + }, + { + color: '#f59e0b', + disabled: !usable, + id: 'expired', + label: usable ? name : 'Expired token', + reason, + sub: 'copy of your session, access token expired', + }, + ]; +}; + +const renderTopbar = () => { + const {meta} = state; + $('sdk-label').textContent = `Express Β· ${meta.sdk.name} ${meta.sdk.version}`; + + const appStatus = $('app-status'); + appStatus.dataset.state = 'ok'; + appStatus.lastChild.textContent = new URL(meta.app).host; + + const idpStatus = $('idp-status'); + idpStatus.dataset.state = meta.idp.reachable ? 'ok' : 'down'; + idpStatus.lastChild.textContent = `idp ${new URL(meta.idp.baseUrl).host}`; + idpStatus.title = meta.idp.reachable ? 'ThunderID is reachable' : `ThunderID is unreachable: ${meta.idp.error}`; + + const chip = $('session-chip'); + chip.replaceChildren(); + if (meta.session.present && meta.session.hasTokens) { + const name = meta.session.name ?? 'Signed in'; + chip.append( + el('div', {className: 'avatar', 'aria-hidden': 'true', text: initials(name)}), + el('span', {className: 'session-name'}, [ + el('span', {text: name}), + el('small', {text: meta.session.signedIn ? 'signed in' : 'access token expired'}), + ]), + el('a', {className: 'btn-link', href: '/logout', text: 'Sign out'}), + ); + } else { + chip.append(el('a', {className: 'btn-primary small', href: '/login', text: 'Sign in'})); + } + + const notices = $('notices'); + notices.replaceChildren(); + const notice = (parts, title) => + notices.append(el('div', {className: 'notice', role: 'status'}, [el('strong', {text: title}), ...parts])); + if (!meta.configured) { + notice( + [ + el('span', {text: 'Set '}), + el('code', {text: 'THUNDERID_CLIENT_ID'}), + el('span', {text: ' in .env (copy .env.example) and restart.'}), + ], + 'Not configured.', + ); + } + if (!meta.idp.reachable) { + notice( + [ + el('span', { + text: `${meta.idp.baseUrl} did not answer: ${meta.idp.error}. See the README for local TLS setup.`, + }), + ], + 'ThunderID unreachable.', + ); + } +}; + +const renderRoutes = () => { + const container = $('route-groups'); + container.replaceChildren( + ...GROUPS.map((group) => { + const items = state.meta.routes.filter((route) => route.group === group); + return el('div', {className: 'group'}, [ + el('div', {className: 'group-name', text: group}), + el( + 'ul', + {}, + items.map((route) => { + const button = el( + 'button', + { + 'aria-current': route.id === state.routeId ? 'true' : 'false', + className: 'route-btn', + onClick: () => selectRoute(route.id), + type: 'button', + }, + [ + el('span', {className: 'method', 'data-method': route.method, text: route.method}), + el('span', {className: 'route-path', text: route.path}), + ], + ); + if (route.locked) { + const lock = el('span', {className: 'lock', role: 'img', 'aria-label': 'Requires a session'}); + lock.innerHTML = LOCK_ICON; + button.append(lock); + } + return el('li', {}, [button]); + }), + ), + ]); + }), + ); +}; + +const renderRequest = () => { + const route = currentRoute(); + $('ep-method').textContent = route.method; + $('ep-method').dataset.method = route.method; + $('ep-path').textContent = route.path; + $('ep-tags').replaceChildren( + ...route.chain.filter((c) => c !== 'handler').map((c) => el('span', {className: 'tag', text: c})), + ); + $('ep-summary').textContent = route.summary; + $('ep-desc').textContent = route.description; + $('req-content-type').textContent = route.contentType ?? 'no body'; + $('handler').textContent = route.source; + + $('identities').replaceChildren( + ...identities().map((identity) => + el( + 'button', + { + 'aria-checked': state.identity === identity.id ? 'true' : 'false', + className: 'identity', + disabled: identity.disabled, + onClick: () => { + state.identity = identity.id; + state.showCurl = false; + renderRequest(); + }, + role: 'radio', + title: identity.disabled ? identity.reason : undefined, + type: 'button', + }, + [ + el('span', {className: 'id-dot', style: `background: ${identity.color}`}), + el('span', {className: 'id-text'}, [ + el('span', {className: 'id-label', text: identity.label}), + el('span', {className: 'id-sub', text: identity.sub}), + ]), + ], + ), + ), + ); + + $('params-block').hidden = route.params.length === 0; + $('params').replaceChildren( + ...route.params.map((param) => + el('label', {className: 'param'}, [ + el('span', {className: 'param-name'}, [ + el('span', {}, [ + document.createTextNode(param.name), + param.required ? el('span', {className: 'required', text: ' *'}) : '', + ]), + el('small', {text: `${param.where} Β· ${param.type}`}), + ]), + el('input', {'data-param': param.name, type: 'text', value: param.value}), + ]), + ), + ); + + $('body-block').hidden = !route.body; + $('req-body').value = route.body ?? ''; + + renderCurl(); +}; + +const readParams = () => + Object.fromEntries([...document.querySelectorAll('[data-param]')].map((input) => [input.dataset.param, input.value])); + +const draftCurl = () => { + const route = currentRoute(); + const params = readParams(); + let path = route.path; + const query = new URLSearchParams(); + route.params.forEach((param) => { + const value = params[param.name] ?? ''; + if (param.where === 'path') path = path.replace(`:${param.name}`, encodeURIComponent(value)); + else if (value) query.set(param.name, value); + }); + const lines = [`curl -i -X ${route.method} '${state.meta.app}${path}${query.size ? `?${query}` : ''}'`]; + if (state.identity !== 'anonymous') lines.push(` -H 'Cookie: ${state.meta.cookieName}='`); + if (route.method === 'POST') { + if (route.contentType) lines.push(` -H 'Content-Type: ${route.contentType}'`); + lines.push(` --data '${$('req-body').value.replace(/\s*\n\s*/g, ' ')}'`); + } + return lines.join(' \\\n'); +}; + +const curlText = () => state.results[state.routeId]?.curl ?? draftCurl(); + +const renderCurl = () => { + const button = $('curl-toggle'); + button.textContent = state.curlCopied ? 'Copied' : state.showCurl ? 'Hide cURL' : 'Copy as cURL'; + $('curl').hidden = !state.showCurl; + $('curl').textContent = state.showCurl ? maskCookie(curlText()) : ''; +}; + +const renderResponse = () => { + const result = state.results[state.routeId]; + $('send').classList.toggle('sending', state.sending); + $('send').disabled = state.sending; + $('res-empty').hidden = Boolean(result); + $('res-content').hidden = !result; + $('res-status').hidden = !result || result.error; + $('res-meta').textContent = ''; + + document.querySelectorAll('.tabs button').forEach((tab) => { + tab.setAttribute('aria-selected', String(tab.dataset.tab === state.tab)); + }); + document.querySelectorAll('[data-panel]').forEach((panel) => { + panel.hidden = panel.dataset.panel !== state.tab; + }); + + if (!result) return; + + if (result.error) { + $('res-body').textContent = result.error; + $('res-headers').replaceChildren(); + $('res-trace').replaceChildren(); + return; + } + + const badge = $('res-status'); + badge.textContent = `${result.status} ${result.statusText}`; + badge.dataset.class = String(result.status).charAt(0); + $('res-meta').textContent = `${result.ms} ms Β· ${formatSize(result.size)}`; + $('res-body').textContent = result.body || '(no body)'; + $('res-headers').replaceChildren( + ...result.headers.flatMap(([name, value]) => [el('dt', {text: name}), el('dd', {text: value})]), + ); + $('res-trace').replaceChildren( + ...(result.trace.length + ? result.trace.map((step) => + el('li', {style: `padding-left: ${step.depth * 20}px`}, [ + el('div', {className: 'trace-rail'}, [ + el('span', {className: 'trace-dot', 'data-kind': step.kind}), + el('span', {className: 'trace-line'}), + ]), + el('div', {className: 'trace-main'}, [ + el('div', {className: 'trace-row'}, [ + el('span', {className: 'trace-source', text: step.source}), + el('span', {className: `trace-step${step.source === 'log' ? '' : ' code'}`, text: step.step}), + el('span', { + className: 'trace-ms', + text: `+${step.at} ms${step.durationMs ? ` Β· ${step.durationMs} ms` : ''}`, + }), + ]), + el('div', {className: 'trace-detail', text: step.detail}), + ]), + ]), + ) + : [el('li', {className: 'trace-detail', text: 'No SDK steps were recorded for this request.'})]), + ); +}; + +const selectRoute = (id) => { + state.routeId = id; + state.showCurl = false; + state.curlCopied = false; + history.replaceState(null, '', `#${id}`); + renderRoutes(); + renderRequest(); + renderResponse(); +}; + +const loadMeta = async () => { + const response = await fetch('/_playground/meta'); + state.meta = await response.json(); + const session = state.meta.session; + if (state.identity !== 'anonymous' && !(session.present && session.hasTokens)) state.identity = 'anonymous'; + renderTopbar(); +}; + +const send = async () => { + if (state.sending) return; + state.sending = true; + renderResponse(); + try { + const response = await fetch('/_playground/send', { + body: JSON.stringify({ + body: $('req-body').value, + identity: state.identity, + params: readParams(), + routeId: state.routeId, + }), + headers: {'Content-Type': 'application/json'}, + method: 'POST', + }); + const payload = await response.json(); + state.results[state.routeId] = response.ok ? payload : {error: payload.message}; + } catch (e) { + state.results[state.routeId] = {error: `The explorer could not reach the playground server: ${e.message}`}; + } + state.sending = false; + await loadMeta(); + renderRequest(); + renderResponse(); +}; + +const copyCurl = async () => { + if (state.showCurl) { + state.showCurl = false; + renderCurl(); + return; + } + state.showCurl = true; + try { + await navigator.clipboard.writeText(curlText()); + state.curlCopied = true; + setTimeout(() => { + state.curlCopied = false; + renderCurl(); + }, 1500); + } catch { + state.curlCopied = false; + } + renderCurl(); +}; + +const start = async () => { + $('send').addEventListener('click', send); + $('curl-toggle').addEventListener('click', copyCurl); + window.addEventListener('hashchange', () => { + const id = location.hash.slice(1); + if (id !== state.routeId && state.meta?.routes.some((route) => route.id === id)) selectRoute(id); + }); + document.querySelectorAll('.tabs button').forEach((tab) => { + tab.addEventListener('click', () => { + state.tab = tab.dataset.tab; + renderResponse(); + }); + }); + + state.identity = new URLSearchParams(location.search).get('as') ?? 'anonymous'; + await loadMeta(); + const fromHash = location.hash.slice(1); + const ids = state.meta.routes.map((route) => route.id); + selectRoute(ids.includes(fromHash) ? fromHash : (ids.find((id) => id === 'me') ?? ids[0])); +}; + +start(); diff --git a/playgrounds/express/public/favicon.svg b/playgrounds/express/public/favicon.svg new file mode 100644 index 00000000..3fa7c648 --- /dev/null +++ b/playgrounds/express/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/playgrounds/express/public/index.html b/playgrounds/express/public/index.html new file mode 100644 index 00000000..9b38d30a --- /dev/null +++ b/playgrounds/express/public/index.html @@ -0,0 +1,179 @@ + + + + + + + Express playground Β· API explorer + + + + + + + +
+
+ + + + + + ThunderID + Playground +
+
+ ex + Express +
+ +
+ app + idp +
+
+ +
+ +
+ + +
+
+
+ +

+ +
+

+

+
+ +
+
+
+
+

Request

+ +
+
+
+
Send as
+
+
+ + +
+ + +
+ +
+
+ +
+
+

Handler

+ src/server.ts +
+

+            
+
+ +
+
+

Response

+ + +
+
+ + + +
+
+
+ +

Send the request to see the response.

+

+ The SDK trace shows each step the middleware took: session lookup, client calls and requests to + ThunderID. +

+
+ +
+
+
+
+ + diff --git a/playgrounds/express/public/styles.css b/playgrounds/express/public/styles.css new file mode 100644 index 00000000..8f5243be --- /dev/null +++ b/playgrounds/express/public/styles.css @@ -0,0 +1,897 @@ +/* + * Copyright 2026 The ThunderID Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +:root { + --bg: #050b18; + --surface: rgba(255, 255, 255, 0.02); + --surface-2: rgba(255, 255, 255, 0.05); + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.1); + --text: #fff; + --text-2: rgba(255, 255, 255, 0.75); + --muted: rgba(255, 255, 255, 0.5); + --code: #cfe3ff; + --primary: #3688ff; + --primary-2: #2060c8; + --accent: #8bf9fa; + --ok: #34d399; + --warn: #f59e0b; + --error: #ef4444; + --info: #9cc4ff; + --mono: 'JetBrains Mono', ui-monospace, monospace; + --sans: 'Inter', system-ui, sans-serif; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +[hidden] { + display: none !important; +} + +/* Show code as typed: JetBrains Mono would otherwise render => and === as ligatures. */ +* { + font-variant-ligatures: none !important; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +button, +input, +textarea { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +button:disabled { + cursor: not-allowed; +} + +a { + color: #6aa8ff; + text-decoration: none; +} + +code, +.mono { + font-family: var(--mono); +} + +.muted { + color: var(--muted); + font-size: 11.5px; + font-weight: 500; +} + +.spacer { + flex: 1; +} + +/* Top bar */ + +.topbar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 10px 20px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.015); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; +} + +.brand-name { + font-weight: 600; + letter-spacing: -0.2px; +} + +.brand-sub { + color: var(--muted); +} + +.sdk-pill { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 8px; + background: var(--surface-2); + border: 1px solid var(--line-strong); + font-size: 13px; + font-weight: 500; +} + +.sdk-badge { + width: 18px; + height: 18px; + border-radius: 5px; + background: rgba(255, 255, 255, 0.12); + display: flex; + align-items: center; + justify-content: center; + font: 600 10px/1 var(--mono); +} + +.sections { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.04); +} + +.sections a { + padding: 6px 12px; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); +} + +.sections a[aria-current='page'] { + color: var(--text); + background: rgba(54, 136, 255, 0.18); +} + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + font: 500 12px/1 var(--mono); + color: rgba(255, 255, 255, 0.6); +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); +} + +.status[data-state='ok'] .dot { + background: var(--ok); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +.status[data-state='down'] .dot { + background: var(--error); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.session-chip { + display: flex; + align-items: center; + gap: 10px; +} + +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + background: linear-gradient(135deg, #22d3ee, #0e7490); +} + +.session-name { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12.5px; + font-weight: 500; +} + +.session-name small { + font-size: 11px; + font-weight: 400; + color: var(--muted); +} + +.btn-primary, +.btn-secondary, +.btn-link { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; +} + +.btn-primary { + background: linear-gradient(90deg, var(--primary) 0%, var(--primary-2) 100%); + color: #fff; + padding: 10px 18px; + font-size: 13.5px; +} + +.btn-primary.small { + padding: 7px 14px; + font-size: 13px; +} + +.btn-secondary { + padding: 10px 14px; + background: var(--surface-2); + border: 1px solid var(--line-strong); + color: rgba(255, 255, 255, 0.8); +} + +.btn-link { + padding: 7px 10px; + color: rgba(255, 255, 255, 0.7); + border: 1px solid var(--line-strong); +} + +/* Notices */ + +.notice { + display: flex; + gap: 10px; + align-items: baseline; + padding: 10px 20px; + font-size: 13px; + line-height: 1.5; + border-bottom: 1px solid rgba(245, 158, 11, 0.2); + background: rgba(245, 158, 11, 0.07); + color: #fcd34d; +} + +.notice code { + color: #fde68a; + font-size: 12px; +} + +/* Layout */ + +.layout { + flex: 1; + display: flex; + flex-wrap: wrap; + min-height: 0; +} + +.routes { + flex: 1 1 280px; + max-width: 100%; + border-right: 1px solid var(--line); + padding: 14px 10px; + display: flex; + flex-direction: column; + gap: 16px; + background: rgba(255, 255, 255, 0.01); +} + +.routes-intro { + padding: 0 6px; +} + +.routes-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; +} + +.routes-intro p { + font-size: 12px; + line-height: 1.5; + color: var(--muted); +} + +.routes-intro code { + color: var(--accent); +} + +#route-groups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.group-name { + padding: 0 6px 6px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +} + +.group ul { + list-style: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +.route-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 8px; + border-radius: 7px; + text-align: left; + color: rgba(255, 255, 255, 0.72); +} + +.route-btn:hover { + background: rgba(255, 255, 255, 0.04); +} + +.route-btn[aria-current='true'] { + background: rgba(54, 136, 255, 0.16); + color: var(--text); +} + +.route-path { + font: 500 12.5px/1.3 var(--mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lock { + margin-left: auto; + flex: none; + opacity: 0.55; +} + +.method { + flex: none; + display: inline-flex; + justify-content: center; + width: 42px; + padding: 3px 0; + font: 600 10.5px/1.2 var(--mono); + border-radius: 5px; +} + +.method-big { + width: auto; + min-width: 58px; + padding: 5px 10px; + font-size: 12px; + border-radius: 6px; +} + +.method[data-method='GET'] { + color: #6ee7b7; + background: rgba(52, 211, 153, 0.12); +} + +.method[data-method='POST'] { + color: #9cc4ff; + background: rgba(54, 136, 255, 0.12); +} + +.main { + flex: 999 1 640px; + min-width: 0; + display: flex; + flex-direction: column; +} + +.endpoint-head { + padding: 22px 28px 18px; + border-bottom: 1px solid var(--line); +} + +.endpoint-title { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.endpoint-title h1 { + font: 500 20px/1.3 var(--mono); + letter-spacing: -0.3px; +} + +.tags { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; +} + +.tag { + display: inline-flex; + align-items: center; + font: 500 11.5px/1 var(--mono); + color: var(--accent); + background: rgba(139, 249, 250, 0.07); + border: 1px solid rgba(139, 249, 250, 0.16); + border-radius: 6px; + padding: 5px 8px; +} + +.summary { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 4px; +} + +.desc { + font-size: 13px; + line-height: 1.55; + color: rgba(255, 255, 255, 0.55); + max-width: 820px; +} + +.columns { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 20px 28px; +} + +.stack { + flex: 1 1 380px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--line); +} + +.card-head h2 { + font-size: 13px; + font-weight: 600; +} + +.card-body { + padding: 14px 16px; +} + +.request-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.field-label { + display: block; + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.65); + margin-bottom: 8px; +} + +.identities { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.identity { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + border-radius: 9px; + border: 1px solid var(--line-strong); + text-align: left; +} + +.identity[aria-checked='true'] { + background: rgba(54, 136, 255, 0.12); + border-color: var(--primary); +} + +.identity:disabled { + opacity: 0.45; +} + +.identity .id-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} + +.identity .id-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.identity .id-label { + font-size: 12.5px; + font-weight: 500; +} + +.identity .id-sub { + font-size: 11px; + color: var(--muted); +} + +.params { + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} + +.param { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 9px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.param:last-child { + border-bottom: none; +} + +.param-name { + display: flex; + flex-direction: column; + gap: 3px; + font: 500 12.5px/1 var(--mono); +} + +.param-name small { + font: 400 11px/1 var(--mono); + color: rgba(255, 255, 255, 0.45); +} + +.required { + color: #f87171; +} + +.param input, +.body-field textarea { + width: 100%; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 7px 9px; + font: 400 12px/1.3 var(--mono); + color: var(--code); +} + +.body-field { + display: flex; + flex-direction: column; +} + +.body-field textarea { + resize: vertical; + background: rgba(0, 0, 0, 0.3); + padding: 10px 12px; + font-size: 12.5px; + line-height: 1.6; +} + +.actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.icon-spin { + display: none; + animation: pg-spin 0.8s linear infinite; +} + +.sending .icon-spin { + display: block; +} + +.sending .icon-play { + display: none; +} + +@keyframes pg-spin { + to { + transform: rotate(360deg); + } +} + +.code-block { + padding: 12px 14px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--line); + font: 400 12px/1.6 var(--mono); + color: var(--code); + white-space: pre-wrap; + word-break: break-all; +} + +.source { + padding: 14px 16px; + font: 400 12.5px/1.65 var(--mono); + color: var(--code); + white-space: pre; + overflow-x: auto; +} + +/* Response */ + +.response { + flex: 1 1 380px; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 520px; +} + +.response-head { + justify-content: flex-start; + flex-wrap: wrap; + padding: 10px 16px; +} + +.status-badge { + display: inline-flex; + padding: 4px 9px; + border-radius: 6px; + font: 600 12px/1 var(--mono); +} + +.status-badge[data-class='2'] { + color: #6ee7b7; + background: rgba(52, 211, 153, 0.12); +} + +.status-badge[data-class='3'] { + color: #9cc4ff; + background: rgba(54, 136, 255, 0.12); +} + +.status-badge[data-class='4'] { + color: #fcd34d; + background: rgba(245, 158, 11, 0.12); +} + +.status-badge[data-class='5'] { + color: #fca5a5; + background: rgba(239, 68, 68, 0.12); +} + +.tabs { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 8px; + background: var(--surface-2); +} + +.tabs button { + padding: 5px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); +} + +.tabs button[aria-selected='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--info); +} + +.empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + text-align: center; + color: var(--muted); + font-size: 12.5px; + line-height: 1.5; +} + +.empty p { + max-width: 320px; +} + +.empty .empty-title { + font-size: 13.5px; + color: var(--text-2); +} + +.res-content { + flex: 1; + min-height: 0; + overflow: auto; +} + +.headers { + display: grid; + grid-template-columns: minmax(140px, auto) minmax(0, 1fr); +} + +.headers dt, +.headers dd { + padding: 9px 16px; + font: 400 12px/1.4 var(--mono); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.headers dt { + font-weight: 500; + color: rgba(255, 255, 255, 0.55); +} + +.headers dd { + color: var(--code); + word-break: break-all; +} + +.trace { + list-style: none; + padding: 16px; +} + +.trace li { + display: flex; + gap: 12px; +} + +.trace-rail { + display: flex; + flex-direction: column; + align-items: center; +} + +.trace-dot { + width: 10px; + height: 10px; + margin-top: 4px; + flex: none; + border-radius: 50%; +} + +.trace-line { + width: 1px; + flex: 1; + background: rgba(255, 255, 255, 0.1); +} + +.trace-dot[data-kind='ok'] { + background: var(--ok); + box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.13); +} + +.trace-dot[data-kind='warn'] { + background: var(--warn); + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.13); +} + +.trace-dot[data-kind='error'] { + background: var(--error); + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.13); +} + +.trace-dot[data-kind='info'] { + background: var(--info); + box-shadow: 0 0 0 4px rgba(156, 196, 255, 0.13); +} + +.trace-main { + padding-bottom: 16px; + flex: 1; + min-width: 0; +} + +.trace-row { + display: flex; + align-items: baseline; + gap: 8px; +} + +.trace-step { + font-size: 13px; + color: rgba(255, 255, 255, 0.9); + word-break: break-word; +} + +.trace-step.code { + font: 500 12.5px/1.4 var(--mono); +} + +.trace-source { + flex: none; + font: 500 10px/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 3px 5px; + border-radius: 4px; + color: rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.06); +} + +.trace-ms { + margin-left: auto; + font: 500 11.5px/1 var(--mono); + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; +} + +.trace-detail { + font: 400 11.5px/1.5 var(--mono); + color: var(--muted); + margin-top: 3px; + word-break: break-word; +} + +.error-text { + padding: 16px; + font-size: 13px; + color: #fca5a5; +} + +@media (max-width: 720px) { + .topbar, + .notice { + padding-left: 16px; + padding-right: 16px; + } + + .endpoint-head, + .columns { + padding-left: 16px; + padding-right: 16px; + } + + .routes { + border-right: none; + border-bottom: 1px solid var(--line); + } + + .param { + grid-template-columns: 1fr; + } +} diff --git a/playgrounds/express/src/catalog.ts b/playgrounds/express/src/catalog.ts new file mode 100644 index 00000000..742eb532 --- /dev/null +++ b/playgrounds/express/src/catalog.ts @@ -0,0 +1,200 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import config from './config.ts'; + +export interface RouteParam { + name: string; + required: boolean; + type: string; + value: string; + where: 'path' | 'query'; +} + +export interface RouteSpec { + /** Example request body, sent with `contentType`. */ + body?: string; + /** Labels for the route's handlers, in mount order, as they appear in the SDK trace. */ + chain: string[]; + contentType?: string; + description: string; + group: 'Authentication' | 'Session' | 'Tokens' | 'Protected routes'; + id: string; + /** Whether the route needs a session. */ + locked: boolean; + method: 'GET' | 'POST'; + params: RouteParam[]; + path: string; + /** `#region` names in `src/server.ts` that make up the route's source. */ + regions: string[]; + summary: string; +} + +const routes: RouteSpec[] = [ + { + chain: ['handleSignIn()'], + description: + 'Without a code, creates the session cookie and redirects to the ThunderID authorize endpoint. With a code (the redirect back from ThunderID), exchanges it for tokens and calls onSignIn.', + group: 'Authentication', + id: 'login', + locked: false, + method: 'GET', + params: [ + {name: 'code', required: false, type: 'string', value: '', where: 'query'}, + {name: 'state', required: false, type: 'string', value: '', where: 'query'}, + ], + path: '/login', + regions: ['setup', 'login'], + summary: 'Start or finish sign-in.', + }, + { + chain: ['handleSignOut()'], + description: + 'Ends the local session, clears the cookie and redirects to the ThunderID end-session endpoint. Sending this as your session signs you out of the playground.', + group: 'Authentication', + id: 'logout', + locked: true, + method: 'GET', + params: [], + path: '/logout', + regions: ['logout'], + summary: 'Sign out.', + }, + { + body: JSON.stringify({applicationId: config.applicationId ?? '', flowType: 'SIGN_IN'}, null, 2), + chain: ['handleFlow()'], + contentType: 'application/json', + description: + 'Drives the embedded (app-native) sign-in flow one step at a time. The first call starts the flow; later calls send executionId, challengeToken, authId and inputs.', + group: 'Authentication', + id: 'flow', + locked: false, + method: 'POST', + params: [], + path: '/flow/sign-in', + regions: ['flow'], + summary: 'Embedded sign-in flow.', + }, + { + body: 'logout_token=not-a-real-token', + chain: ['handleBackchannelLogout()'], + contentType: 'application/x-www-form-urlencoded', + description: + 'ThunderID posts a signed logout_token here when a session ends elsewhere. The handler verifies it and ends the matching local sessions. An invalid token gets 400.', + group: 'Authentication', + id: 'backchannel', + locked: false, + method: 'POST', + params: [], + path: '/backchannel-logout', + regions: ['backchannel'], + summary: 'OpenID Connect back-channel logout.', + }, + { + chain: ['handler'], + description: 'Reports whether the request carries a session cookie and whether the SDK considers it signed in.', + group: 'Session', + id: 'session', + locked: false, + method: 'GET', + params: [], + path: '/api/session', + regions: ['session'], + summary: 'Session status.', + }, + { + chain: ['protect()', 'handler'], + description: 'Returns the signed-in user from the session with client.getUser().', + group: 'Session', + id: 'me', + locked: true, + method: 'GET', + params: [], + path: '/api/me', + regions: ['unauthenticated', 'me'], + summary: 'Current user.', + }, + { + chain: ['protect()', 'handler'], + description: 'Returns the claims of the session ID token with client.getDecodedIdToken().', + group: 'Session', + id: 'idToken', + locked: true, + method: 'GET', + params: [], + path: '/api/id-token', + regions: ['unauthenticated', 'idToken'], + summary: 'Decoded ID token.', + }, + { + body: '{}', + chain: ['handler'], + contentType: 'application/json', + description: + 'Uses the refresh token in the session to get a new access token with client.refreshAccessToken(). Needs a refresh token, so the application must allow the refresh_token grant.', + group: 'Tokens', + id: 'refresh', + locked: true, + method: 'POST', + params: [], + path: '/api/token/refresh', + regions: ['unauthenticated', 'refresh'], + summary: 'Refresh tokens.', + }, + { + chain: ['handler'], + description: 'Open to everyone. A baseline that runs no SDK checks.', + group: 'Protected routes', + id: 'public', + locked: false, + method: 'GET', + params: [], + path: '/api/public', + regions: ['public'], + summary: 'Example public route.', + }, + { + chain: ['protect()', 'handler'], + description: 'Any signed-in user. protect() answers 401 when the session cookie is missing or not signed in.', + group: 'Protected routes', + id: 'orders', + locked: true, + method: 'GET', + params: [], + path: '/api/orders', + regions: ['unauthenticated', 'orders'], + summary: 'Example protected route.', + }, + { + chain: ['protect()', 'handler'], + description: 'A protected route with a path parameter.', + group: 'Protected routes', + id: 'order', + locked: true, + method: 'GET', + params: [{name: 'id', required: true, type: 'string', value: 'ord_1001', where: 'path'}], + path: '/api/orders/:id', + regions: ['order'], + summary: 'Example protected route with a parameter.', + }, + { + chain: ['protect()', `requireScope('${config.adminScope}')`, 'handler'], + description: `Requires the ${config.adminScope} scope in the access token, then lists users through the ThunderID management API with client.getUsers().`, + group: 'Protected routes', + id: 'admin', + locked: true, + method: 'GET', + params: [{name: 'limit', required: false, type: 'number', value: '10', where: 'query'}], + path: '/api/admin/users', + regions: ['requireScope', 'admin'], + summary: 'Example scoped route.', + }, +]; + +const catalog: {chains: () => Map; routes: RouteSpec[]} = { + chains: (): Map => + new Map(routes.map((route: RouteSpec): [string, string[]] => [`${route.method} ${route.path}`, route.chain])), + routes, +}; + +export default catalog; diff --git a/playgrounds/express/src/config.ts b/playgrounds/express/src/config.ts new file mode 100644 index 00000000..6319098d --- /dev/null +++ b/playgrounds/express/src/config.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +const env = (name: string): string | undefined => { + const value: string | undefined = process.env[name]?.trim(); + return value === '' ? undefined : value; +}; + +const port = Number(env('PORT') ?? 3000); +const appUrl: string = env('APP_URL') ?? `http://localhost:${port}`; + +/** Playground settings, read from the environment (see `.env.example`). */ +const config: { + adminScope: string; + afterSignInUrl: string; + afterSignOutUrl: string; + appUrl: string; + applicationId: string | undefined; + baseUrl: string; + clientId: string | undefined; + clientSecret: string | undefined; + flowSecret: string | undefined; + port: number; + scopes: string[]; +} = { + adminScope: env('PLAYGROUND_ADMIN_SCOPE') ?? 'system', + afterSignInUrl: env('THUNDERID_AFTER_SIGN_IN_URL') ?? `${appUrl}/login`, + afterSignOutUrl: env('THUNDERID_AFTER_SIGN_OUT_URL') ?? `${appUrl}/logout`, + appUrl, + applicationId: env('THUNDERID_APPLICATION_ID'), + baseUrl: env('THUNDERID_BASE_URL') ?? 'https://localhost:8090', + clientId: env('THUNDERID_CLIENT_ID'), + clientSecret: env('THUNDERID_CLIENT_SECRET'), + flowSecret: env('THUNDERID_FLOW_SECRET'), + port, + scopes: (env('THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/), +}; + +export default config; diff --git a/playgrounds/express/src/explorer.ts b/playgrounds/express/src/explorer.ts new file mode 100644 index 00000000..99aa425e --- /dev/null +++ b/playgrounds/express/src/explorer.ts @@ -0,0 +1,243 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {randomUUID} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import type {SessionData, ThunderIDExpressClient, User} from '@thunderid/express'; +import express, {type Request, type Response, type Router} from 'express'; +import catalog, {type RouteSpec} from './catalog.ts'; +import config from './config.ts'; +import {clientOf, sessionOf} from './helpers.ts'; +import {newTraceId, takeTrace, TRACE_HEADER, type TraceStep} from './trace.ts'; + +type Identity = 'anonymous' | 'session' | 'expired'; + +const SERVER_FILE: URL = new URL('./server.ts', import.meta.url); + +const readRegions = (): Map => { + const source: string = readFileSync(SERVER_FILE, 'utf8'); + const regions: Map = new Map(); + const pattern = /\/\/ #region (\S+)\n([\s\S]*?)\/\/ #endregion/g; + let match: RegExpExecArray | null = pattern.exec(source); + while (match) { + regions.set(match[1], match[2].trimEnd()); + match = pattern.exec(source); + } + return regions; +}; + +const readSdkVersion = (): string => { + const entry: string = fileURLToPath(import.meta.resolve('@thunderid/express')); + const manifest: {version: string} = JSON.parse( + readFileSync(new URL('../package.json', `file://${entry}`), 'utf8'), + ) as {version: string}; + return manifest.version; +}; + +/** + * The session record behind a session id. `@thunderid/node` exposes no public read of it (expiry, + * refresh token presence), so the playground reaches the client's storage manager directly. + */ +const sessionRecord = (client: ThunderIDExpressClient, sessionId: string): Promise => + (client as unknown as {getStorageManager(): {getSessionData(id: string): Promise}}) + .getStorageManager() + .getSessionData(sessionId); + +const expiresAt = (data: SessionData | undefined): number | undefined => + data?.created_at && data.expires_in ? data.created_at + parseInt(data.expires_in, 10) * 1000 : undefined; + +const displayName = (user: User): string => { + const u: Record = user as Record; + const given: unknown = u['given_name'] ?? u['givenName']; + const family: unknown = u['family_name'] ?? u['familyName']; + const named: string = [given, family].filter((part: unknown): part is string => typeof part === 'string').join(' '); + const candidates: unknown[] = [u['name'], named, u['username'], u['email'], u['sub']]; + const found: unknown = candidates.find((value: unknown): boolean => typeof value === 'string' && value !== ''); + return typeof found === 'string' ? found : 'Signed-in user'; +}; + +const idpStatus = async (): Promise<{error?: string; reachable: boolean}> => { + try { + await fetch(`${config.baseUrl}/.well-known/openid-configuration`, {signal: AbortSignal.timeout(1500)}); + return {reachable: true}; + } catch (e: unknown) { + const cause: unknown = e instanceof Error ? e.cause : undefined; + return {error: cause instanceof Error ? cause.message : String(e), reachable: false}; + } +}; + +// One expired copy per real session, rebuilt before every send that uses it. +const expiredCopies: Map = new Map(); + +/** + * Builds the "expired access token" identity: a copy of the caller's session whose access token + * expired a minute ago. The SDK then sees a real expired session, and the caller's own session is + * left alone. + */ +const expiredCopyOf = async (client: ThunderIDExpressClient, sessionId: string): Promise => { + const data: SessionData | undefined = await sessionRecord(client, sessionId); + if (!data?.access_token) return undefined; + + const copyId: string = expiredCopies.get(sessionId) ?? randomUUID(); + expiredCopies.set(sessionId, copyId); + const lifetimeSeconds: number = parseInt(data.expires_in, 10) || 3600; + await client.setSession({...data, created_at: Date.now() - (lifetimeSeconds + 60) * 1000}, copyId); + return copyId; +}; + +const shellQuote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`; + +const buildCurl = (method: string, url: string, headers: Record, body?: string): string => { + const lines: string[] = [`curl -i -X ${method} ${shellQuote(url)}`]; + Object.entries(headers).forEach(([name, value]: [string, string]): void => { + lines.push(` -H ${shellQuote(`${name}: ${value}`)}`); + }); + if (body) lines.push(` --data ${shellQuote(body)}`); + return lines.join(' \\\n'); +}; + +const prettyBody = (text: string, contentType: string | null): string => { + if (!text) return ''; + if (contentType?.includes('json')) { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + return text; +}; + +const waitForTrace = async (id: string): Promise => { + let steps: TraceStep[] = takeTrace(id); + for (let attempt = 0; steps.length === 0 && attempt < 10; attempt += 1) { + await new Promise((resolve: (value: unknown) => void): void => { + setTimeout(resolve, 10); + }); + steps = takeTrace(id); + } + return steps; +}; + +/** The API explorer's backend: route catalogue, session state and the request runner. */ +const explorer = (): Router => { + const router: Router = express.Router(); + const regions: Map = readRegions(); + const sdkVersion: string = readSdkVersion(); + + router.get('/meta', async (req: Request, res: Response) => { + const client: ThunderIDExpressClient = clientOf(req); + const sessionId: string | undefined = sessionOf(req); + let session: Record = {present: false}; + + if (sessionId) { + const data: SessionData | undefined = await sessionRecord(client, sessionId); + const signedIn: boolean = await client.isSignedIn(sessionId); + let name: string | undefined; + try { + name = data?.id_token ? displayName(await client.getUser(sessionId)) : undefined; + } catch { + name = undefined; + } + session = { + expiresAt: expiresAt(data), + hasRefreshToken: Boolean(data?.refresh_token), + hasTokens: Boolean(data?.access_token), + name, + present: true, + scopes: data?.scope?.split(' ') ?? [], + signedIn, + }; + } + + res.json({ + app: config.appUrl, + configured: Boolean(config.clientId), + cookieName: client.getSessionCookieName(), + idp: {baseUrl: config.baseUrl, ...(await idpStatus())}, + routes: catalog.routes.map((route: RouteSpec) => ({ + ...route, + source: route.regions.map((name: string): string => regions.get(name) ?? '').join('\n\n'), + })), + sdk: {name: '@thunderid/express', version: sdkVersion}, + session, + }); + }); + + router.post('/send', async (req: Request, res: Response) => { + const {body, identity, params, routeId} = (req.body ?? {}) as { + body?: string; + identity?: Identity; + params?: Record; + routeId?: string; + }; + const route: RouteSpec | undefined = catalog.routes.find((r: RouteSpec): boolean => r.id === routeId); + + if (!route) { + res.status(404).json({message: 'Unknown route.'}); + return; + } + + const client: ThunderIDExpressClient = clientOf(req); + const ownSession: string | undefined = sessionOf(req); + let sessionId: string | undefined; + + if (identity === 'session' || identity === 'expired') { + if (!ownSession) { + res.status(409).json({message: 'Sign in first: this identity uses your playground session.'}); + return; + } + sessionId = identity === 'session' ? ownSession : await expiredCopyOf(client, ownSession); + if (!sessionId) { + res.status(409).json({message: 'Your session holds no tokens to expire. Sign in again.'}); + return; + } + } + + let path: string = route.path; + const query: URLSearchParams = new URLSearchParams(); + route.params.forEach((param: {name: string; where: string}): void => { + const value: string = params?.[param.name] ?? ''; + if (param.where === 'path') path = path.replace(`:${param.name}`, encodeURIComponent(value)); + else if (value) query.set(param.name, value); + }); + const url = `${config.appUrl}${path}${query.size ? `?${query.toString()}` : ''}`; + + const headers: Record = {}; + if (sessionId) headers['Cookie'] = `${client.getSessionCookieName()}=${sessionId}`; + const sendBody: string | undefined = route.method === 'POST' ? (body ?? route.body ?? '') : undefined; + if (sendBody !== undefined && route.contentType) headers['Content-Type'] = route.contentType; + + const traceId: string = newTraceId(); + const started: number = performance.now(); + + try { + const response: globalThis.Response = await fetch(url, { + body: sendBody, + headers: {...headers, [TRACE_HEADER]: traceId}, + method: route.method, + redirect: 'manual', + }); + const text: string = await response.text(); + const ms: number = Math.round(performance.now() - started); + + res.json({ + body: prettyBody(text, response.headers.get('content-type')), + curl: buildCurl(route.method, url, headers, sendBody), + headers: [...response.headers.entries()], + ms, + size: Buffer.byteLength(text), + status: response.status, + statusText: response.statusText, + trace: await waitForTrace(traceId), + }); + } catch (e: unknown) { + res.status(502).json({message: `The request did not complete: ${e instanceof Error ? e.message : String(e)}`}); + } + }); + + return router; +}; + +export default explorer; diff --git a/playgrounds/express/src/helpers.ts b/playgrounds/express/src/helpers.ts new file mode 100644 index 00000000..e521cc68 --- /dev/null +++ b/playgrounds/express/src/helpers.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ThunderIDExpressClient} from '@thunderid/express'; +import type {Request} from 'express'; + +/** The client that `thunderID()` attaches to every request. */ +export const clientOf = (req: Request): ThunderIDExpressClient => + (req as Request & {thunderIDAuth: ThunderIDExpressClient}).thunderIDAuth; + +/** The session id from the SDK's session cookie, if the request carries one. */ +export const sessionOf = (req: Request): string | undefined => { + const cookies: Record | undefined = req.cookies as + | Record + | undefined; + return cookies?.[clientOf(req).getSessionCookieName()]; +}; diff --git a/playgrounds/express/src/server.ts b/playgrounds/express/src/server.ts new file mode 100644 index 00000000..4f3c755e --- /dev/null +++ b/playgrounds/express/src/server.ts @@ -0,0 +1,194 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// The API explorer shows each route's source by reading the `#region` blocks of this file, so keep +// a route's code inside its region. + +import {fileURLToPath} from 'node:url'; +import { + handleBackchannelLogout, + handleFlow, + handleSignIn, + handleSignOut, + protect, + thunderID, + type ThunderIDRuntimeError, + type TokenResponse, +} from '@thunderid/express'; +import cookieParser from 'cookie-parser'; +import express, {type NextFunction, type Request, type RequestHandler, type Response} from 'express'; +import catalog from './catalog.ts'; +import config from './config.ts'; +import explorer from './explorer.ts'; +import {clientOf, sessionOf} from './helpers.ts'; +import {traceFetch, traceRequests, traceRoutes, traceSdkClient, traceSdkLogs} from './trace.ts'; + +traceSdkLogs(); +traceFetch(); + +const app: express.Express = express(); + +app.use(traceRequests()); +app.use(cookieParser()); +app.use(express.json()); + +// #region setup +app.use( + thunderID({ + afterSignInUrl: config.afterSignInUrl, + afterSignOutUrl: config.afterSignOutUrl, + applicationId: config.applicationId, + baseUrl: config.baseUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, + flowSecret: config.flowSecret, + onError: (res: Response, error: ThunderIDRuntimeError) => + res.status(500).json({error: error.code, message: error.message}), + onSignIn: (res: Response) => res.redirect('/'), + onSignOut: (res: Response) => res.redirect('/'), + scopes: config.scopes, + }), +); +// #endregion + +app.use(traceSdkClient()); + +// #region unauthenticated +const unauthenticated = (res: Response): void => { + res + .status(401) + .set('WWW-Authenticate', 'Bearer realm="playground"') + .json({error: 'unauthenticated', message: 'Sign in to access this resource.'}); +}; +// #endregion + +// #region requireScope +// protect() checks for a session only. Scope checks are up to the application: this one reads the +// `scope` claim of the session's access token. +const requireScope = + (scope: string): RequestHandler => + async (req: Request, res: Response, next: NextFunction): Promise => { + const client = clientOf(req); + const accessToken: string = await client.getAccessToken(sessionOf(req)); + let granted: string[] = []; + try { + const claims = await client.decodeJwtToken<{scope?: string}>(accessToken); + granted = claims.scope?.split(' ') ?? []; + } catch { + // An opaque access token has no claims to read, so it grants nothing here. + } + if (granted.includes(scope)) { + next(); + return; + } + res.status(403).json({error: 'insufficient_scope', granted, required: [scope]}); + }; +// #endregion + +// #region login +app.get('/login', handleSignIn()); +// #endregion + +// #region logout +app.get('/logout', handleSignOut()); +// #endregion + +// #region flow +app.post('/flow/sign-in', handleFlow()); +// #endregion + +// #region backchannel +app.post('/backchannel-logout', handleBackchannelLogout()); +// #endregion + +// #region public +app.get('/api/public', (_req: Request, res: Response) => { + res.json({message: 'Anyone can call this route.', time: new Date().toISOString()}); +}); +// #endregion + +// #region session +app.get('/api/session', async (req: Request, res: Response) => { + const sessionId = sessionOf(req); + const signedIn = sessionId ? await clientOf(req).isSignedIn(sessionId) : false; + res.json({sessionCookie: sessionId ? 'present' : 'absent', signedIn}); +}); +// #endregion + +// #region me +app.get('/api/me', protect(unauthenticated), async (req: Request, res: Response) => { + res.json(await clientOf(req).getUser(sessionOf(req))); +}); +// #endregion + +// #region idToken +app.get('/api/id-token', protect(unauthenticated), async (req: Request, res: Response) => { + res.json(await clientOf(req).getDecodedIdToken(sessionOf(req))); +}); +// #endregion + +// #region refresh +// Not behind protect(): a session whose access token expired is exactly the one to refresh. +app.post('/api/token/refresh', async (req: Request, res: Response) => { + const sessionId = sessionOf(req); + if (!sessionId) { + unauthenticated(res); + return; + } + const tokens = (await clientOf(req).refreshAccessToken(sessionId)) as TokenResponse; + res.json({expiresIn: tokens.expiresIn, refreshed: true, scope: tokens.scope, tokenType: tokens.tokenType}); +}); +// #endregion + +// #region orders +const orders = [ + {id: 'ord_1001', item: 'Noise-cancelling headphones', status: 'shipped', total: 249}, + {id: 'ord_1002', item: 'Mechanical keyboard', status: 'processing', total: 129}, +]; + +app.get('/api/orders', protect(unauthenticated), (_req: Request, res: Response) => { + res.json({orders}); +}); +// #endregion + +// #region order +app.get('/api/orders/:id', protect(unauthenticated), (req: Request, res: Response) => { + const order = orders.find((o) => o.id === req.params['id']); + if (!order) { + res.status(404).json({error: 'not_found'}); + return; + } + res.json(order); +}); +// #endregion + +// #region admin +app.get( + '/api/admin/users', + protect(unauthenticated), + requireScope(config.adminScope), + async (req: Request, res: Response) => { + const limit = Number(req.query['limit'] ?? 10); + res.json(await clientOf(req).getUsers({limit}, sessionOf(req))); + }, +); +// #endregion + +traceRoutes(app.router as unknown as Parameters[0], catalog.chains()); + +app.use('/_playground', explorer()); +app.use(express.static(fileURLToPath(new URL('../public', import.meta.url)))); + +// #region errors +app.use((err: Error & {code?: string; statusCode?: number}, _req: Request, res: Response, next: NextFunction) => { + if (res.headersSent) { + next(err); + return; + } + res.status(err.statusCode ?? 500).json({error: err.code ?? err.name, message: err.message}); +}); +// #endregion + +app.listen(config.port, () => { + process.stdout.write(`ThunderID Express playground on ${config.appUrl} (ThunderID at ${config.baseUrl})\n`); +}); diff --git a/playgrounds/express/src/trace.ts b/playgrounds/express/src/trace.ts new file mode 100644 index 00000000..ee5a1428 --- /dev/null +++ b/playgrounds/express/src/trace.ts @@ -0,0 +1,314 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Playground-only request tracing. `@thunderid/express` has no debug event stream, so the + * playground builds the SDK trace from three sources: + * + * - the SDK's own logger, through its public `formatter` hook (`logger.configure()`); + * - wrappers around the middleware and handlers mounted on each route; + * - wrappers around the client methods those handlers call, and around `fetch`, which the SDK + * uses for every request to ThunderID. + * + * Each traced request carries an `x-playground-trace` header set by the explorer. Its steps are + * collected in an `AsyncLocalStorage` context and kept briefly so the explorer can read them. + */ + +import {AsyncLocalStorage} from 'node:async_hooks'; +import {randomUUID} from 'node:crypto'; +import {logger, type LogLevel, type ThunderIDExpressClient} from '@thunderid/express'; +import type {NextFunction, Request, RequestHandler, Response} from 'express'; + +export type TraceKind = 'ok' | 'info' | 'warn' | 'error'; + +export type TraceSource = 'middleware' | 'client' | 'http' | 'log'; + +export interface TraceStep { + at: number; + depth: number; + detail: string; + durationMs?: number; + kind: TraceKind; + source: TraceSource; + step: string; +} + +interface TraceContext { + depth: number; + start: number; + steps: TraceStep[]; +} + +export const TRACE_HEADER = 'x-playground-trace'; + +const KEEP_TRACES = 50; + +const storage: AsyncLocalStorage = new AsyncLocalStorage(); + +const finished: Map = new Map(); + +const begin = (step: string, source: TraceSource): ((detail: string, kind: TraceKind) => void) => { + const ctx: TraceContext | undefined = storage.getStore(); + + if (!ctx) { + return (): void => undefined; + } + + const startedAt: number = performance.now(); + const entry: TraceStep = { + at: Math.round(startedAt - ctx.start), + depth: ctx.depth, + detail: '', + kind: 'info', + source, + step, + }; + + ctx.steps.push(entry); + ctx.depth += 1; + + return (detail: string, kind: TraceKind): void => { + ctx.depth -= 1; + entry.detail = detail; + entry.kind = kind; + entry.durationMs = Math.round(performance.now() - startedAt); + }; +}; + +const record = (step: string, detail: string, kind: TraceKind, source: TraceSource): void => { + begin(step, source)(detail, kind); +}; + +const kindForStatus = (status: number): TraceKind => { + if (status >= 500) return 'error'; + if (status >= 400) return 'warn'; + return 'ok'; +}; + +/** Starts a trace for requests sent by the explorer. Mount it before everything else. */ +export const traceRequests = + (): RequestHandler => + (req: Request, res: Response, next: NextFunction): void => { + const id: string | undefined = req.get(TRACE_HEADER); + + if (!id) { + next(); + return; + } + + const ctx: TraceContext = {depth: 0, start: performance.now(), steps: []}; + + res.on('finish', (): void => { + finished.set(id, ctx.steps); + if (finished.size > KEEP_TRACES) { + finished.delete(finished.keys().next().value!); + } + }); + + storage.run(ctx, next); + }; + +export const newTraceId = (): string => randomUUID(); + +/** Returns and forgets the steps recorded for a finished request. */ +export const takeTrace = (id: string): TraceStep[] => { + const steps: TraceStep[] = finished.get(id) ?? []; + finished.delete(id); + return steps; +}; + +/** + * Wraps a middleware or handler so the trace shows whether it passed the request on or answered + * it, and with what status. + */ +export const traced = + (label: string, handler: RequestHandler): RequestHandler => + async (req: Request, res: Response, next: NextFunction): Promise => { + const end: (detail: string, kind: TraceKind) => void = begin(label, 'middleware'); + let passed = false; + + try { + await handler(req, res, (err?: unknown): void => { + passed = true; + if (res.headersSent && !err) { + end(`responded ${res.statusCode}, then called next()`, kindForStatus(res.statusCode)); + next(); + return; + } + end( + err ? `next(error): ${err instanceof Error ? err.message : 'error'}` : 'passed the request on', + err ? 'error' : 'ok', + ); + next(err); + }); + } catch (e: unknown) { + end(`threw: ${e instanceof Error ? e.message : String(e)}`, 'error'); + throw e; + } + + if (!passed) { + // Some handlers (handleBackchannelLogout()) answer from a callback after they return. + if (!res.writableEnded) { + await new Promise((resolve: () => void): void => { + res.once('finish', resolve); + res.once('close', resolve); + }); + } + const location: string | undefined = res.get('location'); + const status: number = res.statusCode; + end(location ? `responded ${status}, location ${location}` : `responded ${status}`, kindForStatus(status)); + } + }; + +/** Route-layer shape of Express 5's router, enough to wrap each handler in place. */ +interface RouterLayer { + handle: RequestHandler; + route?: {methods: Record; path: string; stack: RouterLayer[]}; +} + +/** + * Wraps the handlers of every route registered on `app` with {@link traced}, labelling them from + * `chains` (keyed by `METHOD path`), so route definitions stay free of tracing code. + */ +export const traceRoutes = (router: {stack: RouterLayer[]}, chains: Map): void => { + router.stack.forEach((layer: RouterLayer): void => { + if (!layer.route) return; + const method: string = Object.keys(layer.route.methods)[0]?.toUpperCase() ?? 'GET'; + const labels: string[] = chains.get(`${method} ${layer.route.path}`) ?? []; + layer.route.stack.forEach((routeLayer: RouterLayer, index: number): void => { + routeLayer.handle = traced(labels[index] ?? 'handler', routeLayer.handle); + }); + }); +}; + +const summarize = (value: unknown): string => { + if (typeof value === 'boolean') return String(value); + if (value === undefined || value === null || value === '') return 'nothing'; + if (typeof value === 'string') { + if (value.startsWith('http')) { + const url: URL = new URL(value); + return `${url.host}${url.pathname}`; + } + return `string (${value.length} chars)`; + } + if (typeof value === 'object') { + const keys: string[] = Object.keys(value); + return `{${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', ...' : ''}}`; + } + return typeof value; +}; + +const shortId = (value: unknown): string => (typeof value === 'string' ? `sid=${value.slice(0, 8)}...` : ''); + +// Client methods to trace, with the position of their session id argument (-1: none). +const CLIENT_METHODS: Record = { + decodeJwtToken: -1, + getAccessToken: 0, + getDecodedIdToken: 0, + getIdToken: 0, + getSignInUrl: 1, + getUser: 0, + getUsers: 1, + handleBackchannelLogout: -1, + isSignedIn: 0, + refreshAccessToken: 0, + signOut: 0, +}; + +const instrumented: WeakSet = new WeakSet(); + +/** Wraps the client's public methods on the instance so nested SDK calls show up in the trace. */ +const traceClient = (client: ThunderIDExpressClient): void => { + if (instrumented.has(client)) return; + instrumented.add(client); + + const target: Record = client as unknown as Record; + + Object.entries(CLIENT_METHODS).forEach(([name, sessionIndex]: [string, number]): void => { + const original: unknown = target[name]; + if (typeof original !== 'function') return; + + target[name] = async (...args: unknown[]): Promise => { + const sessionArg: unknown = sessionIndex >= 0 ? args[sessionIndex] : undefined; + const end: (detail: string, kind: TraceKind) => void = begin(`client.${name}(${shortId(sessionArg)})`, 'client'); + try { + const result: unknown = await (original as (...a: unknown[]) => Promise).apply(client, args); + end(`returned ${summarize(result)}`, result === false ? 'warn' : 'ok'); + return result; + } catch (e: unknown) { + end(`threw ${e instanceof Error ? e.message : String(e)}`, 'error'); + throw e; + } + }; + }); +}; + +/** + * Mount right after `thunderID()`: instruments the client it attached and notes the step in the + * trace (an app-level middleware is not wrapped by {@link traceRoutes}). + */ +export const traceSdkClient = + (): RequestHandler => + (req: Request, _res: Response, next: NextFunction): void => { + const client: ThunderIDExpressClient | undefined = (req as Request & {thunderIDAuth?: ThunderIDExpressClient}) + .thunderIDAuth; + if (client) traceClient(client); + record( + 'thunderID()', + client ? 'initialised the client and attached it to req.thunderIDAuth' : 'no client attached', + client ? 'ok' : 'error', + 'middleware', + ); + next(); + }; + +const describeBody = (body: unknown): string => { + if (typeof body !== 'string') return ''; + const grant: string | null = new URLSearchParams(body).get('grant_type'); + return grant ? ` grant_type=${grant}` : ''; +}; + +/** Wraps `fetch` so the SDK's requests to ThunderID appear in the trace. */ +export const traceFetch = (): void => { + const original: typeof fetch = globalThis.fetch; + + globalThis.fetch = async ( + input: string | URL | globalThis.Request, + init?: RequestInit, + ): Promise => { + if (!storage.getStore()) return original(input, init); + + const url: URL = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + const method: string = init?.method ?? (input instanceof globalThis.Request ? input.method : 'GET'); + const end: (detail: string, kind: TraceKind) => void = begin( + `${method} ${url.host}${url.pathname}${describeBody(init?.body)}`, + 'http', + ); + + try { + const response: globalThis.Response = await original(input, init); + end(`${response.status} ${response.statusText}`, kindForStatus(response.status)); + return response; + } catch (e: unknown) { + const cause: unknown = e instanceof Error ? e.cause : undefined; + end(`failed: ${cause instanceof Error ? cause.message : String(e)}`, 'error'); + throw e; + } + }; +}; + +const LOG_KIND: Record = {debug: 'info', error: 'error', info: 'info', warn: 'warn'}; + +/** + * Routes the SDK logger through its `formatter` hook: every line goes to stdout and, for a traced + * request, into its trace. + */ +export const traceSdkLogs = (): void => { + logger.configure({ + formatter: (level: LogLevel, message: string): void => { + process.stdout.write(`[ThunderID SDK] ${level.toUpperCase()} ${message}\n`); + record('SDK log', message, LOG_KIND[level], 'log'); + }, + level: 'debug', + }); +}; diff --git a/playgrounds/express/tsconfig.eslint.json b/playgrounds/express/tsconfig.eslint.json new file mode 100644 index 00000000..b03733cd --- /dev/null +++ b/playgrounds/express/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true + }, + "include": ["**/*.js", "**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/playgrounds/express/tsconfig.json b/playgrounds/express/tsconfig.json new file mode 100644 index 00000000..6b0a5b83 --- /dev/null +++ b/playgrounds/express/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/playgrounds/express/turbo.json b/playgrounds/express/turbo.json new file mode 100644 index 00000000..b743d5d2 --- /dev/null +++ b/playgrounds/express/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": [] + } + } +} diff --git a/playgrounds/react/.env.example b/playgrounds/react/.env.example new file mode 100644 index 00000000..a8604289 --- /dev/null +++ b/playgrounds/react/.env.example @@ -0,0 +1,22 @@ +# Base URL of your ThunderID server. +VITE_THUNDERID_BASE_URL=https://localhost:8090 + +# Client ID of the application registered in ThunderID (a public SPA client). +VITE_THUNDERID_CLIENT_ID= + +# Optional: ID of the same application. Needed for the app-native (embedded) sign-in, sign-up, +# and recovery flows rendered by , , and . +VITE_THUNDERID_APPLICATION_ID= + +# Where ThunderID sends the user after sign-in and sign-out. Register both as redirect URIs. +VITE_THUNDERID_AFTER_SIGN_IN_URL=http://localhost:5173 +VITE_THUNDERID_AFTER_SIGN_OUT_URL=http://localhost:5173 + +# Space separated scopes requested at sign-in. +VITE_THUNDERID_SCOPES=openid profile email + +# Optional: token storage. One of sessionStorage, localStorage, browserMemory. Unset uses the SDK default. +VITE_THUNDERID_STORAGE= + +# Optional: URL of the Express playground, linked from the SDK switcher. +VITE_PLAYGROUND_EXPRESS_URL=http://localhost:3000 diff --git a/playgrounds/react/.gitignore b/playgrounds/react/.gitignore new file mode 100644 index 00000000..3ea646c5 --- /dev/null +++ b/playgrounds/react/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.local +.env.*.local diff --git a/playgrounds/react/README.md b/playgrounds/react/README.md new file mode 100644 index 00000000..8e1c97c4 --- /dev/null +++ b/playgrounds/react/README.md @@ -0,0 +1,70 @@ +# ThunderID React Playground + +A Vite app that exercises every capability of `@thunderid/react` against a local ThunderID server. It links the SDK +packages from this workspace (`workspace:*`), so it always runs the code you are changing. It is not published. + +| Page | What it does | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Overview (`#/`) | Capability checks driven by the real SDK API (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown, the provider configuration, and a live SDK event log. | +| Components (`#/components`) | A component explorer: a searchable tree of the SDK components and their stories, a viewport, theme, and grid toolbar, and Controls, Code, Actions, and Accessibility panels. | +| Hooks (`#/hooks`) | Live values of `useThunderID`, `useUser`, `useI18n`, `useTranslation`, `useTheme`, `useFlow`, and the management query hooks. | +| Tokens (`#/tokens`) | The access and ID tokens held by the SDK, decoded locally, with expiry and claim notes. | + +## Set up a local ThunderID server + +1. Start ThunderID on `https://localhost:8090` (see the [ThunderID docs](https://thunderid.dev)) and sign in to the + Console. +2. Create a single-page (public) application and register these URLs: + - Redirect URIs: `http://localhost:5173` + - Allowed origins (CORS): `http://localhost:5173` +3. Note the application's client ID and application ID. +4. Accept the server's self-signed certificate once by opening `https://localhost:8090` in the browser you use for the + playground. + +## Run + +```bash +# From the repository root: install and build the SDKs once. +pnpm install +pnpm build + +# Configure the playground. +cp playgrounds/react/.env.example playgrounds/react/.env +# Set VITE_THUNDERID_CLIENT_ID, and VITE_THUNDERID_APPLICATION_ID for the app-native flows. + +pnpm --filter @thunderid/playground-react dev +``` + +Open http://localhost:5173. After changing SDK source, rebuild that package (for example +`pnpm --filter @thunderid/react build`) and reload. + +| Variable | Purpose | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `VITE_THUNDERID_BASE_URL` | ThunderID server. Defaults to `https://localhost:8090`. | +| `VITE_THUNDERID_CLIENT_ID` | Client ID of the SPA application. Required for sign-in. | +| `VITE_THUNDERID_APPLICATION_ID` | Application ID. Needed by ``, ``, `` and their checks. | +| `VITE_THUNDERID_AFTER_SIGN_IN_URL`, `VITE_THUNDERID_AFTER_SIGN_OUT_URL` | Redirect targets. Default to the app origin. | +| `VITE_THUNDERID_SCOPES` | Space separated scopes. Defaults to `openid profile email`. | +| `VITE_THUNDERID_STORAGE` | `sessionStorage`, `localStorage`, or `browserMemory`. Unset uses the SDK default. | +| `VITE_PLAYGROUND_EXPRESS_URL` | Where the SDK switcher sends you for the Express playground. Defaults to `http://localhost:3000`. | + +Without a server or a client ID the app still runs: checks that need one fail with the reason. + +## Checks + +Checks that would leave the page (redirect sign-in, sign-out) only run from their own **Run** button; **Run all checks** +marks them as not run. Results are kept in `sessionStorage` so they survive the sign-in redirect. + +## Known SDK gaps + +The playground reports these instead of faking them: + +- No organization API in `@thunderid/react` (`useOrganization`, `switchOrganization`): the Organizations check is shown + as unavailable. +- No public event subscription. The browser client has `on('sign-in' | 'sign-out' | ...)` hooks, but `ThunderIDProvider` + keeps them internal. The event log is built from observed `useThunderID()`/`useUser()` state changes plus the SDK + calls the playground makes. Exposing `on()` (sign-in, sign-out, token refreshed, refresh failed, HTTP request + start/finish/error) would let apps and this log subscribe directly. +- No manual token refresh or access token expiry on the context. The session card reads expiry from + `getStorageManager().getSessionData()`, and "Renew silently" uses `signInSilently()`. +- `user`, `signIn`, `signOut`, `signUp`, `recover`, and `getStorageManager` are typed as `any`; see `src/lib/sdk.ts`. diff --git a/playgrounds/react/eslint.config.js b/playgrounds/react/eslint.config.js new file mode 100644 index 00000000..f5fd8bcd --- /dev/null +++ b/playgrounds/react/eslint.config.js @@ -0,0 +1,11 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import thunderIdPlugin from '@thunderid/eslint-plugin'; + +export default [ + { + ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**'], + }, + ...thunderIdPlugin.configs.react, +]; diff --git a/playgrounds/react/index.html b/playgrounds/react/index.html new file mode 100644 index 00000000..396e104d --- /dev/null +++ b/playgrounds/react/index.html @@ -0,0 +1,18 @@ + + + + + + + + + ThunderID React Playground + + +
+ + + diff --git a/playgrounds/react/package.json b/playgrounds/react/package.json new file mode 100644 index 00000000..93ef16f2 --- /dev/null +++ b/playgrounds/react/package.json @@ -0,0 +1,35 @@ +{ + "name": "@thunderid/playground-react", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Playground for exercising every @thunderid/react capability against a local ThunderID server.", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", + "lint:fix": "eslint . --fix --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", + "format:check": "prettier --check --cache .", + "format:fix": "prettier --write --cache ." + }, + "dependencies": { + "@thunderid/browser": "workspace:*", + "@thunderid/react": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@thunderid/eslint-plugin": "catalog:", + "@thunderid/prettier-config": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:", + "vite": "^6.3.5" + } +} diff --git a/playgrounds/react/prettier.config.js b/playgrounds/react/prettier.config.js new file mode 100644 index 00000000..f054cd50 --- /dev/null +++ b/playgrounds/react/prettier.config.js @@ -0,0 +1,6 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import config from '@thunderid/prettier-config'; + +export default config; diff --git a/playgrounds/react/public/favicon.svg b/playgrounds/react/public/favicon.svg new file mode 100644 index 00000000..3fa7c648 --- /dev/null +++ b/playgrounds/react/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/playgrounds/react/src/App.tsx b/playgrounds/react/src/App.tsx new file mode 100644 index 00000000..5790aabe --- /dev/null +++ b/playgrounds/react/src/App.tsx @@ -0,0 +1,29 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ReactElement} from 'react'; +import SdkObserver from './components/SdkObserver'; +import TopBar from './components/TopBar'; +import {Section, useHashPath} from './lib/router'; +import Explorer from './pages/explorer/Explorer'; +import Hooks from './pages/Hooks'; +import Overview from './pages/Overview'; +import Tokens from './pages/Tokens'; + +const App = (): ReactElement => { + const [first, ...rest] = useHashPath(); + const section: Section = first === 'components' || first === 'hooks' || first === 'tokens' ? first : 'overview'; + + return ( +
+ + + {section === 'overview' && } + {section === 'components' && } + {section === 'hooks' && } + {section === 'tokens' && } +
+ ); +}; + +export default App; diff --git a/playgrounds/react/src/components/ErrorBoundary.tsx b/playgrounds/react/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..6f533872 --- /dev/null +++ b/playgrounds/react/src/components/ErrorBoundary.tsx @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Component, ErrorInfo, ReactNode} from 'react'; +import {logEvent} from '../lib/events'; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Keeps a throwing story from taking down the whole explorer. + */ +class ErrorBoundary extends Component { + override state: ErrorBoundaryState = {error: null}; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return {error}; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + logEvent('story:error', `${error.message} ${info.componentStack?.split('\n')[1]?.trim() ?? ''}`, 'error'); + } + + override render(): ReactNode { + const {error} = this.state; + const {children} = this.props; + + if (error) { + return ( +
+ This story threw: {error.message} +
+ ); + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/playgrounds/react/src/components/EventLog.tsx b/playgrounds/react/src/components/EventLog.tsx new file mode 100644 index 00000000..801bebf3 --- /dev/null +++ b/playgrounds/react/src/components/EventLog.tsx @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ReactElement} from 'react'; +import {clearEvents, PlaygroundEvent, useEvents} from '../lib/events'; + +const EventLog = (): ReactElement => { + const events: PlaygroundEvent[] = useEvents(); + + return ( +
+
+

SDK events

+ +
+
    + {events.map((event: PlaygroundEvent) => ( +
  1. + {event.time} + + {event.name} + + + {event.detail} + +
  2. + ))} + {events.length === 0 &&
  3. No events yet.
  4. } +
+
+ ); +}; + +export default EventLog; diff --git a/playgrounds/react/src/components/Icons.tsx b/playgrounds/react/src/components/Icons.tsx new file mode 100644 index 00000000..03b9fd0f --- /dev/null +++ b/playgrounds/react/src/components/Icons.tsx @@ -0,0 +1,152 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ReactElement} from 'react'; + +/** The ThunderID mark, with the top bar lightened for the playground's dark background. */ +export const Logo = ({height = 26}: {height?: number}): ReactElement => ( + + + + + +); + +export const Spinner = ({size = 14}: {size?: number}): ReactElement => ( + +); + +export const Play = (): ReactElement => ( + +); + +const stroke = { + fill: 'none', + stroke: 'currentColor', + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + strokeWidth: 2, +}; + +export const Check = (): ReactElement => ( + +); + +export const Cross = (): ReactElement => ( + +); + +export const Dash = (): ReactElement => ( + +); + +export const Bang = (): ReactElement => ( + +); + +export const Search = (): ReactElement => ( + +); + +export const Chevron = (): ReactElement => ( + +); + +export const Grid4 = (): ReactElement => ( + +); + +export const Desktop = (): ReactElement => ( + +); + +export const Tablet = (): ReactElement => ( + +); + +export const Mobile = (): ReactElement => ( + +); + +export const Moon = (): ReactElement => ( + +); + +export const Sun = (): ReactElement => ( + +); + +export const GridBg = (): ReactElement => ( + +); + +export const Copy = (): ReactElement => ( + +); diff --git a/playgrounds/react/src/components/SdkObserver.tsx b/playgrounds/react/src/components/SdkObserver.tsx new file mode 100644 index 00000000..a2296444 --- /dev/null +++ b/playgrounds/react/src/components/SdkObserver.tsx @@ -0,0 +1,67 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useThunderID, useUser} from '@thunderid/react'; +import {ReactElement, useEffect, useRef} from 'react'; +import {logEvent} from '../lib/events'; +import {toUser, SdkUser} from '../lib/sdk'; +import {getDisplayName} from '../lib/user'; + +/** + * Turns changes in the SDK's React state into playground events. @thunderid/react does not expose an + * event subscription API, so state transitions observed through `useThunderID()` and `useUser()` are + * the closest public signal. + */ +const SdkObserver = (): ReactElement | null => { + const sdk = useThunderID(); + const {isInitialized, isSignedIn, isLoading, meta, discovery} = sdk; + const user: SdkUser | null = toUser(sdk.user); + const {profile} = useUser(); + const previous = useRef<{isSignedIn: boolean | null}>({isSignedIn: null}); + + useEffect(() => { + if (isInitialized) logEvent('sdk:initialized', 'ThunderIDProvider is ready', 'ok'); + }, [isInitialized]); + + useEffect(() => { + if (!isInitialized) return; + + const before: boolean | null = previous.current.isSignedIn; + + if (before === null) { + logEvent( + isSignedIn ? 'session:restored' : 'session:none', + isSignedIn ? 'signed in from storage' : 'signed out', + 'info', + ); + } else if (before !== isSignedIn) { + logEvent(isSignedIn ? 'signIn:completed' : 'signOut:completed', '', 'ok'); + } + + previous.current.isSignedIn = isSignedIn; + }, [isInitialized, isSignedIn]); + + useEffect(() => { + logEvent('sdk:loading', isLoading ? 'true' : 'false', 'info'); + }, [isLoading]); + + useEffect(() => { + if (user) logEvent('user:loaded', getDisplayName(user), 'ok'); + }, [user]); + + useEffect(() => { + if (profile) logEvent('profile:loaded', `${Object.keys(profile).length} fields`, 'ok'); + }, [profile]); + + useEffect(() => { + if (meta) logEvent('flowMeta:loaded', 'GET /flow/meta', 'ok'); + }, [meta]); + + useEffect(() => { + if (discovery.wellKnown) logEvent('discovery:loaded', String(discovery.wellKnown.issuer ?? ''), 'ok'); + }, [discovery.wellKnown]); + + return null; +}; + +export default SdkObserver; diff --git a/playgrounds/react/src/components/SessionCard.tsx b/playgrounds/react/src/components/SessionCard.tsx new file mode 100644 index 00000000..24cfbf3f --- /dev/null +++ b/playgrounds/react/src/components/SessionCard.tsx @@ -0,0 +1,155 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useThunderID} from '@thunderid/react'; +import {ReactElement, useState} from 'react'; +import {describeError, track} from '../lib/events'; +import {formatClaim, formatDuration} from '../lib/jwt'; +import {href} from '../lib/router'; +import {asText, sdkSignIn, sdkSignOut, toUser, SdkUser} from '../lib/sdk'; +import {SessionSnapshot, useNow, useSession} from '../lib/session'; +import {getDisplayName, getEmail, getInitials} from '../lib/user'; + +const SessionCard = (): ReactElement => { + const sdk = useThunderID(); + const {isSignedIn, isInitialized, signInSilently} = sdk; + const user: SdkUser | null = toUser(sdk.user); + const [refreshKey, setRefreshKey] = useState(0); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const session: SessionSnapshot = useSession(refreshKey); + const now: number = useNow(); + + const act = async (label: string, call: () => Promise): Promise => { + setBusy(label); + setError(null); + + try { + await track(label, call); + setRefreshKey((key: number) => key + 1); + } catch (caught) { + setError(describeError(caught)); + } finally { + setBusy(null); + } + }; + + const remaining: number | null = session.expiresAt ? session.expiresAt - now : null; + const lifetime: number | null = session.expiresAt && session.issuedAt ? session.expiresAt - session.issuedAt : null; + const percent: number = remaining !== null && lifetime ? Math.max(0, Math.min(100, (remaining / lifetime) * 100)) : 0; + const claims: [string, unknown][] = session.idClaims + ? ( + [ + ['sub', session.idClaims['sub']], + ['scope', session.scope ?? session.idClaims['scope']], + ['amr', session.idClaims['amr']], + ] as [string, unknown][] + ).filter(([, value]: [string, unknown]) => value !== undefined) + : []; + + return ( +
+
+

Session

+ + {isSignedIn ? 'Signed in' : isInitialized ? 'Signed out' : 'Initializing'} + +
+
+
+ +
+
{isSignedIn ? getDisplayName(user) : 'No active session'}
+
+ {isSignedIn ? (getEmail(user) ?? asText(user?.['sub'])) : 'Sign in to inspect tokens and claims.'} +
+
+
+ + {isSignedIn && ( +
+
+ Access token + + {remaining === null + ? 'expiry unknown' + : remaining > 0 + ? `expires in ${formatDuration(remaining)}` + : 'expired'} + +
+
+
+
+
+ )} + + {claims.length > 0 && ( +
+ {claims.map(([key, value]: [string, unknown]) => ( +
+
{key}
+
{formatClaim(value)}
+
+ ))} +
+ )} + + {(error ?? session.error) && ( +

+ {error ?? session.error} +

+ )} + +
+ {isSignedIn ? ( + <> + + + Decode tokens + + + + ) : ( + + )} +
+
+
+ ); +}; + +export default SessionCard; diff --git a/playgrounds/react/src/components/TopBar.tsx b/playgrounds/react/src/components/TopBar.tsx new file mode 100644 index 00000000..37720f7d --- /dev/null +++ b/playgrounds/react/src/components/TopBar.tsx @@ -0,0 +1,95 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useThunderID} from '@thunderid/react'; +import {ChangeEvent, ReactElement, useEffect} from 'react'; +import {Logo} from './Icons'; +import playgroundConfig from '../config'; +import {checkServerHealth, useServerHealth} from '../lib/health'; +import {href, Section} from '../lib/router'; +import {toUser, SdkUser} from '../lib/sdk'; +import {getDisplayName, getInitials} from '../lib/user'; + +interface SdkOption { + href?: string; + id: string; + label: string; +} + +const SDK_OPTIONS: SdkOption[] = [ + {id: 'react', label: 'React Β· @thunderid/react'}, + {href: playgroundConfig.expressPlaygroundUrl, id: 'express', label: 'Express Β· @thunderid/express'}, + {id: 'vue', label: 'Vue Β· @thunderid/vue (no playground yet)'}, + {id: 'nextjs', label: 'Next.js Β· @thunderid/nextjs (no playground yet)'}, + {id: 'browser', label: 'Browser Β· @thunderid/browser (no playground yet)'}, +]; + +const NAV: [Section, string, string][] = [ + ['overview', 'Overview', '#/'], + ['components', 'Components', href('components')], + ['hooks', 'Hooks', href('hooks')], + ['tokens', 'Tokens', href('tokens')], +]; + +const TopBar = ({section}: {section: Section}): ReactElement => { + const sdk = useThunderID(); + const {isSignedIn} = sdk; + const user: SdkUser | null = toUser(sdk.user); + const health = useServerHealth(); + + useEffect(() => { + void checkServerHealth(); + }, []); + + const onSdkChange = (event: ChangeEvent): void => { + const option: SdkOption | undefined = SDK_OPTIONS.find((sdk: SdkOption) => sdk.id === event.target.value); + + if (option?.href) window.location.assign(option.href); + }; + + const host: string = playgroundConfig.baseUrl.replace(/^https?:\/\//, ''); + + return ( +
+
+ + ThunderID + Playground +
+ + +
+ + + {host} + +
+ {isSignedIn ? getInitials(user) : '?'} +
+
+ ); +}; + +export default TopBar; diff --git a/playgrounds/react/src/config.ts b/playgrounds/react/src/config.ts new file mode 100644 index 00000000..3f8935a1 --- /dev/null +++ b/playgrounds/react/src/config.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type StorageMode = 'sessionStorage' | 'localStorage' | 'browserMemory'; + +export interface PlaygroundConfig { + afterSignInUrl: string; + afterSignOutUrl: string; + applicationId?: string; + baseUrl: string; + clientId: string; + expressPlaygroundUrl: string; + scopes: string[]; + storage?: StorageMode; +} + +const env: Record = import.meta.env as unknown as Record; + +const read = (key: string): string | undefined => { + const value: string | undefined = env[key]?.trim(); + + return value === '' ? undefined : value; +}; + +const readStorage = (value: string | undefined): StorageMode | undefined => + value === 'sessionStorage' || value === 'localStorage' || value === 'browserMemory' ? value : undefined; + +const playgroundConfig: PlaygroundConfig = { + afterSignInUrl: read('VITE_THUNDERID_AFTER_SIGN_IN_URL') ?? window.location.origin, + afterSignOutUrl: read('VITE_THUNDERID_AFTER_SIGN_OUT_URL') ?? window.location.origin, + applicationId: read('VITE_THUNDERID_APPLICATION_ID'), + baseUrl: (read('VITE_THUNDERID_BASE_URL') ?? 'https://localhost:8090').replace(/\/+$/, ''), + clientId: read('VITE_THUNDERID_CLIENT_ID') ?? '', + expressPlaygroundUrl: read('VITE_PLAYGROUND_EXPRESS_URL') ?? 'http://localhost:3000', + scopes: (read('VITE_THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/).filter(Boolean), + storage: readStorage(read('VITE_THUNDERID_STORAGE')), +}; + +export default playgroundConfig; diff --git a/playgrounds/react/src/lib/events.ts b/playgrounds/react/src/lib/events.ts new file mode 100644 index 00000000..d2adec3b --- /dev/null +++ b/playgrounds/react/src/lib/events.ts @@ -0,0 +1,81 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useSyncExternalStore} from 'react'; + +export type EventKind = 'ok' | 'error' | 'info'; + +export interface PlaygroundEvent { + detail: string; + id: number; + kind: EventKind; + name: string; + time: string; +} + +const MAX_EVENTS = 200; + +let events: PlaygroundEvent[] = []; +let nextId = 1; +const listeners = new Set<() => void>(); + +const emit = (): void => listeners.forEach((listener: () => void) => listener()); + +export const timestamp = (): string => { + const now: Date = new Date(); + + return `${now.toTimeString().slice(0, 8)}.${String(now.getMilliseconds()).padStart(3, '0')}`; +}; + +export const logEvent = (name: string, detail = '', kind: EventKind = 'info'): void => { + events = [{detail, id: nextId, kind, name, time: timestamp()}, ...events].slice(0, MAX_EVENTS); + nextId += 1; + emit(); +}; + +export const clearEvents = (): void => { + events = []; + emit(); +}; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +}; + +const getSnapshot = (): PlaygroundEvent[] => events; + +export const useEvents = (): PlaygroundEvent[] => useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + +export const describeError = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +/** + * Runs an SDK call and records started, completed, and failed events for it. The React SDK has no + * public event subscription, so the playground instruments the calls it makes instead. + */ +export const track = async (name: string, call: () => Promise, describe?: (result: T) => string): Promise => { + logEvent(`${name}:started`, '', 'info'); + + try { + const result: T = await call(); + + logEvent(`${name}:completed`, describe ? describe(result) : '', 'ok'); + + return result; + } catch (error) { + logEvent(`${name}:failed`, describeError(error), 'error'); + throw error; + } +}; diff --git a/playgrounds/react/src/lib/health.ts b/playgrounds/react/src/lib/health.ts new file mode 100644 index 00000000..371e3f4d --- /dev/null +++ b/playgrounds/react/src/lib/health.ts @@ -0,0 +1,64 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useSyncExternalStore} from 'react'; +import {describeError, logEvent} from './events'; +import playgroundConfig from '../config'; + +export interface ServerHealth { + checkedAt: number | null; + detail: string; + state: 'unknown' | 'up' | 'down'; +} + +let health: ServerHealth = {checkedAt: null, detail: 'not checked', state: 'unknown'}; +const listeners = new Set<() => void>(); + +const setHealth = (next: ServerHealth): void => { + health = next; + listeners.forEach((listener: () => void) => listener()); +}; + +/** + * Probes the ThunderID liveness endpoint. This is a plain fetch, not an SDK call. + */ +export const checkServerHealth = async (): Promise => { + const url = `${playgroundConfig.baseUrl}/health/liveness`; + + try { + const response: Response = await fetch(url, {cache: 'no-store'}); + const next: ServerHealth = { + checkedAt: Date.now(), + detail: response.ok ? 'healthy' : `HTTP ${response.status}`, + state: response.ok ? 'up' : 'down', + }; + + setHealth(next); + logEvent('server:health', `${next.detail} (${url})`, response.ok ? 'ok' : 'error'); + + return next; + } catch (error) { + const next: ServerHealth = { + checkedAt: Date.now(), + detail: `unreachable: ${describeError(error)}`, + state: 'down', + }; + + setHealth(next); + logEvent('server:health', next.detail, 'error'); + + return next; + } +}; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +}; + +const getSnapshot = (): ServerHealth => health; + +export const useServerHealth = (): ServerHealth => useSyncExternalStore(subscribe, getSnapshot, getSnapshot); diff --git a/playgrounds/react/src/lib/inspect.ts b/playgrounds/react/src/lib/inspect.ts new file mode 100644 index 00000000..93f36b87 --- /dev/null +++ b/playgrounds/react/src/lib/inspect.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Serializes hook values for display: functions become signatures, long strings are shortened, and + * deep or circular structures are cut off. + */ +export const inspect = (value: unknown, maxDepth = 4): string => { + const seen = new WeakSet(); + + const walk = (current: unknown, depth: number): unknown => { + if (typeof current === 'function') return `Ζ’ ${current.name || 'anonymous'}()`; + if (typeof current === 'string') return current.length > 120 ? `${current.slice(0, 117)}…` : current; + if (current === null || typeof current !== 'object') return current; + if (seen.has(current)) return '[circular]'; + if (depth >= maxDepth) return Array.isArray(current) ? `[${current.length} items]` : '{…}'; + + seen.add(current); + + if (Array.isArray(current)) return current.slice(0, 20).map((item: unknown) => walk(item, depth + 1)); + + return Object.fromEntries( + Object.entries(current as Record).map(([key, item]: [string, unknown]) => [ + key, + walk(item, depth + 1), + ]), + ); + }; + + return JSON.stringify(walk(value, 0), null, 2) ?? 'undefined'; +}; diff --git a/playgrounds/react/src/lib/jwt.ts b/playgrounds/react/src/lib/jwt.ts new file mode 100644 index 00000000..4276a0f3 --- /dev/null +++ b/playgrounds/react/src/lib/jwt.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type JwtClaims = Record; + +export interface DecodedJwt { + header: JwtClaims; + payload: JwtClaims; +} + +const decodeSegment = (segment: string): JwtClaims => { + const base64: string = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded: string = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const binary: string = atob(padded); + const bytes: Uint8Array = Uint8Array.from(binary, (char: string) => char.charCodeAt(0)); + + return JSON.parse(new TextDecoder().decode(bytes)) as JwtClaims; +}; + +/** + * Decodes a JWT without verifying it. Returns null for opaque tokens. + */ +export const decodeJwt = (token: string | null | undefined): DecodedJwt | null => { + if (!token) return null; + + const parts: string[] = token.split('.'); + + if (parts.length !== 3) return null; + + try { + return {header: decodeSegment(parts[0]), payload: decodeSegment(parts[1])}; + } catch { + return null; + } +}; + +export const formatClaim = (value: unknown): string => (typeof value === 'string' ? value : JSON.stringify(value)); + +export const formatDuration = (ms: number): string => { + const total: number = Math.max(0, Math.floor(ms / 1000)); + const hours: number = Math.floor(total / 3600); + const minutes: number = Math.floor((total % 3600) / 60); + const seconds: number = total % 60; + const mmss = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + return hours > 0 ? `${hours}:${mmss}` : mmss; +}; diff --git a/playgrounds/react/src/lib/router.ts b/playgrounds/react/src/lib/router.ts new file mode 100644 index 00000000..85796fa6 --- /dev/null +++ b/playgrounds/react/src/lib/router.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useSyncExternalStore} from 'react'; + +export type Section = 'overview' | 'components' | 'hooks' | 'tokens'; + +const subscribe = (listener: () => void): (() => void) => { + window.addEventListener('hashchange', listener); + + return () => window.removeEventListener('hashchange', listener); +}; + +const getSnapshot = (): string => window.location.hash.replace(/^#\/?/, ''); + +/** + * A tiny hash router. Returns the path segments after `#/`. + */ +export const useHashPath = (): string[] => { + const hash: string = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return hash.split('/').filter(Boolean).map(decodeURIComponent); +}; + +export const href = (...segments: string[]): string => `#/${segments.map(encodeURIComponent).join('/')}`; diff --git a/playgrounds/react/src/lib/sdk.ts b/playgrounds/react/src/lib/sdk.ts new file mode 100644 index 00000000..e375c31a --- /dev/null +++ b/playgrounds/react/src/lib/sdk.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ThunderIDContextProps} from '@thunderid/react'; + +/** + * Typed views over the parts of `useThunderID()` that the SDK types as `any`. Each one lists a gap + * the playground had to paper over; see the README for the follow-ups. + */ +export type SdkUser = Record; + +export interface SdkStorageManager { + getSessionData: () => Promise | null | undefined>; +} + +export const toUser = (value: unknown): SdkUser | null => + value && typeof value === 'object' ? (value as SdkUser) : null; + +export const sdkSignIn = (sdk: ThunderIDContextProps, ...args: unknown[]): Promise => + (sdk.signIn as (...params: unknown[]) => Promise)(...args); + +export const sdkSignUp = (sdk: ThunderIDContextProps, ...args: unknown[]): Promise => + (sdk.signUp as (...params: unknown[]) => Promise)(...args); + +export const sdkRecover = (sdk: ThunderIDContextProps, payload: unknown): Promise => + (sdk.recover as (params: unknown) => Promise)(payload); + +export const sdkSignOut = (sdk: ThunderIDContextProps): Promise => (sdk.signOut as () => Promise)(); + +export const sdkStorage = async (sdk: ThunderIDContextProps): Promise => + ((await sdk.getStorageManager()) as SdkStorageManager | null) ?? null; + +export const asText = (value: unknown): string => { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + + return JSON.stringify(value); +}; diff --git a/playgrounds/react/src/lib/session.ts b/playgrounds/react/src/lib/session.ts new file mode 100644 index 00000000..5d0af8b0 --- /dev/null +++ b/playgrounds/react/src/lib/session.ts @@ -0,0 +1,99 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useThunderID} from '@thunderid/react'; +import {useEffect, useState} from 'react'; +import {describeError} from './events'; +import {decodeJwt, JwtClaims} from './jwt'; +import {SdkStorageManager} from './sdk'; + +export interface SessionSnapshot { + accessToken: string | null; + error: string | null; + expiresAt: number | null; + idClaims: JwtClaims | null; + idToken: string | null; + issuedAt: number | null; + refreshToken: boolean; + scope: string | null; +} + +const EMPTY: SessionSnapshot = { + accessToken: null, + error: null, + expiresAt: null, + idClaims: null, + idToken: null, + issuedAt: null, + refreshToken: false, + scope: null, +}; + +/** + * Reads the current session through the SDK: tokens via getAccessToken()/getIdToken(), claims via + * getDecodedIdToken(), and expiry from the stored session data. + */ +export const useSession = (refreshKey = 0): SessionSnapshot => { + const {isSignedIn, getAccessToken, getIdToken, getDecodedIdToken, getStorageManager} = useThunderID(); + const [snapshot, setSnapshot] = useState(EMPTY); + + useEffect(() => { + let cancelled = false; + + const load = async (): Promise => { + if (!isSignedIn) return EMPTY; + + try { + const [accessToken, idToken, idClaims] = await Promise.all([ + getAccessToken(), + getIdToken(), + getDecodedIdToken(), + ]); + const storage: SdkStorageManager | null = (await getStorageManager()) as SdkStorageManager | null; + const data: Record = (await storage?.getSessionData()) ?? {}; + const createdAt: number | null = typeof data['created_at'] === 'number' ? data['created_at'] : null; + const expiresIn = Number(data['expires_in'] ?? 0); + const accessExp: unknown = decodeJwt(accessToken)?.payload['exp']; + let expiresAt: number | null = null; + + if (createdAt && expiresIn) expiresAt = createdAt + expiresIn * 1000; + else if (typeof accessExp === 'number') expiresAt = accessExp * 1000; + + return { + accessToken, + error: null, + expiresAt, + idClaims: idClaims as JwtClaims, + idToken, + issuedAt: createdAt, + refreshToken: !!data['refresh_token'], + scope: typeof data['scope'] === 'string' ? data['scope'] : null, + }; + } catch (error) { + return {...EMPTY, error: describeError(error)}; + } + }; + + void load().then((next: SessionSnapshot) => { + if (!cancelled) setSnapshot(next); + }); + + return () => { + cancelled = true; + }; + }, [isSignedIn, getAccessToken, getIdToken, getDecodedIdToken, getStorageManager, refreshKey]); + + return snapshot; +}; + +export const useNow = (intervalMs = 1000): number => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer: number = window.setInterval(() => setNow(Date.now()), intervalMs); + + return () => window.clearInterval(timer); + }, [intervalMs]); + + return now; +}; diff --git a/playgrounds/react/src/lib/user.ts b/playgrounds/react/src/lib/user.ts new file mode 100644 index 00000000..70bf9529 --- /dev/null +++ b/playgrounds/react/src/lib/user.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +type UserLike = Record | null | undefined; + +const pick = (user: UserLike, keys: string[]): string | undefined => { + if (!user) return undefined; + + for (const key of keys) { + const value: unknown = user[key]; + + if (typeof value === 'string' && value.trim()) return value; + } + + return undefined; +}; + +export const getDisplayName = (user: UserLike): string => { + const given: string | undefined = pick(user, ['given_name', 'givenName', 'firstName']); + const family: string | undefined = pick(user, ['family_name', 'familyName', 'lastName']); + + if (given || family) return [given, family].filter(Boolean).join(' '); + + return pick(user, ['name', 'displayName', 'username', 'preferred_username', 'email', 'sub']) ?? 'Anonymous'; +}; + +export const getEmail = (user: UserLike): string | undefined => pick(user, ['email', 'emailaddress', 'username']); + +export const getInitials = (user: UserLike): string => { + if (!user) return '?'; + + const parts: string[] = getDisplayName(user) + .replace(/@.*/, '') + .split(/[\s._-]+/) + .filter(Boolean); + + return (parts.length > 1 ? parts[0][0] + parts[1][0] : (parts[0] ?? '?').slice(0, 2)).toUpperCase(); +}; diff --git a/playgrounds/react/src/main.tsx b/playgrounds/react/src/main.tsx new file mode 100644 index 00000000..26e6017b --- /dev/null +++ b/playgrounds/react/src/main.tsx @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDProvider} from '@thunderid/react'; +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import App from './App'; +import playgroundConfig from './config'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/playgrounds/react/src/pages/Hooks.tsx b/playgrounds/react/src/pages/Hooks.tsx new file mode 100644 index 00000000..6d5ddef0 --- /dev/null +++ b/playgrounds/react/src/pages/Hooks.tsx @@ -0,0 +1,217 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + useFlow, + useGetAgents, + useGetApplications, + useGetUsers, + useI18n, + useTheme, + useThunderID, + useTranslation, + useUser, +} from '@thunderid/react'; +import {ReactElement, ReactNode, useState} from 'react'; +import EventLog from '../components/EventLog'; +import {describeError, track} from '../lib/events'; +import {inspect} from '../lib/inspect'; +import {sdkSignIn, sdkSignOut} from '../lib/sdk'; + +interface HookCardProps { + actions?: ReactNode; + children?: ReactNode; + name: string; + signature: string; + value: unknown; +} + +const HookCard = ({actions = null, children = null, name, signature, value}: HookCardProps): ReactElement => ( +
+
+
+

{name}

+
{signature}
+
+ {actions &&
{actions}
} +
+ {children} +
{inspect(value)}
+
+); + +interface QueryLike { + data: unknown; + error: Error | null; + isLoading: boolean; + refetch: () => Promise; +} + +const QueryCard = ({name, query}: {name: string; query: QueryLike}): ReactElement => ( + void track(name, () => query.refetch()).catch(() => undefined)} + > + Fetch + + } + > +

+ Management hook. Calls the ThunderID management API with the signed-in user's token. +

+
+); + +const Hooks = (): ReactElement => { + const sdk = useThunderID(); + const user = useUser(); + const i18n = useI18n(); + const theme = useTheme(); + const flow = useFlow(); + const {t} = useTranslation(); + const users = useGetUsers({limit: 5}, {enabled: false}); + const applications = useGetApplications({limit: 5}, {enabled: false}); + const agents = useGetAgents({limit: 5}, {enabled: false}); + const [error, setError] = useState(null); + + const call = (label: string, run: () => Promise): void => { + setError(null); + track(label, run).catch((caught: unknown) => setError(describeError(caught))); + }; + + const languages: string[] = Object.keys(i18n.bundles); + + return ( +
+
+
+

Hooks

+

+ Live values from every hook @thunderid/react exposes. Functions are shown + as signatures; use the buttons to call them. +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + } + /> + call('revalidateProfile', () => user.revalidateProfile())} + > + revalidateProfile() + + } + /> + ( + + ))} + /> + + + toggleTheme() + + } + /> + + + + + +
+
+ ); +}; + +export default Hooks; diff --git a/playgrounds/react/src/pages/Overview.tsx b/playgrounds/react/src/pages/Overview.tsx new file mode 100644 index 00000000..227d904a --- /dev/null +++ b/playgrounds/react/src/pages/Overview.tsx @@ -0,0 +1,338 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useI18n, useTheme, useThunderID, useUser} from '@thunderid/react'; +import {ReactElement, useEffect, useRef, useState} from 'react'; +import { + CAPABILITIES, + Capability, + CapabilityContext, + CapabilityResult, + CapabilityStatus, + runCapability, +} from './capabilities'; +import EventLog from '../components/EventLog'; +import {Bang, Check, Cross, Dash, Play, Spinner} from '../components/Icons'; +import SessionCard from '../components/SessionCard'; +import playgroundConfig from '../config'; +import {logEvent} from '../lib/events'; +import {useServerHealth} from '../lib/health'; + +interface CapabilityState { + note: string; + status: CapabilityStatus; +} + +type Filter = 'all' | 'pass' | 'fail' | 'idle' | 'unavailable'; + +interface LastRun { + at: number; + durationMs: number; +} + +const RESULTS_KEY = 'playground:react:results'; + +const readStored = (): {lastRun: LastRun | null; results: Record} => { + try { + const raw: string | null = sessionStorage.getItem(RESULTS_KEY); + + if (raw) return JSON.parse(raw) as {lastRun: LastRun | null; results: Record}; + } catch { + // Storage can be blocked; start fresh. + } + + return {lastRun: null, results: {}}; +}; + +const writeStored = (results: Record, lastRun: LastRun | null): void => { + try { + sessionStorage.setItem(RESULTS_KEY, JSON.stringify({lastRun, results})); + } catch { + // Persisting results is a convenience only. + } +}; + +const orNotSet = (value: string | undefined): string => (value === undefined || value === '' ? '(not set)' : value); + +const matchesFilter = (status: CapabilityStatus, filter: Filter): boolean => { + if (filter === 'all') return true; + if (filter === 'idle') return status === 'idle' || status === 'skip'; + + return status === filter; +}; + +const StatusIcon = ({status}: {status: CapabilityStatus}): ReactElement => ( + + {status === 'pass' && } + {status === 'fail' && } + {status === 'running' && } + {status === 'unavailable' && } + {(status === 'idle' || status === 'skip') && } + +); + +const timeAgo = (at: number, now: number): string => { + const seconds: number = Math.max(0, Math.round((now - at) / 1000)); + + if (seconds < 60) return `${seconds} s`; + if (seconds < 3600) return `${Math.round(seconds / 60)} min`; + + return `${Math.round(seconds / 3600)} h`; +}; + +const Overview = (): ReactElement => { + const sdk = useThunderID(); + const user = useUser(); + const i18n = useI18n(); + const theme = useTheme(); + const health = useServerHealth(); + + const [stored] = useState(readStored); + const [results, setResults] = useState>(stored.results); + const [lastRun, setLastRun] = useState(stored.lastRun); + const [filter, setFilter] = useState('all'); + const [runningAll, setRunningAll] = useState(false); + const [now, setNow] = useState(() => Date.now()); + + const contextRef = useRef({i18n, sdk, theme, user}); + + useEffect(() => { + contextRef.current = {i18n, sdk, theme, user}; + }); + + useEffect(() => { + writeStored(results, lastRun); + }, [results, lastRun]); + + useEffect(() => { + const timer: number = window.setInterval(() => setNow(Date.now()), 15000); + + return () => window.clearInterval(timer); + }, []); + + const getContext = (): CapabilityContext => contextRef.current; + + const statusOf = (id: string): CapabilityStatus => results[id]?.status ?? 'idle'; + + const setOne = (id: string, state: CapabilityState): void => + setResults((previous: Record) => ({...previous, [id]: state})); + + const runOne = async (capability: Capability, interactive: boolean): Promise => { + setOne(capability.id, {note: 'running…', status: 'running'}); + + const result: CapabilityResult = await runCapability(capability, getContext, {interactive}); + + setOne(capability.id, result); + logEvent(`check:${capability.id}`, `${result.status}: ${result.note}`, result.status === 'fail' ? 'error' : 'ok'); + + return result; + }; + + const runAll = async (): Promise => { + if (runningAll) return; + + setRunningAll(true); + logEvent('checks:started', `${CAPABILITIES.length} capabilities`, 'info'); + + const started: number = Date.now(); + + setResults( + Object.fromEntries( + CAPABILITIES.map((capability: Capability) => [capability.id, {note: 'queued', status: 'running'}]), + ), + ); + + for (const capability of CAPABILITIES) { + // Sequential on purpose: several checks depend on the state earlier ones settle. + await runOne(capability, false); + } + + const finished: number = Date.now(); + + setLastRun({at: finished, durationMs: finished - started}); + setNow(finished); + setRunningAll(false); + logEvent('checks:finished', `${((finished - started) / 1000).toFixed(1)} s`, 'ok'); + }; + + const counts: Record = {fail: 0, idle: 0, pass: 0, running: 0, skip: 0, unavailable: 0}; + + CAPABILITIES.forEach((capability: Capability) => { + counts[statusOf(capability.id)] += 1; + }); + + const total: number = CAPABILITIES.length; + const filters: [Filter, string][] = [ + ['all', `All ${total}`], + ['pass', `Passed ${counts.pass}`], + ['fail', `Failed ${counts.fail}`], + ['idle', `Not run ${counts.idle + counts.skip}`], + ['unavailable', `Unavailable ${counts.unavailable}`], + ]; + + const sections: string[] = Array.from(new Set(CAPABILITIES.map((capability: Capability) => capability.section))); + const host: string = playgroundConfig.baseUrl.replace(/^https?:\/\//, ''); + + const configText: string = [ + ['baseUrl', orNotSet(sdk.baseUrl)], + ['clientId', orNotSet(sdk.clientId)], + ['applicationId', orNotSet(sdk.applicationId)], + ['afterSignIn', sdk.afterSignInUrl ?? playgroundConfig.afterSignInUrl], + ['afterSignOut', playgroundConfig.afterSignOutUrl], + ['scopes', Array.isArray(sdk.scopes) ? sdk.scopes.join(' ') : String(sdk.scopes ?? '')], + ['storage', playgroundConfig.storage ?? 'SDK default'], + ] + .map(([key, value]: string[]) => `${key.padEnd(14)}${value}`) + .join('\n'); + + return ( +
+
+
+

React SDK playground

+

+ Exercise every capability of @thunderid/react against your local + ThunderID server. +

+
+
+ + Server SDKs + + +
+
+ + {!playgroundConfig.clientId && ( +
+ No client ID configured. Copy .env.example to .env, set{' '} + VITE_THUNDERID_CLIENT_ID, and restart pnpm dev. Checks that need a server session + will fail until then. +
+ )} + +
+
+
Capabilities passing
+
+ + {counts.pass} / {total} + + {Math.round((counts.pass / total) * 100)}% +
+
+
+
Failing
+
+ {counts.fail} + {counts.fail ? 'needs attention' : 'all good'} +
+
+
+
Server
+
+ + {health.state === 'up' ? 'Healthy' : health.state === 'down' ? 'Down' : 'Checking'} + + {host} +
+
+
+
Last full run
+
+ {lastRun ? timeAgo(lastRun.at, now) : 'Never'} + {lastRun ? `ago Β· ${(lastRun.durationMs / 1000).toFixed(1)} s` : 'press Run all checks'} +
+
+
+ +
+
+
+

Capabilities

+
+ {filters.map(([id, label]: [Filter, string]) => ( + + ))} +
+
+ {sections.map((section: string) => { + const items: Capability[] = CAPABILITIES.filter( + (capability: Capability) => + capability.section === section && matchesFilter(statusOf(capability.id), filter), + ); + + if (!items.length) return null; + + return ( +
+
{section}
+ {items.map((capability: Capability) => { + const status: CapabilityStatus = statusOf(capability.id); + const note: string = results[capability.id]?.note ?? (status === 'idle' ? 'not run yet' : ''); + + return ( +
+ +
+ {capability.name} + {capability.api} +
+ + {note} + + +
+ ); + })} +
+ ); + })} + {CAPABILITIES.every((capability: Capability) => !matchesFilter(statusOf(capability.id), filter)) && ( +

No capabilities match this filter.

+ )} +
+ +
+ + +
+
+

ThunderIDProvider

+ .env +
+
{configText}
+
+ + +
+
+
+ ); +}; + +export default Overview; diff --git a/playgrounds/react/src/pages/Tokens.tsx b/playgrounds/react/src/pages/Tokens.tsx new file mode 100644 index 00000000..a9632f18 --- /dev/null +++ b/playgrounds/react/src/pages/Tokens.tsx @@ -0,0 +1,204 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {useThunderID} from '@thunderid/react'; +import {ReactElement, useState} from 'react'; +import {decodeJwt, DecodedJwt, formatClaim, formatDuration, JwtClaims} from '../lib/jwt'; +import {SessionSnapshot, useNow, useSession} from '../lib/session'; + +const CLAIM_NOTES: Record = { + acr: 'authentication context class', + amr: 'authentication methods used', + aud: 'audience', + auth_time: 'time the user authenticated', + azp: 'authorized party', + client_id: 'client the token was issued to', + email: 'email address', + exp: 'expires at', + iat: 'issued at', + iss: 'issuer', + jti: 'token id', + nbf: 'not valid before', + nonce: 'replay protection value', + scope: 'granted scopes', + sid: 'session id', + sub: 'subject (user id)', +}; + +const TIME_CLAIMS: string[] = ['exp', 'iat', 'nbf', 'auth_time']; + +const ClaimsTable = ({claims, now}: {claims: JwtClaims; now: number}): ReactElement => ( + + + {Object.entries(claims).map(([key, value]: [string, unknown]) => { + const isTime: boolean = TIME_CLAIMS.includes(key) && typeof value === 'number'; + const at: number = isTime ? (value as number) * 1000 : 0; + + return ( + + + + + ); + })} + +
{key} + {formatClaim(value)} + {isTime && ( + + {new Date(at).toLocaleString()} Β·{' '} + {at > now ? `in ${formatDuration(at - now)}` : `${formatDuration(now - at)} ago`} + + )} + {!isTime && CLAIM_NOTES[key] && {CLAIM_NOTES[key]}} +
+); + +const TokenCard = ({ + decoded, + label, + now, + source, + token, +}: { + decoded: DecodedJwt | null; + label: string; + now: number; + source: string; + token: string | null; +}): ReactElement => { + const [revealed, setRevealed] = useState(false); + const [copied, setCopied] = useState(false); + + const copy = async (): Promise => { + if (!token) return; + + try { + await navigator.clipboard.writeText(token); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }; + + return ( +
+
+
+

{label}

+
{source}
+
+
+ + +
+
+
+
+ {!token && 'No token'} + {token && (revealed ? token : `${token.slice(0, 12)}${'β€’'.repeat(24)}${token.slice(-6)}`)} +
+
+ {token && !decoded &&

Opaque token: not a JWT, nothing to decode.

} + {decoded && ( + <> +
Header
+ +
Payload
+ + + )} +
+ ); +}; + +const Tokens = (): ReactElement => { + const {isSignedIn, isInitialized} = useThunderID(); + const [refreshKey, setRefreshKey] = useState(0); + const session: SessionSnapshot = useSession(refreshKey); + const now: number = useNow(); + + return ( +
+
+
+

Tokens

+

+ Tokens held by the SDK for this session, decoded locally. Nothing on this page is verified or sent anywhere. +

+
+ +
+ + {!isSignedIn && ( +
+ {isInitialized ? 'Sign in from the Overview page to see tokens.' : 'The SDK is still initializing.'} +
+ )} + {session.error && ( +
+ {session.error} +
+ )} + + {isSignedIn && ( +
+
+
Access token expires
+
+ {session.expiresAt ? formatDuration(session.expiresAt - now) : 'Unknown'} + {session.expiresAt ? new Date(session.expiresAt).toLocaleTimeString() : ''} +
+
+
+
Refresh token
+
+ {session.refreshToken ? 'Present' : 'None'} + value never leaves SDK storage +
+
+
+
Scope
+
+ {session.scope ?? 'unknown'} +
+
+
+ )} + +
+ + +
+
+ ); +}; + +export default Tokens; diff --git a/playgrounds/react/src/pages/capabilities.ts b/playgrounds/react/src/pages/capabilities.ts new file mode 100644 index 00000000..3fc66e1b --- /dev/null +++ b/playgrounds/react/src/pages/capabilities.ts @@ -0,0 +1,489 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {EmbeddedFlowType} from '@thunderid/browser'; +import type {I18nContextValue, ThemeContextValue, ThunderIDContextProps, UserContextProps} from '@thunderid/react'; +import {COMPONENTS, STORY_COUNT} from './explorer/stories'; +import playgroundConfig from '../config'; +import {describeError, track} from '../lib/events'; +import {checkServerHealth} from '../lib/health'; +import {decodeJwt, formatDuration} from '../lib/jwt'; +import {asText, sdkRecover, sdkSignIn, sdkSignOut, sdkSignUp, sdkStorage, SdkStorageManager, toUser} from '../lib/sdk'; +import {getDisplayName} from '../lib/user'; + +export type CapabilityStatus = 'idle' | 'running' | 'pass' | 'fail' | 'skip' | 'unavailable'; + +export interface CapabilityResult { + note: string; + status: Exclude; +} + +export interface CapabilityContext { + i18n: I18nContextValue; + sdk: ThunderIDContextProps; + theme: ThemeContextValue; + user: UserContextProps; +} + +export interface RunOptions { + /** True when the check was started from its own Run button, so it may navigate away. */ + interactive: boolean; +} + +export interface Capability { + api: string; + id: string; + name: string; + run: (getContext: () => CapabilityContext, options: RunOptions) => Promise | CapabilityResult; + section: string; +} + +const pass = (note: string): CapabilityResult => ({note, status: 'pass'}); +const fail = (note: string): CapabilityResult => ({note, status: 'fail'}); +const skip = (note: string): CapabilityResult => ({note, status: 'skip'}); +const unavailable = (note: string): CapabilityResult => ({note, status: 'unavailable'}); + +const sleep = (ms: number): Promise => + new Promise((resolve: () => void) => { + setTimeout(resolve, ms); + }); + +const withTimeout = (promise: Promise, ms: number, label: string): Promise => + Promise.race([ + promise, + new Promise((_resolve: (value: T) => void, reject: (reason: Error) => void) => { + setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }), + ]); + +/** + * Polls the latest context until the predicate holds or the timeout elapses. + */ +const waitFor = async ( + getContext: () => CapabilityContext, + predicate: (context: CapabilityContext) => boolean, + ms: number, +): Promise => { + const deadline: number = Date.now() + ms; + + while (Date.now() < deadline) { + if (predicate(getContext())) return true; + await sleep(150); + } + + return predicate(getContext()); +}; + +const requireSignedIn = (context: CapabilityContext): CapabilityResult | null => + context.sdk.isSignedIn ? null : skip('sign in first'); + +const flowSummary = (response: unknown): string => { + const flow: Record = (response ?? {}) as Record; + const data: Record = (flow['data'] ?? {}) as Record; + const components: unknown[] = Array.isArray(data['components']) ? (data['components'] as unknown[]) : []; + + return `flowStatus=${asText(flow['flowStatus']) || 'unknown'}, ${components.length} components`; +}; + +export const CAPABILITIES: Capability[] = [ + { + api: 'GET /health/liveness', + id: 'server', + name: 'Server reachable', + run: async () => { + const health = await checkServerHealth(); + + return health.state === 'up' ? pass(`${playgroundConfig.baseUrl} is healthy`) : fail(health.detail); + }, + section: 'Setup', + }, + { + api: '', + id: 'config', + name: 'Provider configuration', + run: (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + + if (!sdk.clientId) return fail('VITE_THUNDERID_CLIENT_ID is empty; copy .env.example to .env'); + if (!sdk.baseUrl) return fail('VITE_THUNDERID_BASE_URL is empty'); + + return pass(`clientId ${sdk.clientId}`); + }, + section: 'Setup', + }, + { + api: 'useThunderID().isInitialized', + id: 'init', + name: 'Provider initialized', + run: async (getContext: () => CapabilityContext) => + (await waitFor(getContext, (context: CapabilityContext) => context.sdk.isInitialized, 8000)) + ? pass('client ready') + : fail('isInitialized stayed false for 8s'), + section: 'Setup', + }, + { + api: 'useThunderID().discovery.wellKnown', + id: 'discovery', + name: 'OIDC discovery', + run: async (getContext: () => CapabilityContext) => { + await waitFor(getContext, (context: CapabilityContext) => !!context.sdk.discovery.wellKnown?.issuer, 5000); + + const issuer: string = asText(getContext().sdk.discovery.wellKnown?.issuer); + + return issuer + ? pass(`issuer ${issuer}`) + : fail('wellKnown has no issuer: discovery not loaded into SDK storage yet'); + }, + section: 'Setup', + }, + { + api: 'useThunderID().meta', + id: 'meta', + name: 'Flow metadata', + run: async (getContext: () => CapabilityContext) => { + const loaded: boolean = await waitFor( + getContext, + (context: CapabilityContext) => !!context.sdk.meta || !context.sdk.isMetaLoading, + 5000, + ); + const {meta} = getContext().sdk; + + if (loaded && meta) return pass('GET /flow/meta resolved'); + + return fail('meta is null: /flow/meta failed or needs applicationId'); + }, + section: 'Setup', + }, + { + api: ' Β· signIn()', + id: 'redirect', + name: 'Sign in (redirect)', + run: async (getContext: () => CapabilityContext, {interactive}: RunOptions) => { + const {sdk} = getContext(); + + if (sdk.isSignedIn) return pass(`signed in as ${getDisplayName(toUser(sdk.user))}`); + if (!interactive) return skip('interactive: press Run to redirect to ThunderID'); + + await track('signIn', () => sdkSignIn(sdk)); + + return skip('redirecting to ThunderID'); + }, + section: 'Authentication', + }, + { + api: ' Β· signIn({applicationId, flowType})', + id: 'native', + name: 'Sign in (app-native flow)', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + + if (!sdk.applicationId) return fail('VITE_THUNDERID_APPLICATION_ID is not set'); + + const response: unknown = await track( + 'signIn:embedded', + () => + withTimeout( + sdkSignIn(sdk, {applicationId: sdk.applicationId, flowType: EmbeddedFlowType.Authentication}), + 10000, + 'flow initiation', + ), + flowSummary, + ); + + return pass(`flow started: ${flowSummary(response)}`); + }, + section: 'Authentication', + }, + { + api: ' Β· signUp({applicationId, flowType})', + id: 'signup', + name: 'Sign up (app-native flow)', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + + if (!sdk.applicationId) return fail('VITE_THUNDERID_APPLICATION_ID is not set'); + + const response: unknown = await track( + 'signUp:embedded', + () => + withTimeout( + sdkSignUp(sdk, {applicationId: sdk.applicationId, flowType: EmbeddedFlowType.Registration}), + 10000, + 'flow initiation', + ), + flowSummary, + ); + + return pass(`flow started: ${flowSummary(response)}`); + }, + section: 'Authentication', + }, + { + api: ' Β· recover({applicationId, flowType})', + id: 'recovery', + name: 'Account recovery', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + + if (!sdk.applicationId) return fail('VITE_THUNDERID_APPLICATION_ID is not set'); + + const response: unknown = await track( + 'recover', + () => + withTimeout( + sdkRecover(sdk, {applicationId: sdk.applicationId, flowType: EmbeddedFlowType.Recovery}), + 10000, + 'flow initiation', + ), + flowSummary, + ); + + return pass(`flow started: ${flowSummary(response)}`); + }, + section: 'Authentication', + }, + { + api: 'signInSilently()', + id: 'silent', + name: 'Silent sign-in', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + const result: unknown = await track( + 'signInSilently', + () => withTimeout(sdk.signInSilently(), 15000, 'signInSilently'), + (value: unknown) => (value ? 'session renewed' : 'returned false'), + ); + + return result + ? pass('session renewed without interaction') + : fail('returned false: login_required or no session'); + }, + section: 'Authentication', + }, + { + api: 'useThunderID().isSignedIn after reload', + id: 'restore', + name: 'Session restore', + run: async (getContext: () => CapabilityContext) => { + await waitFor(getContext, (context: CapabilityContext) => context.sdk.isInitialized, 8000); + + return getContext().sdk.isSignedIn + ? pass('session restored from storage') + : skip('no session: sign in, then reload the page'); + }, + section: 'Authentication', + }, + { + api: ' Β· signOut()', + id: 'signout', + name: 'Sign out', + run: async (getContext: () => CapabilityContext, {interactive}: RunOptions) => { + const {sdk} = getContext(); + + if (!sdk.isSignedIn) return skip('not signed in'); + if (!interactive) return skip('interactive: press Run to sign out'); + + await track('signOut', () => sdkSignOut(sdk)); + + return pass('signOut() resolved'); + }, + section: 'Authentication', + }, + { + api: 'getAccessToken()', + id: 'accessToken', + name: 'Access token', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + const token: string = await track('getAccessToken', () => sdk.getAccessToken()); + + if (!token) return fail('empty access token'); + + const exp: unknown = decodeJwt(token)?.payload['exp']; + + return pass( + typeof exp === 'number' + ? `JWT, expires in ${formatDuration(exp * 1000 - Date.now())}` + : `opaque token, ${token.length} chars`, + ); + }, + section: 'Session and tokens', + }, + { + api: 'getIdToken() Β· getDecodedIdToken()', + id: 'idToken', + name: 'ID token and claims', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + const idToken: string = await track('getIdToken', () => sdk.getIdToken()); + const claims: Record = (await track('getDecodedIdToken', () => + sdk.getDecodedIdToken(), + )) as Record; + + if (!idToken) return fail('empty ID token'); + + return pass(`sub ${asText(claims['sub']) || '?'}, ${Object.keys(claims).length} claims`); + }, + section: 'Session and tokens', + }, + { + api: 'getStorageManager().getSessionData()', + id: 'refresh', + name: 'Token refresh', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + const storage: SdkStorageManager | null = await sdkStorage(sdk); + const session: Record = (await storage?.getSessionData()) ?? {}; + + if (!session['refresh_token']) { + return fail('no refresh_token in session: add offline_access or enable refresh tokens for the app'); + } + + return pass('refresh_token present; ThunderIDProvider schedules auto refresh'); + }, + section: 'Session and tokens', + }, + { + api: 'http.request()', + id: 'http', + name: 'Authenticated request', + run: async (getContext: () => CapabilityContext) => { + const {sdk} = getContext(); + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + const response: {status?: number} = await track( + 'http.request', + () => sdk.http.request({method: 'GET', url: `${sdk.baseUrl}/users/me`}), + (value: {status?: number}) => `GET /users/me ${String(value?.status ?? '')}`, + ); + + return response?.status && response.status < 400 + ? pass(`GET /users/me ${response.status}`) + : fail(`GET /users/me ${String(response?.status ?? 'no response')}`); + }, + section: 'Session and tokens', + }, + { + api: 'exchangeToken()', + id: 'exchange', + name: 'Token exchange', + run: () => skip('needs a grant-specific config; call exchangeToken() from your app'), + section: 'Session and tokens', + }, + { + api: 'useThunderID().user', + id: 'user', + name: 'User info', + run: (getContext: () => CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + const user = toUser(getContext().sdk.user); + + return user ? pass(getDisplayName(user)) : fail('user is null while signed in'); + }, + section: 'User', + }, + { + api: 'useUser().revalidateProfile()', + id: 'profile', + name: 'User profile', + run: async (getContext: () => CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + await track('revalidateProfile', () => getContext().user.revalidateProfile()); + + const {profile, userSchema} = getContext().user; + + if (!profile) return fail('profile is null after revalidateProfile()'); + + return pass(`${Object.keys(profile).length} fields, schema ${userSchema ? 'loaded' : 'missing'}`); + }, + section: 'User', + }, + { + api: ' Β· updateProfile()', + id: 'updateProfile', + name: 'Update profile', + run: (getContext: () => CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(getContext()); + + if (gate) return gate; + + return skip('writes data: edit a field in Components > UserProfile'); + }, + section: 'User', + }, + { + api: 'useOrganization() Β· switchOrganization()', + id: 'orgs', + name: 'Organizations', + run: () => unavailable('@thunderid/react has no organization API'), + section: 'User', + }, + { + api: 'useI18n() Β· preferences.i18n', + id: 'i18n', + name: 'Translations', + run: (getContext: () => CapabilityContext) => { + const {i18n} = getContext(); + const key = 'elements.buttons.signin.text'; + const text: string = i18n.t(key); + + if (!text || text === key) return fail(`t('${key}') returned the key`); + + return pass(`${i18n.currentLanguage}: "${text}", ${Object.keys(i18n.bundles).length} bundles`); + }, + section: 'UI', + }, + { + api: 'useTheme() Β· ThemeProvider', + id: 'theme', + name: 'Theming and dark mode', + run: (getContext: () => CapabilityContext) => { + const {theme} = getContext(); + const count: number = Object.keys(theme.theme.cssVariables ?? {}).length; + + if (!count) return fail('theme exposes no CSS variables'); + + return pass(`${theme.colorScheme} scheme, ${count} CSS variables`); + }, + section: 'UI', + }, + { + api: 'Component explorer', + id: 'components', + name: 'Components', + run: () => pass(`${COMPONENTS.length} components Β· ${STORY_COUNT} stories`), + section: 'UI', + }, +]; + +export const runCapability = async ( + capability: Capability, + getContext: () => CapabilityContext, + options: RunOptions, +): Promise => { + try { + return await capability.run(getContext, options); + } catch (error) { + return fail(describeError(error)); + } +}; diff --git a/playgrounds/react/src/pages/explorer/Explorer.tsx b/playgrounds/react/src/pages/explorer/Explorer.tsx new file mode 100644 index 00000000..c7b1e586 --- /dev/null +++ b/playgrounds/react/src/pages/explorer/Explorer.tsx @@ -0,0 +1,199 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ChangeEvent, ReactElement, useEffect, useRef, useState} from 'react'; +import {COMPONENTS, GROUPS} from './stories'; +import type {ComponentDef, StoryDef} from './types'; +import Workbench, {PanelTab, PreviewTheme, Viewport} from './Workbench'; +import {Chevron, Desktop, Grid4, GridBg, Mobile, Moon, Search, Sun, Tablet} from '../../components/Icons'; +import {href} from '../../lib/router'; + +interface ExplorerProps { + componentId?: string; + storyId?: string; +} + +const VIEWPORTS: [Viewport, string, () => ReactElement][] = [ + ['desktop', 'Desktop', Desktop], + ['tablet', 'Tablet', Tablet], + ['mobile', 'Mobile', Mobile], +]; + +const Explorer = ({componentId = '', storyId = ''}: ExplorerProps): ReactElement => { + const component: ComponentDef = + COMPONENTS.find((candidate: ComponentDef) => candidate.id === componentId) ?? COMPONENTS[0]; + const story: StoryDef = + component.stories.find((candidate: StoryDef) => candidate.id === storyId) ?? component.stories[0]; + + const [search, setSearch] = useState(''); + const [collapsed, setCollapsed] = useState([]); + const [viewport, setViewport] = useState('desktop'); + const [previewTheme, setPreviewTheme] = useState('dark'); + const [grid, setGrid] = useState(true); + const [tab, setTab] = useState('controls'); + const searchRef = useRef(null); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + const target: HTMLElement | null = event.target as HTMLElement | null; + + if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')) { + event.preventDefault(); + searchRef.current?.focus(); + } + }; + + window.addEventListener('keydown', onKeyDown); + + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + const query: string = search.trim().toLowerCase(); + + const toggleGroup = (group: string): void => + setCollapsed((previous: string[]) => + previous.includes(group) ? previous.filter((item: string) => item !== group) : [...previous, group], + ); + + return ( +
+ + +
+
+
+ {component.group} + + {component.name} + + {story.name} +
+
+
+ {VIEWPORTS.map(([id, label, Icon]: [Viewport, string, () => ReactElement]) => ( + + ))} +
+ + +
+ + +
+
+ ); +}; + +export default Explorer; diff --git a/playgrounds/react/src/pages/explorer/StoryWidgets.tsx b/playgrounds/react/src/pages/explorer/StoryWidgets.tsx new file mode 100644 index 00000000..ff261f12 --- /dev/null +++ b/playgrounds/react/src/pages/explorer/StoryWidgets.tsx @@ -0,0 +1,83 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {OtpField, PasswordField, Select} from '@thunderid/react'; +import {ChangeEvent, ReactElement, ReactNode, useState} from 'react'; +import type {Action, Args} from './types'; + +const str = (args: Args, key: string): string => String(args[key] ?? ''); +const bool = (args: Args, key: string): boolean => args[key] === true; +const num = (args: Args, key: string): number => Number(args[key] ?? 0); + +export const PasswordFieldStory = ({action, args}: {action: Action; args: Args}): ReactElement => { + const [value, setValue] = useState(''); + + return ( +
+ { + setValue(next); + action('onChange', `"${'β€’'.repeat(next.length)}"`); + }} + /> +
+ ); +}; + +export const OtpFieldStory = ({action, args}: {action: Action; args: Args}): ReactElement => { + const [value, setValue] = useState(''); + + return ( + { + setValue(event.target.value); + action('onChange', `"${event.target.value}"`); + }} + onComplete={(code: string) => action('onComplete', `"${code}"`)} + /> + ); +}; + +export const SelectStory = ({action, args}: {action: Action; args: Args}): ReactElement => { + const [value, setValue] = useState(''); + + return ( +
+ ) => + onChange(isNumber ? Number(event.target.value) : event.target.value) + } + /> + ); + } + + return ( +
+ {control.control.map((option: string) => ( + + ))} +
+ ); +}; + +const buildCode = (component: ComponentDef, args: Args): string => { + const snippet = component.code(args); + const imports: string[] = Array.from(new Set(snippet.imports)).sort(); + const body: string = snippet.jsx + .split('\n') + .map((line: string) => ` ${line}`) + .join('\n'); + + return `import {${imports.join(', ')}} from '@thunderid/react';\n\nexport default function Example() {\n return (\n${body}\n );\n}`; +}; + +const Workbench = ({ + component, + grid, + onTabChange, + previewTheme, + story, + tab, + viewport, +}: WorkbenchProps): ReactElement => { + const [args, setArgs] = useState(() => ({...component.defaults, ...story.args})); + const [log, setLog] = useState([]); + const [findings, setFindings] = useState([]); + const [copied, setCopied] = useState(false); + const frameRef = useRef(null); + + const dark: boolean = previewTheme === 'dark'; + const theme: Theme = useMemo(() => createTheme({}, dark), [dark]); + const themeValue: ThemeContextValue = useMemo( + () => ({colorScheme: previewTheme, direction: 'ltr', theme, toggleTheme: () => undefined}), + [previewTheme, theme], + ); + + const action = (name: string, detail = ''): void => { + setLog((previous: ActionEntry[]) => + [{detail, id: (previous[0]?.id ?? 0) + 1, name, time: timestamp()}, ...previous].slice(0, 50), + ); + }; + + // Re-audit whenever the rendered story changes, including async renders from live components. + useEffect(() => { + const frame: HTMLDivElement | null = frameRef.current; + + if (!frame) return undefined; + + let pending = 0; + const audit = (): void => { + cancelAnimationFrame(pending); + pending = requestAnimationFrame(() => setFindings(auditElement(frame, CANVAS_BACKGROUND[previewTheme]))); + }; + const observer: MutationObserver = new MutationObserver(audit); + + observer.observe(frame, {attributes: true, characterData: true, childList: true, subtree: true}); + audit(); + + return () => { + observer.disconnect(); + cancelAnimationFrame(pending); + }; + }, [previewTheme, viewport]); + + const code: string = buildCode(component, args); + + const copyCode = async (): Promise => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }; + + const frameStyle: CSSProperties = { + ...(theme.cssVariables as CSSProperties), + width: VIEWPORT_WIDTH[viewport], + }; + + return ( + <> +
+
+
+ + {component.render(args, action)} + +
+
+
+ +
+
+ {TABS.map(([id, label]: [PanelTab, string]) => ( + + ))} +
+
+ {tab === 'controls' && + (component.controls.length ? ( +
+
+ Name + Description + Default + Control +
+ {component.controls.map((control: PropControl) => ( +
+ + {control.name} + + + {control.description} + {control.type} + + + {formatDefault(component.defaults[control.name])} + +
+ + setArgs((previous: Args) => ({...previous, [control.name]: value})) + } + /> +
+
+ ))} +
+ ) : ( +

This component has no editable props in the playground.

+ ))} + + {tab === 'code' && ( +
+
+ React Β· generated from the current props +
+ +
+
{code}
+
+ )} + + {tab === 'actions' && ( +
+ {log.map((entry: ActionEntry) => ( +
+ {entry.time} + {entry.name} + {entry.detail} +
+ ))} + {log.length === 0 &&

Interact with the preview to log events here.

} +
+ )} + + {tab === 'a11y' && ( +
    + {findings.map((finding: A11yFinding) => ( +
  • + + {finding.result} + + {finding.rule} + {finding.detail} +
  • + ))} +
+ )} +
+
+ + ); +}; + +export default Workbench; diff --git a/playgrounds/react/src/pages/explorer/a11y.ts b/playgrounds/react/src/pages/explorer/a11y.ts new file mode 100644 index 00000000..a5beaddd --- /dev/null +++ b/playgrounds/react/src/pages/explorer/a11y.ts @@ -0,0 +1,235 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type A11yResult = 'Pass' | 'Warn' | 'Fail'; + +export interface A11yFinding { + detail: string; + result: A11yResult; + rule: string; +} + +type Rgba = [number, number, number, number]; + +const parseColor = (value: string): Rgba | null => { + const match: RegExpExecArray | null = /rgba?\(([^)]+)\)/.exec(value); + + if (!match) return null; + + const parts: number[] = match[1] + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + + return [parts[0], parts[1], parts[2], parts[3] ?? 1]; +}; + +const luminance = ([r, g, b]: Rgba): number => { + const channel = (c: number): number => { + const v: number = c / 255; + + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const blend = (top: Rgba, bottom: Rgba): Rgba => { + const alpha: number = top[3]; + + return [ + top[0] * alpha + bottom[0] * (1 - alpha), + top[1] * alpha + bottom[1] * (1 - alpha), + top[2] * alpha + bottom[2] * (1 - alpha), + 1, + ]; +}; + +/** + * Resolves the effective background behind an element by compositing ancestor backgrounds. + */ +const backgroundOf = (element: Element, fallback: Rgba): Rgba => { + const layers: Rgba[] = []; + let node: Element | null = element; + + while (node) { + const color: Rgba | null = parseColor(getComputedStyle(node).backgroundColor); + + if (color && color[3] > 0) { + layers.push(color); + if (color[3] >= 1) break; + } + node = node.parentElement; + } + + return layers.reverse().reduce((under: Rgba, layer: Rgba) => blend(layer, under), fallback); +}; + +const contrast = (a: Rgba, b: Rgba): number => { + const [light, dark] = [luminance(a), luminance(b)].sort((x: number, y: number) => y - x); + + return (light + 0.05) / (dark + 0.05); +}; + +const accessibleName = (element: Element): string => { + const labelledBy: string | null = element.getAttribute('aria-labelledby'); + + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id: string) => element.ownerDocument.getElementById(id)?.textContent ?? '') + .join(' ') + .trim(); + } + + return ( + element.getAttribute('aria-label') ?? + element.getAttribute('title') ?? + (element.textContent?.trim() ? element.textContent : (element.querySelector('img')?.getAttribute('alt') ?? '')) + ).trim(); +}; + +const hasLabel = (input: HTMLElement): boolean => { + if (input.getAttribute('aria-label') || input.getAttribute('aria-labelledby')) return true; + if (input.closest('label')) return true; + if (input.id && input.ownerDocument.querySelector(`label[for="${CSS.escape(input.id)}"]`)) return true; + + return false; +}; + +/** + * Runs a few lightweight checks against the rendered story. Not a replacement for axe or a screen reader + * pass, but enough to catch missing names, labels, and low contrast while iterating on props. + */ +export const auditElement = (root: HTMLElement, canvasBackground: Rgba): A11yFinding[] => { + const findings: A11yFinding[] = []; + + const buttons: HTMLElement[] = Array.from(root.querySelectorAll('button, [role="button"], a[href]')); + const unnamed: HTMLElement[] = buttons.filter((button: HTMLElement) => !accessibleName(button)); + + findings.push( + buttons.length === 0 + ? {detail: 'no buttons or links', result: 'Pass', rule: 'Accessible name'} + : { + detail: unnamed.length ? `${unnamed.length} of ${buttons.length} unnamed` : `${buttons.length} named`, + result: unnamed.length ? 'Fail' : 'Pass', + rule: 'Accessible name', + }, + ); + + const inputs: HTMLElement[] = Array.from( + root.querySelectorAll('input:not([type="hidden"]), select, textarea'), + ); + const unlabeled: HTMLElement[] = inputs.filter((input: HTMLElement) => !hasLabel(input)); + + findings.push( + inputs.length === 0 + ? {detail: 'no form fields', result: 'Pass', rule: 'Label associated'} + : { + detail: unlabeled.length ? `${unlabeled.length} of ${inputs.length} unlabeled` : `${inputs.length} labeled`, + result: unlabeled.length ? 'Fail' : 'Pass', + rule: 'Label associated', + }, + ); + + const invalid: HTMLElement[] = inputs.filter((input: HTMLElement) => input.getAttribute('aria-invalid') === 'true'); + const described: HTMLElement[] = invalid.filter((input: HTMLElement) => input.getAttribute('aria-describedby')); + + if (invalid.length) { + findings.push({ + detail: `${described.length} of ${invalid.length} invalid fields use aria-describedby`, + result: described.length === invalid.length ? 'Pass' : 'Warn', + rule: 'Error described', + }); + } + + const images: HTMLImageElement[] = Array.from(root.querySelectorAll('img')); + const noAlt: HTMLImageElement[] = images.filter((image: HTMLImageElement) => !image.hasAttribute('alt')); + + if (images.length) { + findings.push({ + detail: noAlt.length ? `${noAlt.length} of ${images.length} missing alt` : `${images.length} with alt`, + result: noAlt.length ? 'Fail' : 'Pass', + rule: 'Image alternative text', + }); + } + + const roles: string[] = Array.from( + new Set(Array.from(root.querySelectorAll('[role]')).map((element: Element) => element.getAttribute('role') ?? '')), + ).filter(Boolean); + + findings.push({ + detail: roles.length ? roles.join(', ') : 'none (native semantics only)', + result: 'Pass', + rule: 'ARIA roles found', + }); + + const busy: HTMLElement[] = Array.from(root.querySelectorAll('button:disabled, [aria-disabled="true"]')); + + if ( + root.querySelector('svg[class*="spin"], [class*="spinner"]') && + !root.querySelector('[aria-busy="true"], [role="status"], [role="progressbar"]') + ) { + findings.push({detail: 'spinner without aria-busy or role="status"', result: 'Warn', rule: 'Busy state announced'}); + } else if (busy.length) { + findings.push({detail: `${busy.length} disabled control(s)`, result: 'Pass', rule: 'Disabled state'}); + } + + const positiveTab: Element[] = Array.from(root.querySelectorAll('[tabindex]')).filter( + (element: Element) => Number(element.getAttribute('tabindex')) > 0, + ); + + findings.push({ + detail: positiveTab.length ? `${positiveTab.length} element(s) with tabindex > 0` : 'natural order', + result: positiveTab.length ? 'Warn' : 'Pass', + rule: 'Focus order', + }); + + const ids: string[] = Array.from(root.querySelectorAll('[id]')).map((element: Element) => element.id); + const duplicates: string[] = Array.from( + new Set(ids.filter((id: string, index: number) => ids.indexOf(id) !== index)), + ); + + if (duplicates.length) { + findings.push({detail: duplicates.slice(0, 3).join(', '), result: 'Fail', rule: 'Unique ids'}); + } + + let lowest = Infinity; + let lowestText = ''; + const walker: TreeWalker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + for (let node: Node | null = walker.nextNode(); node; node = walker.nextNode()) { + const text: string = node.textContent?.trim() ?? ''; + const parent: HTMLElement | null = node.parentElement; + + if (text && parent && parent.offsetParent !== null) { + const style: CSSStyleDeclaration = getComputedStyle(parent); + const foreground: Rgba | null = parseColor(style.color); + + if (foreground) { + const background: Rgba = backgroundOf(parent, canvasBackground); + const ratio: number = contrast(blend(foreground, background), background); + + if (ratio < lowest) { + lowest = ratio; + lowestText = text.slice(0, 24); + } + } + } + } + + if (lowest !== Infinity) { + findings.push({ + detail: `lowest ${lowest.toFixed(1)} : 1 ("${lowestText}")`, + result: lowest >= 4.5 ? 'Pass' : lowest >= 3 ? 'Warn' : 'Fail', + rule: 'Color contrast', + }); + } + + return findings; +}; + +export const CANVAS_BACKGROUND: Record<'dark' | 'light', Rgba> = { + dark: [7, 16, 34, 1], + light: [238, 241, 246, 1], +}; diff --git a/playgrounds/react/src/pages/explorer/stories.tsx b/playgrounds/react/src/pages/explorer/stories.tsx new file mode 100644 index 00000000..669c8361 --- /dev/null +++ b/playgrounds/react/src/pages/explorer/stories.tsx @@ -0,0 +1,1030 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + Alert, + BaseSignInButton, + BaseSignOutButton, + BaseSignUpButton, + BaseUserAvatar, + BaseUserDropdown, + BaseUserProfile, + Button, + Card, + Checkbox, + Divider, + FacebookButton, + FlowTimer, + GitHubButton, + GoogleButton, + LanguageSwitcher, + LinkedInButton, + MicrosoftButton, + Recovery, + SignedIn, + SignedOut, + SignIn, + SignInButton, + SignInWithEthereumButton, + SignOutButton, + SignUp, + SignUpButton, + Spinner, + TextField, + Toggle, + Typography, + User, + UserAvatar, + UserDropdown, + UserProfile, +} from '@thunderid/react'; +import {ChangeEvent, FC, ReactNode} from 'react'; +import {Narrow, OtpFieldStory, PasswordFieldStory, SelectStory} from './StoryWidgets'; +import type {Action, Args, ArgValue, ComponentDef, PropControl} from './types'; +import {getDisplayName} from '../../lib/user'; + +const str = (args: Args, key: string): string => String(args[key] ?? ''); +const bool = (args: Args, key: string): boolean => args[key] === true; +const num = (args: Args, key: string): number => Number(args[key] ?? 0); + +const attr = (key: string, value: ArgValue): string => { + if (value === true) return key; + if (typeof value === 'number') return `${key}={${value}}`; + + return `${key}="${String(value).replace(/"/g, '"')}"`; +}; + +/** + * Builds a JSX element string from the props that differ from the component defaults. + */ +export const jsx = ( + tag: string, + props: Args, + options: {children?: string; defaults?: Args; extra?: string[]} = {}, +): string => { + const parts: string[] = Object.entries(props) + .filter( + ([key, value]: [string, ArgValue]) => + value !== false && value !== '' && value !== undefined && options.defaults?.[key] !== value, + ) + .map(([key, value]: [string, ArgValue]) => attr(key, value)) + .concat(options.extra ?? []); + const inline: string = parts.length ? ` ${parts.join(' ')}` : ''; + const multiline: boolean = inline.length > 70; + const attrs: string = multiline ? `\n ${parts.join('\n ')}\n` : inline; + + if (options.children) return `<${tag}${attrs}>${options.children}`; + + return `<${tag}${attrs}${multiline ? '' : ' '}/>`; +}; + +const omit = (args: Args, ...keys: string[]): Args => + Object.fromEntries(Object.entries(args).filter(([key]: [string, ArgValue]) => !keys.includes(key))); + +const SAMPLE_USER: Record = { + email: 'liam.chen@acme.io', + family_name: 'Chen', + given_name: 'Liam', + sub: 'usr_8f2c41', + username: 'liam.chen', +}; + +const SAMPLE_PROFILE: Record = { + country: 'New Zealand', + email: 'liam.chen@acme.io', + familyName: 'Chen', + givenName: 'Liam', + username: 'liam.chen', +}; + +const SIZE: readonly string[] = ['small', 'medium', 'large']; +const SOURCE_CONTROL: PropControl = { + control: ['base', 'live'], + description: 'base renders the presentational Base component with sample data; live uses the SDK context.', + name: 'source', + type: "'base' | 'live' (playground only)", +}; + +const SOCIAL: Record void}>> = { + FacebookButton, + GitHubButton, + GoogleButton, + LinkedInButton, + MicrosoftButton, + SignInWithEthereumButton, +}; + +export const COMPONENTS: ComponentDef[] = [ + { + code: (args: Args) => ({ + imports: ['Button'], + jsx: jsx('Button', omit(args, 'children'), { + children: str(args, 'children'), + defaults: {color: 'primary', size: 'medium', variant: 'solid'}, + extra: ['onClick={handleClick}'], + }), + }), + controls: [ + {control: 'text', description: 'Button label.', name: 'children', type: 'ReactNode'}, + { + control: ['solid', 'outline', 'text'], + description: 'Visual style of the button.', + name: 'variant', + type: "'solid' | 'outline' | 'text' | 'icon'", + }, + { + control: ['primary', 'secondary', 'tertiary'], + description: 'Color intent.', + name: 'color', + type: "'primary' | 'secondary' | 'tertiary' | string", + }, + {control: SIZE, description: 'Height and padding.', name: 'size', type: "'small' | 'medium' | 'large'"}, + {control: 'bool', description: 'Shows a spinner and blocks clicks.', name: 'loading', type: 'boolean'}, + {control: 'bool', description: 'Disables the button.', name: 'disabled', type: 'boolean'}, + {control: 'bool', description: 'Stretches to the container width.', name: 'fullWidth', type: 'boolean'}, + ], + defaults: { + children: 'Sign in', + color: 'primary', + disabled: false, + fullWidth: false, + loading: false, + size: 'medium', + variant: 'solid', + }, + group: 'Primitives', + id: 'button', + name: 'Button', + render: (args: Args, action: Action) => ( +
+ +
+ ), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {color: 'primary', disabled: false, loading: false, variant: 'solid'}, id: 'primary', name: 'Primary'}, + {args: {color: 'primary', variant: 'outline'}, id: 'outline', name: 'Outline'}, + {args: {color: 'secondary', variant: 'text'}, id: 'text', name: 'Text'}, + {args: {loading: true}, id: 'loading', name: 'Loading'}, + {args: {disabled: true}, id: 'disabled', name: 'Disabled'}, + ], + }, + { + code: (args: Args) => ({imports: ['TextField'], jsx: jsx('TextField', args, {extra: ['onChange={handleChange}']})}), + controls: [ + {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Hint shown when empty.', name: 'placeholder', type: 'string'}, + {control: 'text', description: 'Help text below the field.', name: 'helperText', type: 'string'}, + {control: 'text', description: 'Error message; marks the field invalid.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'}, + {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'}, + ], + defaults: { + disabled: false, + error: '', + helperText: '', + label: 'Email address', + placeholder: 'you@example.com', + required: false, + }, + group: 'Primitives', + id: 'textfield', + name: 'TextField', + render: (args: Args, action: Action) => ( + + ) => action('onChange', `"${event.target.value}"`)} + onFocus={() => action('onFocus')} + onBlur={() => action('onBlur')} + /> + + ), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {error: 'Enter a valid email address.'}, id: 'error', name: 'With error'}, + {args: {helperText: 'We never share your email.'}, id: 'helper', name: 'With helper text'}, + {args: {disabled: true, error: ''}, id: 'disabled', name: 'Disabled'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['PasswordField'], + jsx: jsx('PasswordField', args, {extra: ['value={password}', 'onChange={setPassword}']}), + }), + controls: [ + {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Hint shown when empty.', name: 'placeholder', type: 'string'}, + {control: 'text', description: 'Help text below the field.', name: 'helperText', type: 'string'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'}, + {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, error: '', helperText: '', label: 'Password', placeholder: 'Password', required: true}, + group: 'Primitives', + id: 'passwordfield', + name: 'PasswordField', + render: (args: Args, action: Action) => , + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {error: 'Password must be at least 8 characters.'}, id: 'error', name: 'With error'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['OtpField'], + jsx: jsx('OtpField', args, {defaults: {length: 6, type: 'text'}, extra: ['onComplete={verify}']}), + }), + controls: [ + {control: 'text', description: 'Visible label.', name: 'label', type: 'string'}, + {control: 'number', description: 'Number of digits.', name: 'length', type: 'number'}, + {control: ['text', 'number', 'password'], description: 'Input type.', name: 'type', type: 'OtpFieldType'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Disables the inputs.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, error: '', label: 'Verification code', length: 6, type: 'text'}, + group: 'Primitives', + id: 'otpfield', + name: 'OtpField', + render: (args: Args, action: Action) => , + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {length: 4, type: 'number'}, id: 'four', name: 'Four digits'}, + {args: {error: 'That code has expired.'}, id: 'error', name: 'With error'}, + ], + }, + { + code: (args: Args) => ({imports: ['Checkbox'], jsx: jsx('Checkbox', args, {extra: ['onChange={handleChange}']})}), + controls: [ + {control: 'text', description: 'Visible label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Help text.', name: 'helperText', type: 'string'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Marks as required.', name: 'required', type: 'boolean'}, + {control: 'bool', description: 'Disables the checkbox.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, error: '', helperText: '', label: 'Remember this device', required: false}, + group: 'Primitives', + id: 'checkbox', + name: 'Checkbox', + render: (args: Args, action: Action) => ( + ) => action('onChange', `checked=${event.target.checked}`)} + /> + ), + stories: [ + {id: 'playground', name: 'Playground'}, + { + args: {error: 'You must accept the terms.', label: 'I accept the terms', required: true}, + id: 'error', + name: 'Required', + }, + ], + }, + { + code: (args: Args) => ({imports: ['Toggle'], jsx: jsx('Toggle', args, {extra: ['onChange={handleChange}']})}), + controls: [ + {control: 'text', description: 'Visible label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Help text.', name: 'helperText', type: 'string'}, + {control: 'bool', description: 'Disables the toggle.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, helperText: '', label: 'Enable passkeys'}, + group: 'Primitives', + id: 'toggle', + name: 'Toggle', + render: (args: Args, action: Action) => ( + ) => action('onChange', `checked=${event.target.checked}`)} + /> + ), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {disabled: true}, id: 'disabled', name: 'Disabled'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Select'], + jsx: jsx('Select', args, {extra: ['options={departments}', 'onChange={handleChange}']}), + }), + controls: [ + {control: 'text', description: 'Visible label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Placeholder option.', name: 'placeholder', type: 'string'}, + {control: 'text', description: 'Help text.', name: 'helperText', type: 'string'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Shows a loading state.', name: 'loading', type: 'boolean'}, + {control: 'bool', description: 'Disables the select.', name: 'disabled', type: 'boolean'}, + ], + defaults: { + disabled: false, + error: '', + helperText: '', + label: 'Department', + loading: false, + placeholder: 'Choose a department', + }, + group: 'Primitives', + id: 'select', + name: 'Select', + render: (args: Args, action: Action) => , + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {loading: true}, id: 'loading', name: 'Loading'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Alert'], + jsx: `\n ${str(args, 'title')}\n ${str( + args, + 'description', + )}\n`, + }), + controls: [ + { + control: ['info', 'success', 'warning', 'error'], + description: 'Color and icon of the alert.', + name: 'variant', + type: "'info' | 'success' | 'warning' | 'error'", + }, + {control: 'text', description: 'Rendered in .', name: 'title', type: 'ReactNode'}, + {control: 'text', description: 'Rendered in .', name: 'description', type: 'ReactNode'}, + {control: 'bool', description: 'Shows the variant icon.', name: 'showIcon', type: 'boolean'}, + ], + defaults: { + description: 'We sent a sign-in link to liam.chen@acme.io.', + showIcon: true, + title: 'Check your inbox', + variant: 'info', + }, + group: 'Primitives', + id: 'alert', + name: 'Alert', + render: (args: Args) => ( + + + {str(args, 'title')} + {str(args, 'description')} + + + ), + stories: [ + {args: {variant: 'info'}, id: 'info', name: 'Info'}, + { + args: { + description: 'You can now sign in with your new password.', + title: 'Password updated', + variant: 'success', + }, + id: 'success', + name: 'Success', + }, + { + args: {description: 'You will be signed out in 2 minutes.', title: 'Session expiring', variant: 'warning'}, + id: 'warning', + name: 'Warning', + }, + { + args: {description: 'The email or password is incorrect.', title: 'Sign-in failed', variant: 'error'}, + id: 'error', + name: 'Error', + }, + ], + }, + { + code: (args: Args) => ({ + imports: ['Button', 'Card'], + jsx: `\n \n ${str( + args, + 'title', + )}\n ${str(args, 'description')}\n \n ...\n \n \n \n`, + }), + controls: [ + { + control: ['default', 'outlined', 'elevated'], + description: 'Surface style.', + name: 'variant', + type: "'default' | 'outlined' | 'elevated'", + }, + {control: 'text', description: 'Card.Title content.', name: 'title', type: 'ReactNode'}, + {control: 'text', description: 'Card.Description content.', name: 'description', type: 'ReactNode'}, + ], + defaults: { + description: 'Choose how you want to verify it is you.', + title: 'Verify your identity', + variant: 'outlined', + }, + group: 'Primitives', + id: 'card', + name: 'Card', + render: (args: Args, action: Action) => ( + + + + {str(args, 'title')} + {str(args, 'description')} + + + Use a passkey, an authenticator app, or a code sent by email. + + + + + + + ), + stories: [ + {id: 'outlined', name: 'Outlined'}, + {args: {variant: 'elevated'}, id: 'elevated', name: 'Elevated'}, + {args: {variant: 'default'}, id: 'default', name: 'Default'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Typography'], + jsx: jsx('Typography', omit(args, 'children'), { + children: str(args, 'children'), + defaults: {align: 'left', color: 'textPrimary', variant: 'body1'}, + }), + }), + controls: [ + {control: 'text', description: 'Text content.', name: 'children', type: 'ReactNode'}, + { + control: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'subtitle1', + 'subtitle2', + 'body1', + 'body2', + 'caption', + 'overline', + ], + description: 'Type scale step.', + name: 'variant', + type: 'TypographyVariant', + }, + { + control: ['textPrimary', 'textSecondary', 'primary', 'error', 'success', 'warning'], + description: 'Text color.', + name: 'color', + type: 'TypographyColor', + }, + {control: ['left', 'center', 'right'], description: 'Alignment.', name: 'align', type: 'TypographyAlign'}, + ], + defaults: {align: 'left', children: 'Welcome back, Liam', color: 'textPrimary', variant: 'h4'}, + group: 'Primitives', + id: 'typography', + name: 'Typography', + render: (args: Args) => ( + + {str(args, 'children')} + + ), + stories: [ + {id: 'heading', name: 'Heading'}, + { + args: {children: 'Signed in with a passkey.', color: 'textSecondary', variant: 'body2'}, + id: 'body', + name: 'Body', + }, + ], + }, + { + code: (args: Args) => ({ + imports: ['Divider'], + jsx: str(args, 'children') + ? jsx('Divider', omit(args, 'children'), {children: str(args, 'children'), defaults: {variant: 'solid'}}) + : jsx('Divider', omit(args, 'children'), {defaults: {variant: 'solid'}}), + }), + controls: [ + {control: 'text', description: 'Optional label in the middle.', name: 'children', type: 'ReactNode'}, + {control: ['solid', 'dashed', 'dotted'], description: 'Line style.', name: 'variant', type: 'DividerVariant'}, + ], + defaults: {children: 'or', variant: 'solid'}, + group: 'Primitives', + id: 'divider', + name: 'Divider', + render: (args: Args) => ( + + {str(args, 'children') || undefined} + + ), + stories: [ + {id: 'label', name: 'With label'}, + {args: {children: '', variant: 'dashed'}, id: 'dashed', name: 'Dashed'}, + ], + }, + { + code: (args: Args) => ({imports: ['Spinner'], jsx: jsx('Spinner', args, {defaults: {size: 'medium'}})}), + controls: [{control: SIZE, description: 'Spinner size.', name: 'size', type: 'SpinnerSize'}], + defaults: {size: 'medium'}, + group: 'Primitives', + id: 'spinner', + name: 'Spinner', + render: (args: Args) => , + stories: [ + {id: 'medium', name: 'Medium'}, + {args: {size: 'large'}, id: 'large', name: 'Large'}, + ], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: ['SignInButton'], jsx: jsx('SignInButton', {}, {children: str(args, 'children') || undefined})} + : { + imports: ['BaseSignInButton'], + jsx: jsx('BaseSignInButton', {}, {children: str(args, 'children'), extra: ['signIn={handleSignIn}']}), + }, + controls: [ + SOURCE_CONTROL, + { + control: 'text', + description: 'Button label; empty uses the translated default.', + name: 'children', + type: 'ReactNode', + }, + ], + defaults: {children: 'Sign in', source: 'base'}, + group: 'Actions', + id: 'signinbutton', + live: true, + name: 'SignInButton', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + action('onClick', 'signIn() redirected')}> + {str(args, 'children') || undefined} + + ) : ( + { + action('signIn', 'called (base, no redirect)'); + + return Promise.resolve(); + }} + onClick={() => action('onClick')} + > + {str(args, 'children')} + + ), + stories: [ + {id: 'base', name: 'Base (no redirect)'}, + {args: {children: '', source: 'live'}, id: 'live', name: 'Live (redirects)'}, + ], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: ['SignOutButton'], jsx: jsx('SignOutButton', {}, {children: str(args, 'children') || undefined})} + : { + imports: ['BaseSignOutButton'], + jsx: jsx('BaseSignOutButton', {}, {children: str(args, 'children'), extra: ['signOut={handleSignOut}']}), + }, + controls: [ + SOURCE_CONTROL, + { + control: 'text', + description: 'Button label; empty uses the translated default.', + name: 'children', + type: 'ReactNode', + }, + ], + defaults: {children: 'Sign out', source: 'base'}, + group: 'Actions', + id: 'signoutbutton', + live: true, + name: 'SignOutButton', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + action('onClick', 'signOut() resolved')}> + {str(args, 'children') || undefined} + + ) : ( + { + action('signOut', 'called (base)'); + + return Promise.resolve(); + }} + onClick={() => action('onClick')} + > + {str(args, 'children')} + + ), + stories: [ + {id: 'base', name: 'Base'}, + {args: {children: '', source: 'live'}, id: 'live', name: 'Live (signs out)'}, + ], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: ['SignUpButton'], jsx: jsx('SignUpButton', {}, {children: str(args, 'children') || undefined})} + : { + imports: ['BaseSignUpButton'], + jsx: jsx('BaseSignUpButton', {}, {children: str(args, 'children'), extra: ['signUp={handleSignUp}']}), + }, + controls: [ + SOURCE_CONTROL, + { + control: 'text', + description: 'Button label; empty uses the translated default.', + name: 'children', + type: 'ReactNode', + }, + ], + defaults: {children: 'Create account', source: 'base'}, + group: 'Actions', + id: 'signupbutton', + live: true, + name: 'SignUpButton', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + action('onClick', 'signUp() started')}> + {str(args, 'children') || undefined} + + ) : ( + { + action('signUp', 'called (base)'); + + return Promise.resolve(); + }} + onClick={() => action('onClick')} + > + {str(args, 'children')} + + ), + stories: [ + {id: 'base', name: 'Base'}, + {args: {children: '', source: 'live'}, id: 'live', name: 'Live'}, + ], + }, + { + code: (args: Args) => ({ + imports: [str(args, 'provider')], + jsx: jsx( + str(args, 'provider'), + {}, + {children: str(args, 'children') || undefined, extra: ['onClick={handleClick}']}, + ), + }), + controls: [ + { + control: Object.keys(SOCIAL), + description: 'Which adapter button to render.', + name: 'provider', + type: 'component', + }, + {control: 'text', description: 'Label; empty uses the translated default.', name: 'children', type: 'ReactNode'}, + ], + defaults: {children: '', provider: 'GoogleButton'}, + group: 'Actions', + id: 'social', + name: 'Social buttons', + render: (args: Args, action: Action) => { + const SocialButton: FC<{children?: ReactNode; onClick?: () => void}> = + SOCIAL[str(args, 'provider')] ?? GoogleButton; + + return ( + + action('onClick', str(args, 'provider'))}> + {str(args, 'children') || undefined} + + + ); + }, + stories: [ + {id: 'google', name: 'Google'}, + {args: {provider: 'GitHubButton'}, id: 'github', name: 'GitHub'}, + {args: {provider: 'MicrosoftButton'}, id: 'microsoft', name: 'Microsoft'}, + {args: {provider: 'SignInWithEthereumButton'}, id: 'ethereum', name: 'Ethereum'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['SignIn'], + jsx: jsx('SignIn', args, { + defaults: {size: 'medium', variant: 'outlined'}, + extra: ['onSuccess={handleSuccess}', 'onError={handleError}'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: ['default', 'outlined', 'elevated'], + description: 'Card variant of the flow container.', + name: 'variant', + type: 'CardVariant', + }, + ], + defaults: {size: 'medium', variant: 'outlined'}, + group: 'Auth flows', + id: 'signin', + live: true, + name: 'SignIn', + render: (args: Args, action: Action) => ( + + ) => action('onSuccess', JSON.stringify(data).slice(0, 120))} + onError={(error: Error) => action('onError', error.message)} + /> + + ), + stories: [ + {id: 'default', name: 'Default'}, + {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['SignUp'], + jsx: jsx('SignUp', args, { + defaults: {size: 'medium', variant: 'outlined'}, + extra: ['onComplete={handleComplete}', 'onError={handleError}'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: ['default', 'outlined', 'elevated'], + description: 'Card variant of the flow container.', + name: 'variant', + type: 'CardVariant', + }, + ], + defaults: {size: 'medium', variant: 'outlined'}, + group: 'Auth flows', + id: 'signup', + live: true, + name: 'SignUp', + render: (args: Args, action: Action) => ( + + action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + /> + + ), + stories: [{id: 'default', name: 'Default'}], + }, + { + code: () => ({imports: ['Recovery'], jsx: ''}), + controls: [], + defaults: {}, + group: 'Auth flows', + id: 'recovery', + live: true, + name: 'Recovery', + render: (_args: Args, action: Action) => ( + + action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + /> + + ), + stories: [{id: 'default', name: 'Default'}], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: ['UserAvatar'], jsx: jsx('UserAvatar', omit(args, 'source'), {defaults: {size: 40}})} + : { + imports: ['BaseUserAvatar'], + jsx: jsx('BaseUserAvatar', omit(args, 'source'), {defaults: {size: 40}, extra: ['user={user}']}), + }, + controls: [SOURCE_CONTROL, {control: 'number', description: 'Diameter in px.', name: 'size', type: 'number'}], + defaults: {size: 56, source: 'base'}, + group: 'User', + id: 'useravatar', + name: 'UserAvatar', + render: (args: Args) => + str(args, 'source') === 'live' ? ( + + ) : ( + + ), + stories: [ + {id: 'base', name: 'Sample user'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user'}, + ], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? { + imports: ['UserDropdown'], + jsx: jsx('UserDropdown', omit(args, 'source'), {extra: ['onManageProfile={openProfile}']}), + } + : { + imports: ['BaseUserDropdown'], + jsx: jsx('BaseUserDropdown', omit(args, 'source'), { + extra: ['user={user}', 'onSignOut={signOut}', 'onManageProfile={openProfile}'], + }), + }, + controls: [ + SOURCE_CONTROL, + {control: 'bool', description: 'Shows the name next to the avatar.', name: 'showTriggerLabel', type: 'boolean'}, + {control: 'bool', description: 'Shows the header inside the menu.', name: 'showDropdownHeader', type: 'boolean'}, + ], + defaults: {showDropdownHeader: true, showTriggerLabel: true, source: 'base'}, + group: 'User', + id: 'userdropdown', + live: true, + name: 'UserDropdown', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + action('onManageProfile')} + /> + ) : ( + action('onSignOut')} + onManageProfile={() => action('onManageProfile')} + /> + ), + stories: [ + {id: 'base', name: 'Sample user'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user'}, + ], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: ['UserProfile'], jsx: jsx('UserProfile', omit(args, 'source'), {defaults: {editable: true}})} + : { + imports: ['BaseUserProfile'], + jsx: jsx('BaseUserProfile', omit(args, 'source'), { + extra: ['profile={profile}', 'onUpdate={handleUpdate}'], + }), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Heading of the profile card.', name: 'title', type: 'string'}, + {control: 'bool', description: 'Allows editing fields.', name: 'editable', type: 'boolean'}, + {control: 'bool', description: 'Wraps the profile in a card.', name: 'cardLayout', type: 'boolean'}, + ], + defaults: {cardLayout: true, editable: true, source: 'base', title: 'Profile'}, + group: 'User', + id: 'userprofile', + live: true, + name: 'UserProfile', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + + + + ) : ( + + { + action('onUpdate', JSON.stringify(payload)); + + return Promise.resolve(); + }} + /> + + ), + stories: [ + {id: 'base', name: 'Sample profile'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'}, + ], + }, + { + code: () => ({ + imports: ['User'], + jsx: 'Not signed in

}>\n {(user) =>

Hello, {user?.username}

}\n
', + }), + controls: [], + defaults: {}, + group: 'User', + id: 'user', + live: true, + name: 'User', + render: () => ( + Not signed in}> + {(user: Record | null) => ( + Hello, {getDisplayName(user)} + )} + + ), + stories: [{id: 'default', name: 'Render prop'}], + }, + { + code: () => ({ + imports: ['SignedIn', 'SignedOut', 'SignInButton', 'SignOutButton'], + jsx: '<>\n \n \n \n \n \n \n', + }), + controls: [], + defaults: {}, + group: 'Control', + id: 'signedin', + live: true, + name: 'SignedIn / SignedOut', + render: () => ( +
+ + Rendered by <SignedIn> + + + + Rendered by <SignedOut> + + +
+ ), + stories: [{id: 'default', name: 'Session gate'}], + }, + { + code: () => ({imports: ['LanguageSwitcher'], jsx: ''}), + controls: [], + defaults: {}, + group: 'Control', + id: 'languageswitcher', + name: 'LanguageSwitcher', + render: () => , + stories: [{id: 'default', name: 'Default'}], + }, + { + code: (args: Args) => ({imports: ['FlowTimer'], jsx: jsx('FlowTimer', args)}), + controls: [ + {control: 'number', description: 'Seconds to count down from.', name: 'expiresIn', type: 'number'}, + {control: 'text', description: 'Template; {time} is replaced.', name: 'textTemplate', type: 'string'}, + ], + defaults: {expiresIn: 120, textTemplate: 'Code expires in {time}'}, + group: 'Control', + id: 'flowtimer', + name: 'FlowTimer', + render: (args: Args) => ( + + ), + stories: [ + {id: 'default', name: 'Two minutes'}, + {args: {expiresIn: 5}, id: 'short', name: 'Expiring'}, + ], + }, +]; + +export const STORY_COUNT: number = COMPONENTS.reduce( + (total: number, component: ComponentDef) => total + component.stories.length, + 0, +); + +export const GROUPS: string[] = Array.from(new Set(COMPONENTS.map((component: ComponentDef) => component.group))); diff --git a/playgrounds/react/src/pages/explorer/types.ts b/playgrounds/react/src/pages/explorer/types.ts new file mode 100644 index 00000000..2d4e4379 --- /dev/null +++ b/playgrounds/react/src/pages/explorer/types.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ReactNode} from 'react'; + +export type ArgValue = string | number | boolean; +export type Args = Record; +export type Action = (name: string, detail?: string) => void; + +export type ControlKind = 'text' | 'bool' | 'number' | readonly string[]; + +export interface PropControl { + control: ControlKind; + description: string; + name: string; + type: string; +} + +export interface StoryDef { + args?: Args; + id: string; + name: string; +} + +export interface Snippet { + imports: string[]; + jsx: string; +} + +export interface ComponentDef { + code: (args: Args) => Snippet; + controls: PropControl[]; + defaults: Args; + group: string; + id: string; + /** True when the story drives the live SDK and needs a reachable server or a session. */ + live?: boolean; + name: string; + render: (args: Args, action: Action) => ReactNode; + stories: StoryDef[]; +} diff --git a/playgrounds/react/src/styles.css b/playgrounds/react/src/styles.css new file mode 100644 index 00000000..27fdf3be --- /dev/null +++ b/playgrounds/react/src/styles.css @@ -0,0 +1,1280 @@ +/* + * Copyright 2026 The ThunderID Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +:root { + --pg-bg: #050b18; + --pg-surface: rgba(255, 255, 255, 0.02); + --pg-surface-2: rgba(255, 255, 255, 0.05); + --pg-border: rgba(255, 255, 255, 0.08); + --pg-border-2: rgba(255, 255, 255, 0.1); + --pg-text: #fff; + --pg-text-2: rgba(255, 255, 255, 0.6); + --pg-text-3: rgba(255, 255, 255, 0.45); + --pg-primary: #3688ff; + --pg-primary-2: #2060c8; + --pg-primary-soft: rgba(54, 136, 255, 0.18); + --pg-link: #6aa8ff; + --pg-accent: #8bf9fa; + --pg-code: #cfe3ff; + --pg-ok: #6ee7b7; + --pg-ok-bg: #34d399; + --pg-err: #fca5a5; + --pg-err-bg: #ef4444; + --pg-warn: #fcd34d; + --pg-warn-bg: #f59e0b; + --pg-info: #9cc4ff; + --pg-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + --pg-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + --pg-radius-card: 16px; + --pg-radius-control: 8px; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--pg-bg); + color: var(--pg-text); + font-family: var(--pg-sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--pg-link); + text-decoration: none; +} + +a:hover { + color: var(--pg-info); +} + +.pg-mono { + font-family: var(--pg-mono); +} + +@keyframes pg-spin { + to { + transform: rotate(360deg); + } +} + +.pg-spin { + animation: pg-spin 0.8s linear infinite; +} + +/* Shell */ + +.pg-app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.pg-topbar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 10px 20px; + border-bottom: 1px solid var(--pg-border); + background: rgba(255, 255, 255, 0.015); +} + +.pg-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; +} + +.pg-brand strong { + font-weight: 600; + letter-spacing: -0.2px; +} + +.pg-brand span { + color: rgba(255, 255, 255, 0.5); +} + +.pg-sdk-switch { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--pg-radius-control); + background: var(--pg-surface-2); + border: 1px solid var(--pg-border-2); +} + +.pg-sdk-switch select { + background: transparent; + border: none; + color: var(--pg-text); + font: 500 13px var(--pg-sans); +} + +.pg-sdk-switch select option { + background: #0b1424; +} + +.pg-sdk-badge { + width: 18px; + height: 18px; + border-radius: 5px; + background: rgba(97, 218, 251, 0.15); + color: #61dafb; + display: flex; + align-items: center; + justify-content: center; + font: 600 10px/1 var(--pg-mono); +} + +.pg-nav { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.04); +} + +.pg-nav a { + padding: 6px 12px; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + color: var(--pg-text-2); +} + +.pg-nav a[aria-current='page'] { + color: var(--pg-text); + background: var(--pg-primary-soft); +} + +.pg-spacer { + flex: 1; +} + +.pg-server { + display: inline-flex; + align-items: center; + gap: 7px; + font: 500 12px/1 var(--pg-mono); + color: var(--pg-text-2); +} + +.pg-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #94a3b8; +} + +.pg-dot[data-state='up'] { + background: #34d399; + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +.pg-dot[data-state='down'] { + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.pg-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + background: linear-gradient(135deg, #22d3ee, #0e7490); + flex: none; +} + +.pg-avatar[data-size='lg'] { + width: 40px; + height: 40px; + font-size: 13px; +} + +.pg-avatar[data-anon='true'] { + background: rgba(255, 255, 255, 0.08); + color: var(--pg-text-2); +} + +.pg-main { + flex: 1; + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 28px 24px 40px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.pg-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.pg-heading h1 { + font-size: 24px; + font-weight: 400; + letter-spacing: -0.4px; + margin: 0 0 6px; +} + +.pg-heading p { + font-size: 13.5px; + color: rgba(255, 255, 255, 0.55); + margin: 0; +} + +.pg-accent { + font-family: var(--pg-mono); + color: var(--pg-accent); +} + +/* Controls */ + +.pg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 9px 14px; + border-radius: var(--pg-radius-control); + background: var(--pg-surface-2); + border: 1px solid var(--pg-border-2); + font: 500 13px var(--pg-sans); + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + white-space: nowrap; +} + +.pg-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.pg-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pg-btn[data-variant='primary'] { + background: linear-gradient(90deg, var(--pg-primary) 0%, var(--pg-primary-2) 100%); + border-color: transparent; + color: #fff; +} + +.pg-btn[data-variant='danger'] { + background: transparent; + border-color: rgba(239, 68, 68, 0.45); + color: #f87171; +} + +.pg-btn[data-size='sm'] { + padding: 6px 12px; + font-size: 12px; + border-radius: 7px; +} + +.pg-link-btn { + background: none; + border: none; + font: 500 12px var(--pg-sans); + color: var(--pg-link); + cursor: pointer; + padding: 0; +} + +.pg-segmented { + display: flex; + gap: 2px; + padding: 3px; + border-radius: var(--pg-radius-control); + background: var(--pg-surface-2); +} + +.pg-segmented button { + padding: 5px 10px; + border-radius: 6px; + font: 500 12px var(--pg-sans); + color: var(--pg-text-2); + background: none; + border: none; + cursor: pointer; +} + +.pg-segmented button[aria-pressed='true'], +.pg-segmented button[aria-checked='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); +} + +.pg-input { + width: 100%; + background: var(--pg-surface-2); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 7px; + padding: 7px 10px; + font: 12.5px var(--pg-sans); + color: var(--pg-text); +} + +.pg-input::placeholder { + color: rgba(255, 255, 255, 0.32); +} + +/* Cards */ + +.pg-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.pg-stat { + padding: 16px 18px; + border-radius: 12px; + border: 1px solid var(--pg-border); + background: var(--pg-surface); +} + +.pg-stat-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; +} + +.pg-stat-value { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.pg-stat-value strong { + font-size: 26px; + font-weight: 400; + letter-spacing: -0.5px; +} + +.pg-stat-value span { + font-size: 12px; + color: var(--pg-text-3); +} + +.pg-columns { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; +} + +.pg-col-main { + flex: 999 1 560px; + min-width: 0; +} + +.pg-col-side { + flex: 1 1 340px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 20px; +} + +.pg-card { + border: 1px solid var(--pg-border); + border-radius: 12px; + background: var(--pg-surface); + min-width: 0; +} + +.pg-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--pg-border); + flex-wrap: wrap; +} + +.pg-card-head h2 { + font-size: 14px; + font-weight: 600; + margin: 0; +} + +.pg-card-meta { + font: 500 11.5px/1 var(--pg-mono); + color: var(--pg-text-3); +} + +.pg-card-body { + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.pg-section-label { + padding: 12px 18px 6px; + font: 600 10.5px/1 var(--pg-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pg-text-3); +} + +/* Capability rows */ + +.pg-cap { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.pg-cap-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pg-cap-name { + font-size: 13.5px; + color: rgba(255, 255, 255, 0.92); +} + +.pg-cap-api { + font: 400 11.5px/1.4 var(--pg-mono); + color: var(--pg-text-3); +} + +.pg-cap-note { + font-size: 12px; + text-align: right; + max-width: 280px; + color: var(--pg-text-3); + overflow-wrap: anywhere; +} + +.pg-cap-note[data-status='fail'] { + color: var(--pg-err); +} + +.pg-cap-note[data-status='unavailable'] { + color: var(--pg-warn); +} + +.pg-status-icon { + width: 22px; + height: 22px; + flex: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.55); + background: rgba(148, 163, 184, 0.14); +} + +.pg-status-icon[data-status='pass'] { + color: var(--pg-ok); + background: rgba(52, 211, 153, 0.14); +} + +.pg-status-icon[data-status='fail'] { + color: var(--pg-err); + background: rgba(239, 68, 68, 0.14); +} + +.pg-status-icon[data-status='running'] { + color: var(--pg-info); + background: rgba(54, 136, 255, 0.14); +} + +.pg-status-icon[data-status='unavailable'] { + color: var(--pg-warn); + background: rgba(245, 158, 11, 0.14); +} + +.pg-empty { + padding: 16px 18px; + font-size: 12.5px; + color: rgba(255, 255, 255, 0.5); +} + +/* Session */ + +.pg-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 500; + background: rgba(148, 163, 184, 0.14); + color: var(--pg-text-2); +} + +.pg-pill::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.pg-pill[data-state='ok'] { + background: rgba(52, 211, 153, 0.12); + color: var(--pg-ok); +} + +.pg-pill[data-state='warn'] { + background: rgba(245, 158, 11, 0.12); + color: var(--pg-warn); +} + +.pg-user { + display: flex; + align-items: center; + gap: 12px; +} + +.pg-user-name { + font-size: 14px; +} + +.pg-user-sub { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + overflow-wrap: anywhere; +} + +.pg-progress-head { + display: flex; + justify-content: space-between; + font-size: 12px; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 6px; +} + +.pg-progress { + height: 6px; + border-radius: 999px; + background: var(--pg-border); + overflow: hidden; +} + +.pg-progress > div { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--pg-primary), var(--pg-accent)); + transition: width 0.5s linear; +} + +.pg-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 14px; + font-size: 12px; + margin: 0; +} + +.pg-dl dt { + color: rgba(255, 255, 255, 0.5); +} + +.pg-dl dd { + margin: 0; + font-family: var(--pg-mono); + color: var(--pg-code); + overflow-wrap: anywhere; +} + +.pg-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pg-row > .pg-btn { + flex: 1; + padding: 8px 12px; + font-size: 12.5px; +} + +.pg-pre { + margin: 0; + padding: 14px 18px; + font: 400 12px/1.7 var(--pg-mono); + color: var(--pg-code); + white-space: pre; + overflow-x: auto; +} + +.pg-warning { + padding: 12px 16px; + border-radius: 12px; + border: 1px solid rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.08); + color: var(--pg-warn); + font-size: 13px; + line-height: 1.5; +} + +/* Events */ + +.pg-events { + list-style: none; + padding: 6px 0; + margin: 0; + max-height: 260px; + overflow: auto; +} + +.pg-events li { + display: flex; + gap: 12px; + padding: 6px 18px; + font: 400 12px/1.4 var(--pg-mono); +} + +.pg-events .pg-ev-time { + color: rgba(255, 255, 255, 0.4); + flex: none; +} + +.pg-events .pg-ev-name { + white-space: nowrap; +} + +.pg-events .pg-ev-name[data-kind='ok'] { + color: var(--pg-ok); +} + +.pg-events .pg-ev-name[data-kind='error'] { + color: var(--pg-err); +} + +.pg-events .pg-ev-name[data-kind='info'] { + color: var(--pg-info); +} + +.pg-events .pg-ev-detail { + color: rgba(255, 255, 255, 0.55); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Hooks and tokens */ + +.pg-grid-2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + gap: 20px; +} + +.pg-json { + margin: 0; + padding: 14px 18px; + font: 400 12px/1.6 var(--pg-mono); + color: var(--pg-code); + white-space: pre-wrap; + word-break: break-all; + max-height: 320px; + overflow: auto; +} + +.pg-token { + font: 400 12px/1.6 var(--pg-mono); + color: var(--pg-code); + word-break: break-all; + padding: 12px 14px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--pg-border); +} + +.pg-token[data-masked='true'] { + color: var(--pg-text-3); +} + +.pg-claims { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +.pg-claims th, +.pg-claims td { + text-align: left; + padding: 7px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + vertical-align: top; +} + +.pg-claims th { + font: 500 12px var(--pg-mono); + color: var(--pg-accent); + width: 160px; +} + +.pg-claims td { + font-family: var(--pg-mono); + color: var(--pg-code); + word-break: break-all; +} + +.pg-claims td small { + display: block; + color: var(--pg-text-3); + font-family: var(--pg-sans); +} + +.pg-hook-sig { + font: 400 12px/1.5 var(--pg-mono); + color: var(--pg-text-3); +} + +/* Explorer */ + +.pg-explorer { + flex: 1; + display: flex; + flex-wrap: wrap; + min-height: 0; +} + +.pg-sidebar { + flex: 1 1 248px; + max-width: 100%; + border-right: 1px solid var(--pg-border); + padding: 14px 12px; + display: flex; + flex-direction: column; + gap: 14px; + background: rgba(255, 255, 255, 0.01); +} + +.pg-search { + position: relative; + display: block; +} + +.pg-search svg { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.4); +} + +.pg-search input { + width: 100%; + background: var(--pg-surface-2); + border: 1px solid var(--pg-border-2); + border-radius: var(--pg-radius-control); + padding: 8px 40px 8px 32px; + font: 13px var(--pg-sans); + color: var(--pg-text); +} + +.pg-search kbd { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font: 500 10.5px/1 var(--pg-mono); + color: var(--pg-text-3); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 4px; + padding: 3px 5px; +} + +.pg-tree { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pg-tree ul { + list-style: none; + margin: 4px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.pg-tree-group { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 6px; + font: 600 10.5px/1 var(--pg-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); + background: none; + border: none; + cursor: pointer; +} + +.pg-tree-group svg { + transition: transform 0.15s; +} + +.pg-tree-group[aria-expanded='true'] svg { + transform: rotate(90deg); +} + +.pg-tree-group span { + margin-left: auto; + font: 500 10.5px/1 var(--pg-mono); + letter-spacing: 0; + color: rgba(255, 255, 255, 0.35); +} + +.pg-tree-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border-radius: 7px; + font-size: 13px; + color: rgba(255, 255, 255, 0.7); +} + +.pg-tree-item[aria-current='true'] { + background: rgba(54, 136, 255, 0.16); + color: #fff; + font-weight: 500; +} + +.pg-tree-item small { + margin-left: auto; + font: 500 10px/1 var(--pg-mono); + color: var(--pg-warn); +} + +.pg-tree .pg-stories { + margin: 2px 0 6px 22px; + border-left: 1px solid var(--pg-border-2); + gap: 0; +} + +.pg-story-link { + display: block; + padding: 5px 10px; + font-size: 12.5px; + border-radius: 0 6px 6px 0; + margin-left: -1px; + color: rgba(255, 255, 255, 0.55); + border-left: 2px solid transparent; +} + +.pg-story-link[aria-current='true'] { + color: var(--pg-info); + border-left-color: var(--pg-primary); + background: rgba(54, 136, 255, 0.06); +} + +.pg-sidebar-note { + font-size: 11.5px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.4); + margin: 0; +} + +.pg-stage { + flex: 999 1 560px; + min-width: 0; + display: flex; + flex-direction: column; +} + +.pg-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 10px 16px; + border-bottom: 1px solid var(--pg-border); +} + +.pg-crumbs { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + min-width: 0; +} + +.pg-crumbs strong { + color: #fff; + font-weight: 500; +} + +.pg-icon-btn { + width: 30px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--pg-text-2); + background: none; + border: none; + cursor: pointer; +} + +.pg-icon-btn[aria-pressed='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); +} + +.pg-canvas { + flex: 1; + min-height: 380px; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background-color: #071022; + overflow: auto; +} + +.pg-canvas[data-theme='light'] { + background-color: #eef1f6; +} + +.pg-canvas[data-grid='true'] { + background-image: radial-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px); + background-size: 16px 16px; +} + +.pg-canvas[data-theme='light'][data-grid='true'] { + background-image: radial-gradient(rgba(15, 23, 42, 0.12) 1px, transparent 1px); +} + +.pg-frame { + max-width: 100%; + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + border-radius: 12px; + transition: width 0.2s; + color: var(--thunderid-color-text-primary, inherit); +} + +.pg-frame[data-viewport='tablet'], +.pg-frame[data-viewport='mobile'] { + border: 1px dashed rgba(255, 255, 255, 0.15); +} + +.pg-canvas[data-theme='light'] .pg-frame[data-viewport='tablet'], +.pg-canvas[data-theme='light'] .pg-frame[data-viewport='mobile'] { + border-color: rgba(15, 23, 42, 0.18); +} + +.pg-frame-inner { + width: 100%; + display: flex; + justify-content: center; +} + +.pg-panel { + border-top: 1px solid var(--pg-border); + background: rgba(255, 255, 255, 0.015); + height: 320px; + display: flex; + flex-direction: column; +} + +.pg-tabs { + display: flex; + gap: 2px; + padding: 0 12px; + border-bottom: 1px solid var(--pg-border); +} + +.pg-tabs button { + display: inline-flex; + align-items: center; + padding: 10px 12px; + margin-bottom: -1px; + font: 500 12.5px var(--pg-sans); + color: rgba(255, 255, 255, 0.55); + border: none; + border-bottom: 2px solid transparent; + background: none; + cursor: pointer; +} + +.pg-tabs button[aria-selected='true'] { + color: #fff; + border-bottom-color: var(--pg-primary); +} + +.pg-count { + margin-left: 6px; + font: 500 10.5px/1 var(--pg-mono); + padding: 2px 6px; + border-radius: 999px; + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); +} + +.pg-panel-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.pg-props { + min-width: 640px; +} + +.pg-props-row { + display: grid; + grid-template-columns: 160px minmax(0, 1.4fr) 140px minmax(0, 1.2fr); + gap: 16px; + align-items: center; + padding: 10px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.pg-props-row[data-head='true'] { + font-size: 11.5px; + font-weight: 500; + color: var(--pg-text-3); +} + +.pg-prop-name { + font: 500 12.5px/1.3 var(--pg-mono); + color: var(--pg-accent); +} + +.pg-prop-desc { + font-size: 12.5px; + line-height: 1.45; + color: var(--pg-text-2); +} + +.pg-prop-desc code { + display: block; + margin-top: 3px; + font: 500 11px/1.3 var(--pg-mono); + color: rgba(255, 255, 255, 0.4); +} + +.pg-prop-default { + font: 500 12px/1 var(--pg-mono); + color: rgba(255, 255, 255, 0.55); +} + +.pg-switch { + position: relative; + width: 36px; + height: 20px; + border-radius: 999px; + border: none; + background: rgba(255, 255, 255, 0.15); + transition: background 0.15s; + cursor: pointer; +} + +.pg-switch[aria-checked='true'] { + background: var(--pg-primary); +} + +.pg-switch span { + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: left 0.15s; +} + +.pg-switch[aria-checked='true'] span { + left: 19px; +} + +.pg-options { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.pg-options button { + padding: 5px 10px; + border-radius: 6px; + font: 500 12px var(--pg-sans); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.65); + border: 1px solid var(--pg-border-2); + cursor: pointer; +} + +.pg-options button[aria-checked='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); + border-color: rgba(54, 136, 255, 0.5); +} + +.pg-code { + padding: 14px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.pg-code pre { + margin: 0; + padding: 16px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--pg-border); + font: 400 12.5px/1.65 var(--pg-mono); + color: var(--pg-code); + white-space: pre; + overflow-x: auto; +} + +.pg-actions-log > div { + display: flex; + align-items: center; + gap: 14px; + padding: 7px 20px; + font: 400 12.5px/1.4 var(--pg-mono); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.pg-actions-log span:nth-child(1) { + color: rgba(255, 255, 255, 0.4); +} + +.pg-actions-log span:nth-child(2) { + color: var(--pg-accent); +} + +.pg-actions-log span:nth-child(3) { + color: var(--pg-text-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pg-a11y { + list-style: none; + padding: 8px 0; + margin: 0; +} + +.pg-a11y li { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.pg-a11y-badge { + min-width: 46px; + text-align: center; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: rgba(52, 211, 153, 0.14); + color: var(--pg-ok); +} + +.pg-a11y-badge[data-result='Warn'] { + background: rgba(245, 158, 11, 0.14); + color: var(--pg-warn); +} + +.pg-a11y-badge[data-result='Fail'] { + background: rgba(239, 68, 68, 0.14); + color: var(--pg-err); +} + +.pg-a11y-rule { + font-size: 13px; + color: rgba(255, 255, 255, 0.85); +} + +.pg-a11y-detail { + margin-left: auto; + font: 500 12px/1.3 var(--pg-mono); + color: rgba(255, 255, 255, 0.5); + text-align: right; + overflow-wrap: anywhere; +} + +.pg-story-note { + font-size: 12.5px; + color: var(--thunderid-color-text-secondary, rgba(255, 255, 255, 0.6)); + text-align: center; + max-width: 420px; + line-height: 1.5; +} + +@media (max-width: 720px) { + .pg-main { + padding: 20px 16px 32px; + } + + .pg-topbar { + padding: 10px 16px; + } + + .pg-grid-2 { + grid-template-columns: 1fr; + } + + .pg-cap { + flex-wrap: wrap; + } + + .pg-cap-note { + text-align: left; + max-width: none; + } +} diff --git a/playgrounds/react/src/vite-env.d.ts b/playgrounds/react/src/vite-env.d.ts new file mode 100644 index 00000000..efec67e7 --- /dev/null +++ b/playgrounds/react/src/vite-env.d.ts @@ -0,0 +1,4 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/// diff --git a/playgrounds/react/tsconfig.json b/playgrounds/react/tsconfig.json new file mode 100644 index 00000000..e7ecc902 --- /dev/null +++ b/playgrounds/react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "types": ["vite/client", "node"], + "noPropertyAccessFromIndexSignature": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/playgrounds/react/vite.config.ts b/playgrounds/react/vite.config.ts new file mode 100644 index 00000000..d88f725a --- /dev/null +++ b/playgrounds/react/vite.config.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import react from '@vitejs/plugin-react'; +import {defineConfig} from 'vite'; + +export default defineConfig({ + build: { + // The playground bundles the whole SDK on purpose; it is never shipped. + chunkSizeWarningLimit: 1500, + }, + plugins: [react()], + server: { + port: 5173, + strictPort: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6496ea87..70ff5781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -943,64 +943,97 @@ importers: specifier: 3.5.43 version: 3.5.43(typescript@5.9.3) - samples/browser/quickstart: - dependencies: - '@thunderid/browser': - specifier: workspace:* - version: link:../../../packages/browser - devDependencies: - vite: - specifier: ^6.3.5 - version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) - - samples/express/quickstart: + playgrounds/express: dependencies: '@thunderid/express': specifier: workspace:* - version: link:../../../packages/express + version: link:../../packages/express cookie-parser: specifier: ^1.4.7 version: 1.4.7 express: - specifier: ^4.21.2 - version: 4.22.2(supports-color@10.2.2) + specifier: ^5.2.1 + version: 5.2.1(supports-color@10.2.2) + devDependencies: + '@thunderid/eslint-plugin': + specifier: 'catalog:' + version: 1.0.1(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)(vitest@4.1.11)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) + '@thunderid/prettier-config': + specifier: 'catalog:' + version: 1.0.1 + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 + '@types/node': + specifier: 'catalog:' + version: 24.7.2 + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) + prettier: + specifier: 'catalog:' + version: 3.6.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 - samples/integrations/better-auth/nextjs: + playgrounds/react: dependencies: - '@thunderid/better-auth': + '@thunderid/browser': specifier: workspace:* - version: link:../../../../packages/better-auth - better-auth: - specifier: ^1.7.2 - version: 1.7.2(next@15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3)) - next: - specifier: ^15.5.25 - version: 15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: link:../../packages/browser + '@thunderid/react': + specifier: workspace:* + version: link:../../packages/react react: - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3 react-dom: - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(react@19.2.3) devDependencies: + '@thunderid/eslint-plugin': + specifier: 'catalog:' + version: 1.0.1(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)(vitest@4.1.11)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) + '@thunderid/prettier-config': + specifier: 'catalog:' + version: 1.0.1 '@types/node': - specifier: ^24.7.2 + specifier: 'catalog:' version: 24.7.2 '@types/react': - specifier: ^19.2.14 + specifier: 'catalog:' version: 19.2.14 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.5.2 + version: 4.7.0(supports-color@10.2.2)(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)) + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) + prettier: + specifier: 'catalog:' + version: 3.6.2 typescript: - specifier: ^5.9.3 + specifier: 'catalog:' version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) - samples/nextjs/quickstart: + samples/integrations/better-auth/nextjs: dependencies: - '@thunderid/nextjs': + '@thunderid/better-auth': specifier: workspace:* - version: link:../../../packages/nextjs + version: link:../../../../packages/better-auth + better-auth: + specifier: ^1.7.2 + version: 1.7.2(next@15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3)) next: specifier: ^15.5.25 version: 15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1024,71 +1057,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - samples/node/quickstart: - dependencies: - '@thunderid/node': - specifier: workspace:* - version: link:../../../packages/node - boxen: - specifier: ^8.0.1 - version: 8.0.1 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - - samples/nuxt/quickstart: - dependencies: - '@thunderid/nuxt': - specifier: workspace:* - version: link:../../../packages/nuxt - nuxt: - specifier: ^4.5.2 - version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) - vue: - specifier: ^3.5.13 - version: 3.5.43(typescript@5.9.3) - - samples/react/quickstart: - dependencies: - '@thunderid/react': - specifier: workspace:* - version: link:../../../packages/react - '@thunderid/react-router': - specifier: workspace:* - version: link:../../../packages/react-router - react: - specifier: ^19.2.3 - version: 19.2.3 - react-dom: - specifier: ^19.2.3 - version: 19.2.3(react@19.2.3) - react-router: - specifier: ^7.18.2 - version: 7.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - devDependencies: - '@vitejs/plugin-react': - specifier: ^4.5.2 - version: 4.7.0(supports-color@10.2.2)(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)) - vite: - specifier: ^6.3.5 - version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) - - samples/vue/quickstart: - dependencies: - '@thunderid/vue': - specifier: workspace:* - version: link:../../../packages/vue - vue: - specifier: ^3.5.13 - version: 3.5.43(typescript@5.9.3) - devDependencies: - '@vitejs/plugin-vue': - specifier: ^5.2.3 - version: 5.2.4(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - vite: - specifier: ^6.3.5 - version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) - tests/e2e: devDependencies: '@playwright/test': @@ -1501,12 +1469,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -1519,12 +1481,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} @@ -1537,12 +1493,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} @@ -1555,12 +1505,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} @@ -1573,12 +1517,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} @@ -1591,12 +1529,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} @@ -1609,12 +1541,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} @@ -1627,12 +1553,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} @@ -1645,12 +1565,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} @@ -1663,12 +1577,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} @@ -1681,12 +1589,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} @@ -1699,12 +1601,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} @@ -1717,12 +1613,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} @@ -1735,12 +1625,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} @@ -1753,12 +1637,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} @@ -1771,12 +1649,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} @@ -1789,12 +1661,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -1807,12 +1673,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} @@ -1825,12 +1685,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -1843,12 +1697,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} @@ -1861,12 +1709,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -1879,12 +1721,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} @@ -1897,12 +1733,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} @@ -1915,12 +1745,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} @@ -1933,12 +1757,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} @@ -1951,12 +1769,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -2380,30 +2192,10 @@ packages: peerDependencies: nuxt: ^3.21.11 - '@nuxt/nitro-server@4.5.2': - resolution: {integrity: sha512-sx/vtT8D1WIRUOMuKQz/9z8nZb7jgVWc6HWwlkXKDjYZ84otPFtYCozKqhXWv6xf3JxJvge/RUAOwh9Z7P4nQQ==} - engines: {node: ^22.19.0 || ^24.11.0 || >=26.0.0} - peerDependencies: - '@babel/plugin-proposal-decorators': ^7.25.0 || ^8.0.0 - '@babel/plugin-syntax-typescript': ^7.25.0 || ^8.0.0 - '@rollup/plugin-babel': ^6.0.0 || ^7.0.0 - nuxt: ^4.5.2 - peerDependenciesMeta: - '@babel/plugin-proposal-decorators': - optional: true - '@babel/plugin-syntax-typescript': - optional: true - '@rollup/plugin-babel': - optional: true - '@nuxt/schema@3.21.11': resolution: {integrity: sha512-5Couz53Pl/SgTwtMQ9jlxVIC6wZk9aAbWyeOD7D6D1UIP5DzV/yV5lPDY/kM0nmiqPSzJf9WLTdJgB2gNm0dOA==} engines: {node: ^14.18.0 || >=16.10.0} - '@nuxt/schema@4.5.2': - resolution: {integrity: sha512-h9g1kt9O2iU9nX6HDFu9gvwtpn+8oSJX0RSTWRBnEx/Pkz+WlOHtjuS0SbkTAXw5aT1+H1GysWVwNTjJfBY/0w==} - engines: {node: ^14.18.0 || >=16.10.0} - '@nuxt/telemetry@2.8.0': resolution: {integrity: sha512-zAwXY24KYvpLTmiV+osagd2EHkfs5IF+7oDZYTQoit5r0kPlwaCNlzHp5I/wUAWT4LBw6lG8gZ6bWidAdv/erQ==} engines: {node: '>=18.12.0'} @@ -2461,26 +2253,6 @@ packages: rollup-plugin-visualizer: optional: true - '@nuxt/vite-builder@4.5.2': - resolution: {integrity: sha512-fC8KUs5F1VpSEjTIjmx5pdflciMlp3FDCubx3AXElvRPLtBAPmyB69kmqVFjRnVQ8+vRHNyI7iBHUg9Se8srLA==} - engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} - peerDependencies: - '@babel/plugin-proposal-decorators': ^7.25.0 || ^8.0.0 - '@babel/plugin-syntax-jsx': ^7.25.0 || ^8.0.0 - nuxt: 4.5.2 - rolldown: ^1.0.0 - rollup-plugin-visualizer: ^6.0.0 || ^7.0.1 - vue: ^3.3.4 - peerDependenciesMeta: - '@babel/plugin-proposal-decorators': - optional: true - '@babel/plugin-syntax-jsx': - optional: true - rolldown: - optional: true - rollup-plugin-visualizer: - optional: true - '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} @@ -3687,52 +3459,10 @@ packages: resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unhead/bundler@3.3.1': - resolution: {integrity: sha512-F9gEgqpUKqHFzOv+7Pgm3TbmXhUdLX+WjZiPAK/huLDzblzZmvAUE9vRDymWaOST7jqGT/tPKkwl2kqbgb3nPw==} + '@unhead/vue@2.1.17': + resolution: {integrity: sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg==} peerDependencies: - '@unhead/cli': ^3.3.1 - '@vitejs/devtools-kit': ^0.4.1 - esbuild: '>=0.17.0' - lightningcss: '>=1.20.0' - oxc-parser: '>=0.98.0' - rolldown: '>=1.0.0' - unhead: ^3.3.1 - vite: '>=6.4.2' - webpack: '>=5.0.0' - peerDependenciesMeta: - '@unhead/cli': - optional: true - '@vitejs/devtools-kit': - optional: true - esbuild: - optional: true - lightningcss: - optional: true - oxc-parser: - optional: true - rolldown: - optional: true - vite: - optional: true - webpack: - optional: true - - '@unhead/vue@2.1.17': - resolution: {integrity: sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg==} - peerDependencies: - vue: '>=3.5.18' - - '@unhead/vue@3.3.1': - resolution: {integrity: sha512-iS+eiE1NehbV/eF7wzR7UUuUVTv8fIphFOCekQvEMuPqfKPCB8f3wf7sgPgUngYX6mdgM6PmkGmaOQgTBxqwbw==} - peerDependencies: - vite: '>=6.4.2' vue: '>=3.5.18' - webpack: '>=5.0.0' - peerDependenciesMeta: - vite: - optional: true - webpack: - optional: true '@unrs/resolver-binding-android-arm-eabi@1.12.2': resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} @@ -3872,13 +3602,6 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.0.0 - '@vitejs/plugin-vue@5.2.4': - resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} - engines: {node: ^18.0.0 || >=20.0.0} - peerDependencies: - vite: ^5.0.0 || ^6.0.0 - vue: ^3.2.25 - '@vitejs/plugin-vue@6.0.8': resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3957,15 +3680,6 @@ packages: vue: optional: true - '@vue-macros/common@3.1.4': - resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} - engines: {node: '>=20.19.0'} - peerDependencies: - vue: ^2.7.0 || ^3.2.25 - peerDependenciesMeta: - vue: - optional: true - '@vue/babel-helper-vue-transform-on@2.0.1': resolution: {integrity: sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==} @@ -4012,9 +3726,6 @@ packages: '@vue/devtools-api@8.1.3': resolution: {integrity: sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==} - '@vue/devtools-api@8.2.1': - resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} - '@vue/devtools-core@8.2.1': resolution: {integrity: sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA==} peerDependencies: @@ -4068,10 +3779,6 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4106,9 +3813,6 @@ packages: alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} - ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4159,9 +3863,6 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-includes@3.1.9: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} @@ -4402,10 +4103,6 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - boxen@8.0.1: - resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} - engines: {node: '>=18'} - brace-expansion@1.1.21: resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} @@ -4475,16 +4172,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-api@4.0.0: - resolution: {integrity: sha512-B0hQ1OLyJuHTQSOWXvwibWqM6DCoqJdvBA6X1S/53bd4XU7LJ1yurIPlrsouol3mw1jh9pGI4ivubSpmJeIqCA==} - caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} @@ -4496,10 +4186,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -4514,10 +4200,6 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -4577,10 +4259,6 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -4615,9 +4293,6 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -4703,36 +4378,18 @@ packages: peerDependencies: postcss: 8.5.23 - cssnano-preset-default@8.0.2: - resolution: {integrity: sha512-+jQAqIKCqMmBjZs7741XkilU93ITZ/EW8gjAkMmujdCzfDkfjrDBv2VqkSu29Fzeig/0rZ3S9IAwfPLlmXEUfQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - cssnano-utils@5.0.3: resolution: {integrity: sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - cssnano-utils@6.0.1: - resolution: {integrity: sha512-zk65GIxA8tCjqVk7nTm1mE+ZKxtnxAvU5JSUaBLXbAr3ZF7IOvz3fbPOnEDvZKhnS7GOIitXTS5BgehLzNoc8Q==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - cssnano@7.1.9: resolution: {integrity: sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - cssnano@8.0.2: - resolution: {integrity: sha512-K+a76gA1v0/CsYgcsE95HGGyIuPKxpQSetwSwz4nHEM8fFXqSkzq2JzEXFL8v5+CCjxzVVVhPcTK3Oo8SaF/xA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - csso@5.0.5: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -5013,11 +4670,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -5206,10 +4858,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express@4.22.2: - resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} - engines: {node: '>= 0.10.0'} - express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -5282,10 +4930,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -5307,9 +4951,6 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - fnv1a-64@0.1.2: - resolution: {integrity: sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==} - for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5325,10 +4966,6 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -6045,9 +5682,6 @@ packages: memory-cache@0.2.0: resolution: {integrity: sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -6059,10 +5693,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -6083,11 +5713,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - mime@4.1.0: resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} engines: {node: '>=16'} @@ -6183,10 +5808,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -6297,20 +5918,6 @@ packages: '@types/node': optional: true - nuxt@4.5.2: - resolution: {integrity: sha512-tR3fcqeHlHmmkLMpIg3V7Y+1ltr302lW8djMw/iy+myfo7QSSz+BVJDuQhg5j73b9oteSyBfOKTDYTgvMtj6TA==} - engines: {node: ^22.19.0 || ^24.11.0 || >=26.0.0} - hasBin: true - peerDependencies: - '@parcel/watcher': ^2.1.0 - '@types/node': '>=18.12.0' - rolldown: ~1.2.1 - peerDependenciesMeta: - '@parcel/watcher': - optional: true - '@types/node': - optional: true - nypm@0.6.9: resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} engines: {node: '>=18'} @@ -6320,9 +5927,6 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-identity@0.2.3: - resolution: {integrity: sha512-2J8Joz2Tf7aaylhqFvIUJHNgpuGR38Hh75Voq9GzTbStBxJUaOtN0K1aOd3cV5qp+ij1pMqRbPrGCGMOyX303w==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -6465,9 +6069,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -6538,144 +6139,72 @@ packages: peerDependencies: postcss: 8.5.23 - postcss-colormin@8.0.1: - resolution: {integrity: sha512-qBY4ABQ6d8/mk5RRZHwMllrZMxeMey3azVY2dZUEk+RgiUC4ARdPR3/AITzNqqKTbvW/3y/MJKinDrzwqn8RDQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-convert-values@7.0.12: resolution: {integrity: sha512-xurKu5qqk4viR3Cp3p4xBR4KfnZm4w4ys6+UBwBmeuBSNkH7+DtLnYOYnOffgtE4yx8sH9S1VZ6RAAvROXzP2Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-convert-values@8.0.1: - resolution: {integrity: sha512-IdOSIX3BzfMvCc1TAHIha2gfy17xnb5vfML8e2BIKARnFOghksESfaSAB/3CXgyLfMozZAbTRPVQF5dbuKOidw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-discard-comments@7.0.8: resolution: {integrity: sha512-CvvS5S9WrXblFXCEJ9nVo+4z+eA7zSC7Z88V1HEJuwlQhlFnYTIjg1xJY+BCUiG2bvICap2tXii4mP22BD108Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-discard-comments@8.0.1: - resolution: {integrity: sha512-FDvzm3tXlEsQBO2XQgnta5ugsAqwBrgWH+j5QgXpegEIDYA0VPnZg2aP7LtmWtC49POskeIhXesFiU/k3NyFHA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-discard-duplicates@7.0.4: resolution: {integrity: sha512-VBNn1+EuMZkeGVVtz0gRfbNGtx9IFgAsAV+E2pHtXPrp4qfGBkhTIiAuE/wrb+Y6Pakg9NewAlfTpYIFAWODtw==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-discard-duplicates@8.0.1: - resolution: {integrity: sha512-stTDXkI8YkCUfADurQhp03oq5ynsgSx6Qrw5B1swds6oTHtAeOZ9I0SHGK8cY/VpWUsIYFDWMs3IWf9jIEfFvA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-discard-empty@7.0.3: resolution: {integrity: sha512-M2pyjQCU+/7cMHVtL6bKTHjv0lZnPLMpicgr67Dlth7AbuV9gjVTtUqaRwn6Pp6BwSDspUzhz8SaUrRykJU5Dw==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-discard-empty@8.0.1: - resolution: {integrity: sha512-Zv4fM1Yfhk71tbt6gfiptbL6jDHi+7apSnaMeaO9n1uET+1embrXQw5m93Zp5x28UyQSuv+AVkFY193jdwZ33w==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-discard-overridden@7.0.3: resolution: {integrity: sha512-aNovXo9UsZuRNLzHJtp13lHIvinDPfiXBPePpXkSjCbgp++iU2FqE+YxvjIsg6EdyPZsASFbfu+JcBFVsErXIQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-discard-overridden@8.0.1: - resolution: {integrity: sha512-ykt4fvrC7yYGzbxKyqBVjDCbsjF/11JgWK8enrdkobRyqqEtb/uDUCbKOGdvrK8X7BrShW8Lv5cCRNbdkNHGkQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-merge-longhand@7.0.7: resolution: {integrity: sha512-b3mfYUxR388u5Pt0HPcVIUtUDn/k15UfTY9M+ORW+meCR6JLNxoZffiYvXyOYQoRYQNZyX/UFkMCM/mNHxe1qA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-merge-longhand@8.0.1: - resolution: {integrity: sha512-huTfSYgQ13O81SFvAuOi7GWnO48vvybjj3xF+X3qUoPjzvvaLpJH5DcUqqXcwOEulZUcvaV4s0V9WtWs+IAQPA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-merge-rules@7.0.11: resolution: {integrity: sha512-SJUPM18g2BmPhf8BVlbwqWz4aK3pLu6u6xjfwEzra7xL6IBR10sUaiB++EzqcVfadPHrKBSMlNdP+XieykhI+Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-merge-rules@8.0.1: - resolution: {integrity: sha512-o3rk4UpnPNg469tklYwbR/NtvKc/f/wJiVDTnNQ/EFPw/LeiPOHUCvV1GIBQIZHGrBAYdPjToK6a+ojYprsrxQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-minify-font-values@7.0.3: resolution: {integrity: sha512-yilG/VOaNI74IylQvAQQxm3/wZVBkXyYUqNUAdxqwtbWUXPsbK1q8Ms0mL83v+f8YicgcyfYCRZtWACUdYajpA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-minify-font-values@8.0.1: - resolution: {integrity: sha512-L8Nzs/PRlBSPrLdY/7rAiU5ZN5800+2J/4LRbfyG8SJnPljmgMaXVmQiCklvRS+yObfVRNtvmk/Ean/eoYcSeg==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-minify-gradients@7.0.5: resolution: {integrity: sha512-YraROyQRg3BI1+Hg8E05B/JPdnTm8EDSVu4P2BxdM+CRiOyfmou809+chGIqo6fQqwjPGQ947nbGncSjmTU1WQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-minify-gradients@8.0.1: - resolution: {integrity: sha512-qf+4s/hZMqTwpWN2teqf6+1yvR/SZK5HgHqXYuACeJXV7ABe7AXtBEomgxagUzcN4bSnmqBh5vnIml0dYqykYg==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-minify-params@7.0.9: resolution: {integrity: sha512-R8itbB8BhlpoYyBm1ou0dD+vJnQ3F6adQipR4UnkCHUwlo+S9WXJaDRg1RHjC8YVAtIdrQzSWvJl40HnGDTKjA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-minify-params@8.0.1: - resolution: {integrity: sha512-L0h3H59deFfFg0wQN1NVaS/8E/LfGvaMuZKGO7siwlG995zo3OshtQyRkqKdVqcBwAORBvZ1nDZrKPLRapYkQw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-minify-selectors@7.1.2: resolution: {integrity: sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-minify-selectors@8.0.2: - resolution: {integrity: sha512-3icdxc/zght5UAizdwqZBDE2KOWHf1jMQCxET6iLACeNlRxfTPyXS0/COpGk8CQ2cECyaEKTRUd/i/k8Gxmz4g==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-nested@7.0.2: resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} engines: {node: '>=18.0'} @@ -6688,144 +6217,72 @@ packages: peerDependencies: postcss: 8.5.23 - postcss-normalize-charset@8.0.1: - resolution: {integrity: sha512-xzqr36F8UeIZOvOHsf3aul+RVJCADvSwuwpMLgizqKjisHZpBfztgW0XFLBfJvz9pJgaStaOXAtGb0zLqT6B0w==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-display-values@7.0.3: resolution: {integrity: sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-display-values@8.0.1: - resolution: {integrity: sha512-ZDWOijOK1FFMlpgiQCUO9fCNKd7HJ9L7z9HWEq4iyubnUFWzdTSwm/LcrMbNW6iZ1oAtqeLYA0WA3xHszOI08g==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-positions@7.0.4: resolution: {integrity: sha512-VEvlpeGd3Ju1Hqa/oN4jaP3+ms4laYwkEL9N9u+B6k54PZjXbW1n6wI+aVprf1BQXlCYpS5+1pl/7/vHiKgARg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-positions@8.0.1: - resolution: {integrity: sha512-uuivan2poSqbE48ST4do20dGaFUeXey9/H8rhHzoyVHB2I6BmkoVLZ/C9+BRjUlpaAFYVOoDY7epkiidzaYbvA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-repeat-style@7.0.4: resolution: {integrity: sha512-6mPKlY/8cSaDHxX502wERADarJsccwlky6yIrOapHH2ZgfoKAV94SbiTKfKEs4EEpdazuc3J72WsqeYk7hp9+Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-repeat-style@8.0.1: - resolution: {integrity: sha512-q2hq5fmKxk29K6DjKA3nZ17Q2dtjhLYFNmFweKALmooUqx6UWAHF1bBoWTu/EqlJ88josb82A/J0Atj9LJUmpQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-string@7.0.3: resolution: {integrity: sha512-HnEQPUchi1eznmDKEYrKUTqrprEq97SrpUYClgUkv7V2zRODD9DFoUsYU+m9ZOetmD5ku7fEMZB/lwy8IT6xVQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-string@8.0.1: - resolution: {integrity: sha512-+Wf+kQJhm1WgSGEAuUaswE9rdpR9QbrKRVemcVHs6rhOoOTVIdAbgaicftfYA6vLM346P8onRzkEVbFN29ktKQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-timing-functions@7.0.3: resolution: {integrity: sha512-zmEzHdvpZBZu0OKlbJSfgASQvaayyAoVuWtvyr34IJ/LyS+DaOKvvR3EvFJ9RWWtNIx+CMvO125OVophaxNYew==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-timing-functions@8.0.1: - resolution: {integrity: sha512-W8/tvwRlm3T+yjGkg0IRTF4bvHj0vILYr/LOogCrJKHz2ey2HFRwfsAA8Bk9N4BGR7z7WmmDu/KzzwhJ6FoGPQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-unicode@7.0.9: resolution: {integrity: sha512-DRAdWfeh/TjmhLJsw91vdiWCnUod9iwvM7xyS02/nF/sLsCR3A8l3pztrSUrWG8DSBqfX7yEk9FM0USaVJ2mSg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-unicode@8.0.1: - resolution: {integrity: sha512-Ad0YHNRBp4WHEOYUM/4wL/8MoL2fimEF8se/0q+Rt/owMzYpbxsypC1P8fN/oluwoRmRKdNVX7X2oycEobPWcQ==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-url@7.0.3: resolution: {integrity: sha512-CL93wmloq5qsffmFv+bw24MIRbmhHrp53qoh1LDAb/5TtjWEXI/np4xcP/Gw9oWCb2XyWnqHYLDUwiKRoJBA1Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-url@8.0.1: - resolution: {integrity: sha512-tkYcip6pCDY806xuxpJYqMW2M3/623jzGFJmz3m5Us47q8P28+gbRZxaea3Rr/CmwwLUiVlh+BTGYwQ6gvaP8A==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-normalize-whitespace@7.0.3: resolution: {integrity: sha512-FdHjjn+Ht5Z2ZRjNOmeCbNq6lq09sUYKpmlF/Aq0XjVNSLTL6fmHlA/3swN2wP2caY9GV/tjSDcIIyS7aN7W0A==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-normalize-whitespace@8.0.1: - resolution: {integrity: sha512-XzORadNfSrKWDZZpgAEHPKINKx8r9r9RIfE9c70g/HThdpbmPHhDYCodHSVESDxmKeySAYw1p4liuBCf7j6LyA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-ordered-values@7.0.4: resolution: {integrity: sha512-nubSi49hDHQk4E8KIj+IbLY8Bg+8OcSUEhgyolgM+atnOvXjV7EjaR6bac4YGZoFyPa9mWoAF3EaYbWdFkKqVg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-ordered-values@8.0.1: - resolution: {integrity: sha512-OLXq5lR1yk3KWQ1FPK6aWjFFdktHE9f9kb8cnt4LmIw7w30DnzgD9+sOVYJc5HenkWCX8i1MJhhFwmqc/GYqLg==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-reduce-initial@7.0.9: resolution: {integrity: sha512-ztTNPdIxXTxtBcG03E9u8v44M4ElXbMIRT7pf2onlquGula0Y83nKKxqM22FA/hMgkfCjN7ohevkVlaNwI8iOQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-reduce-initial@8.0.1: - resolution: {integrity: sha512-+aQsR6+61KRoIfcFNLP3v9RM7+0iYOTtPnjl1wr6JqMW1zx6S+t2ktHRefXwacFdHIDj5+ETG0KY7K3+SGQ4Nw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-reduce-transforms@7.0.3: resolution: {integrity: sha512-FXsnN9ZwcZTT8Yf8cAHA8qIGUXcX6WfLd9JoYhrdDfmvsVhhfqkkv7m4AC3rwFOfz+GzkUa87OCKF9dUcicd+g==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-reduce-transforms@8.0.1: - resolution: {integrity: sha512-x71slHVykiFi5RuKEXM0wgYpY2PngC78x6R8TnZhHF3lhqt+u/w3MGwYLX+2t5O87ssRiMfEAhQH+3J4QwVzCw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -6836,24 +6293,12 @@ packages: peerDependencies: postcss: 8.5.23 - postcss-svgo@8.0.1: - resolution: {integrity: sha512-HpnvWii7W0/FPrsejJa6ZTi0kNtTJP/Iba7CUMPX0xPV6QpnndOp+SDP74tFtgjA2cYKYNWJPOlmLXMsvi/9yA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-unique-selectors@7.0.7: resolution: {integrity: sha512-d+sCkaRnSefghOUdH8CMJZV9yUQhj2ojpe8Nw/lA+LV1UOfeleGkLTl6XdCFFSai9UJ+DJPb69FFuqthXYsY8w==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: 8.5.23 - postcss-unique-selectors@8.0.1: - resolution: {integrity: sha512-+xvKI5+/Cl8yYQwxDV39Uhuc4WV951xngFvPPjiPj2NIbIfm6vbbRTXblyw0FioLkIoGlw+7qUcY1h2YhaZYgw==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -6906,10 +6351,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} - engines: {node: '>=0.6'} - qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -7042,15 +6483,6 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown-string@0.3.1: - resolution: {integrity: sha512-dv8GOXkYqUQdI0rsXB8hmzO95pKWSuX2eg5/JSJa9czfEVIFuKNR7ZJDfat8LlmD35RAhWY+evS7/wXXqFDfSg==} - engines: {node: '>=20.19.0'} - peerDependencies: - rolldown: '*' - peerDependenciesMeta: - rolldown: - optional: true - rolldown@1.0.0-beta.45: resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7155,10 +6587,6 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -7180,10 +6608,6 @@ packages: serve-placeholder@2.0.2: resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -7413,12 +6837,6 @@ packages: peerDependencies: postcss: 8.5.23 - stylehacks@8.0.1: - resolution: {integrity: sha512-Gv095oTD0N+BdJALNFDsxZpETHZLTxbOl5RyIO7y6VAE6sR3z0MnV3Nix7N0IATNldNTrkvSASp2KR1Yt526HA==} - engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} - peerDependencies: - postcss: 8.5.23 - stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -7554,10 +6972,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -7656,24 +7070,12 @@ packages: resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} engines: {node: '>=20.18.1'} - undici@8.11.2: - resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} - engines: {node: '>=22.19.0'} - unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} unhead@2.1.17: resolution: {integrity: sha512-HLMKXOszRhAPBrr6VlqCeVeJq2kbC4kXwzGLEZvvojPLWNYTJw22xG7Bfwhsvs31+IBet3Wl8ADg9dwYdyphfQ==} - unhead@3.3.1: - resolution: {integrity: sha512-eqHlbLyuvIXw898WopQmosTml4PdYW5ZhXDom/sf7ysAqQB9uvYrw2/dNvbrmkJTufSkmNmgGCjoQcvoT2mS/A==} - peerDependencies: - vite: '>=6.4.2' - peerDependenciesMeta: - vite: - optional: true - unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -7773,9 +7175,6 @@ packages: webpack: optional: true - unrouting@0.2.2: - resolution: {integrity: sha512-EoCab68s1o9AWFq9fjKsMEsmjKrPO11SAsvopikks2eEm/KLXgZMCof4cgpVgdy0Vz71OFBJw6DoDqu1oOSFGw==} - unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -7872,10 +7271,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@11.1.1: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true @@ -7903,11 +7298,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - vite-node@6.0.0: - resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - vite-plugin-checker@0.14.5: resolution: {integrity: sha512-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA==} engines: {node: '>=20.19.0'} @@ -7995,46 +7385,6 @@ packages: yaml: optional: true - vite@7.3.5: - resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8168,9 +7518,6 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.3.9: - resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==} - vue-devtools-stub@0.1.0: resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} @@ -8203,24 +7550,6 @@ packages: vite: optional: true - vue-router@5.2.0: - resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} - peerDependencies: - '@pinia/colada': '>=0.21.2' - '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 - pinia: ^3.0.4 || ^4.0.2 - vite: ^7.3.0 || ^8.0.0 - vue: ^3.5.34 || ^4.0.0 - peerDependenciesMeta: - '@pinia/colada': - optional: true - '@vue/compiler-sfc': - optional: true - pinia: - optional: true - vite: - optional: true - vue-sfc-transformer@0.1.17: resolution: {integrity: sha512-0mpkDTWm1ybtp/Mp3vhrXP4r8yxcGF+quxGyJfrHDl2tl5naQjK3xkIGaVR5BtR5KG1LWJbdCrqn7I6f460j9A==} engines: {node: '>=18.0.0'} @@ -8302,10 +7631,6 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@5.0.0: - resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} - engines: {node: '>=18'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8557,7 +7882,7 @@ snapshots: '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.8': dependencies: @@ -8839,234 +8164,156 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-arm@0.28.1': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/android-x64@0.28.1': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.1': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.7': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -9418,44 +8665,6 @@ snapshots: - magicast - supports-color - '@nuxt/cli@3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2)': - dependencies: - '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2) - '@clack/prompts': 1.7.0 - c12: 3.3.4(magicast@0.5.4) - citty: 0.2.2 - confbox: 0.2.4 - consola: 3.4.2 - debug: 4.4.3(supports-color@10.2.2) - defu: 6.1.7 - exsolve: 1.1.1 - fuse.js: 7.4.2 - fzf: 0.5.2 - giget: 3.3.0 - jiti: 2.7.0 - listhen: 1.10.0(srvx@0.11.22) - nypm: 0.6.9 - ofetch: 1.5.1 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - scule: 1.3.0 - semver: 7.8.5 - srvx: 0.11.22 - std-env: 4.2.0 - tinyclip: 0.1.15 - tinyexec: 1.2.4 - ufo: 1.6.4 - youch: 4.1.1 - optionalDependencies: - '@nuxt/schema': 4.5.2 - transitivePeerDependencies: - - cac - - commander - - magicast - - supports-color - '@nuxt/devalue@2.0.2': {} '@nuxt/devtools-kit@3.4.1(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': @@ -9806,91 +9015,6 @@ snapshots: - webpack - xml2js - '@nuxt/nitro-server@4.5.2(dc2235b049bcdc63cc648371a1423346)': - dependencies: - '@nuxt/devalue': 2.0.2 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@unhead/vue': 3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - '@vue/shared': 3.5.41 - consola: 3.4.2 - defu: 6.1.7 - destr: 2.0.5 - devalue: 5.9.4 - errx: 0.1.2 - escape-string-regexp: 5.0.0 - exsolve: 1.1.1 - h3: 1.15.11 - impound: 1.1.6 - klona: 2.0.6 - mocked-exports: 0.1.1 - nitropack: 2.13.4(oxc-parser@0.143.0)(rolldown@1.2.3)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - nostics: 1.2.0 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) - nypm: 0.6.9 - ohash: 2.0.11 - pathe: 2.0.3 - rou3: 0.9.1 - std-env: 4.2.0 - ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) - vue: 3.5.43(typescript@5.9.3) - vue-bundle-renderer: 2.3.2 - vue-devtools-stub: 0.1.0 - optionalDependencies: - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@farmfe/core' - - '@libsql/client' - - '@netlify/blobs' - - '@oxc-project/types' - - '@planetscale/database' - - '@rspack/core' - - '@unhead/cli' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - '@vitejs/devtools-kit' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - bun-types-no-globals - - db0 - - drizzle-orm - - encoding - - esbuild - - idb-keyval - - ioredis - - lightningcss - - magic-string - - magicast - - mysql2 - - oxc-parser - - react-native-b4a - - rolldown - - rollup - - sqlite3 - - srvx - - supports-color - - typescript - - unloader - - unplugin - - uploadthing - - vite - - webpack - - xml2js - '@nuxt/schema@3.21.11': dependencies: '@vue/shared': 3.5.41 @@ -9899,15 +9023,6 @@ snapshots: pkg-types: 2.3.1 std-env: 4.2.0 - '@nuxt/schema@4.5.2': - dependencies: - '@vue/shared': 3.5.41 - defu: 6.1.7 - nostics: 1.2.0 - pathe: 2.0.3 - pkg-types: 2.3.1 - std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@3.21.11(magicast@0.5.4))': dependencies: '@nuxt/kit': 3.21.11(magicast@0.5.4) @@ -9917,15 +9032,6 @@ snapshots: rc9: 3.0.1 std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))': - dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - citty: 0.2.2 - consola: 3.4.2 - ofetch: 2.0.0-alpha.3 - rc9: 3.0.1 - std-env: 4.2.0 - '@nuxt/test-utils@3.17.2(@playwright/test@1.60.0)(@types/node@24.7.2)(@vue/test-utils@2.4.6)(jiti@2.7.0)(jsdom@27.0.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(playwright-core@1.60.0)(terser@5.48.0)(typescript@5.9.3)(vitest@4.1.11)(yaml@2.9.0)': dependencies: '@nuxt/kit': 3.21.11(magicast@0.5.4) @@ -10037,75 +9143,6 @@ snapshots: - vue-tsc - yaml - '@nuxt/vite-builder@4.5.2(5ac2fde01a19fe5d6caf00292c982247)': - dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - autoprefixer: 10.5.4(postcss@8.5.23) - consola: 3.4.2 - cssnano: 8.0.2(postcss@8.5.23) - defu: 6.1.7 - escape-string-regexp: 5.0.0 - exsolve: 1.1.1 - generic-names: 4.0.0 - get-port-please: 3.2.0 - jiti: 2.7.0 - js-tokens: 10.0.0 - knitwork: 1.3.0 - mlly: 1.8.2 - mocked-exports: 0.1.1 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) - nypm: 0.6.9 - pathe: 2.0.3 - pkg-types: 2.3.1 - postcss: 8.5.23 - rolldown-string: 0.3.1(rolldown@1.2.3) - seroval: 1.6.8 - std-env: 4.2.0 - ufo: 1.6.4 - unenv: 2.0.0-rc.24 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-node: 6.0.0(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-checker: 0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - vue: 3.5.43(typescript@5.9.3) - vue-bundle-renderer: 2.3.2 - optionalDependencies: - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - rolldown: 1.2.3 - rollup-plugin-visualizer: 7.0.1(rolldown@1.2.3)(rollup@4.62.2) - transitivePeerDependencies: - - '@biomejs/biome' - - '@farmfe/core' - - '@rspack/core' - - '@types/node' - - '@vitejs/devtools' - - bun-types-no-globals - - esbuild - - eslint - - less - - magic-string - - magicast - - meow - - optionator - - oxc-parser - - oxlint - - rollup - - sass - - sass-embedded - - stylelint - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - unloader - - vue-tsc - - webpack - - yaml - '@one-ini/wasm@0.1.1': {} '@opentelemetry/semantic-conventions@1.43.0': {} @@ -10749,24 +9786,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/body-parser@1.19.6': dependencies: @@ -10981,62 +10018,19 @@ snapshots: '@typescript-eslint/visitor-keys@8.57.2': dependencies: - '@typescript-eslint/types': 8.57.2 - eslint-visitor-keys: 5.0.1 - - '@typescript-eslint/visitor-keys@8.61.1': - dependencies: - '@typescript-eslint/types': 8.61.1 - eslint-visitor-keys: 5.0.1 - - '@unhead/bundler@3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': - dependencies: - magic-string: 1.1.0 - oxc-walker: 1.1.1(@oxc-project/types@0.143.0)(oxc-parser@0.143.0)(rolldown@1.2.3) - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - optionalDependencies: - esbuild: 0.28.1 - lightningcss: 1.33.0 - oxc-parser: 0.143.0 - rolldown: 1.2.3 - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@oxc-project/types' - - '@rspack/core' - - bun-types-no-globals - - rollup - - unloader + '@typescript-eslint/types': 8.57.2 + eslint-visitor-keys: 5.0.1 - '@unhead/vue@2.1.17(vue@3.5.43(typescript@5.9.3))': + '@typescript-eslint/visitor-keys@8.61.1': dependencies: - hookable: 6.1.1 - unhead: 2.1.17 - vue: 3.5.43(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 - '@unhead/vue@3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))': + '@unhead/vue@2.1.17(vue@3.5.43(typescript@5.9.3))': dependencies: - '@unhead/bundler': 3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) hookable: 6.1.1 - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + unhead: 2.1.17 vue: 3.5.43(typescript@5.9.3) - optionalDependencies: - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@oxc-project/types' - - '@rspack/core' - - '@unhead/cli' - - '@vitejs/devtools-kit' - - bun-types-no-globals - - esbuild - - lightningcss - - oxc-parser - - rolldown - - rollup - - unloader '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true @@ -11151,35 +10145,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@rolldown/pluginutils': 1.0.1 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vue: 3.5.43(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))': - dependencies: - vite: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) - vue: 3.5.43(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) vue: 3.5.43(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vue: 3.5.43(typescript@5.9.3) - '@vitest/browser-playwright@4.1.11(playwright@1.60.0)(vite@8.2.1(@types/node@22.15.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@vitest/browser': 4.1.11(vite@8.2.1(@types/node@22.15.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.11) @@ -11319,16 +10290,6 @@ snapshots: optionalDependencies: vue: 3.5.43(typescript@5.9.3) - '@vue-macros/common@3.1.4(vue@3.5.43(typescript@5.9.3))': - dependencies: - '@vue/compiler-sfc': 3.5.41 - ast-kit: 2.2.0 - local-pkg: 1.2.1 - magic-string-ast: 1.0.3 - unplugin-utils: 0.3.1 - optionalDependencies: - vue: 3.5.43(typescript@5.9.3) - '@vue/babel-helper-vue-transform-on@2.0.1': {} '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': @@ -11338,7 +10299,7 @@ snapshots: '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/template': 7.29.7 '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@vue/babel-helper-vue-transform-on': 2.0.1 '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@vue/shared': 3.5.41 @@ -11424,10 +10385,6 @@ snapshots: dependencies: '@vue/devtools-kit': 8.1.3 - '@vue/devtools-api@8.2.1': - dependencies: - '@vue/devtools-kit': 8.2.1 - '@vue/devtools-core@8.2.1(vue@3.5.43(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.2.1 @@ -11501,11 +10458,6 @@ snapshots: dependencies: event-target-shim: 5.0.1 - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -11538,10 +10490,6 @@ snapshots: alien-signals@3.2.1: {} - ansi-align@3.0.1: - dependencies: - string-width: 4.2.3 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -11598,8 +10546,6 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-flatten@1.1.1: {} - array-includes@3.1.9: dependencies: call-bind: 1.0.9 @@ -11824,17 +10770,6 @@ snapshots: boolbase@1.0.0: {} - boxen@8.0.1: - dependencies: - ansi-align: 3.0.1 - camelcase: 8.0.0 - chalk: 5.6.2 - cli-boxes: 3.0.0 - string-width: 7.2.0 - type-fest: 4.41.0 - widest-line: 5.0.0 - wrap-ansi: 9.0.2 - brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 @@ -11932,8 +10867,6 @@ snapshots: callsites@3.1.0: {} - camelcase@8.0.0: {} - caniuse-api@3.0.0: dependencies: browserslist: 4.28.8 @@ -11941,11 +10874,6 @@ snapshots: lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-api@4.0.0: - dependencies: - browserslist: 4.28.8 - caniuse-lite: 1.0.30001809 - caniuse-lite@1.0.30001809: {} chai@6.2.2: {} @@ -11955,8 +10883,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.6.2: {} - chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -11969,8 +10895,6 @@ snapshots: citty@0.2.2: {} - cli-boxes@3.0.0: {} - client-only@0.0.1: {} cliui@9.0.1: @@ -12020,10 +10944,6 @@ snapshots: consola@3.4.2: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -12047,8 +10967,6 @@ snapshots: cookie-signature@1.0.6: {} - cookie-signature@1.0.7: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -12156,59 +11074,16 @@ snapshots: postcss-svgo: 7.1.3(postcss@8.5.23) postcss-unique-selectors: 7.0.7(postcss@8.5.23) - cssnano-preset-default@8.0.2(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - cssnano-utils: 6.0.1(postcss@8.5.23) - postcss: 8.5.23 - postcss-calc: 10.1.1(postcss@8.5.23) - postcss-colormin: 8.0.1(postcss@8.5.23) - postcss-convert-values: 8.0.1(postcss@8.5.23) - postcss-discard-comments: 8.0.1(postcss@8.5.23) - postcss-discard-duplicates: 8.0.1(postcss@8.5.23) - postcss-discard-empty: 8.0.1(postcss@8.5.23) - postcss-discard-overridden: 8.0.1(postcss@8.5.23) - postcss-merge-longhand: 8.0.1(postcss@8.5.23) - postcss-merge-rules: 8.0.1(postcss@8.5.23) - postcss-minify-font-values: 8.0.1(postcss@8.5.23) - postcss-minify-gradients: 8.0.1(postcss@8.5.23) - postcss-minify-params: 8.0.1(postcss@8.5.23) - postcss-minify-selectors: 8.0.2(postcss@8.5.23) - postcss-normalize-charset: 8.0.1(postcss@8.5.23) - postcss-normalize-display-values: 8.0.1(postcss@8.5.23) - postcss-normalize-positions: 8.0.1(postcss@8.5.23) - postcss-normalize-repeat-style: 8.0.1(postcss@8.5.23) - postcss-normalize-string: 8.0.1(postcss@8.5.23) - postcss-normalize-timing-functions: 8.0.1(postcss@8.5.23) - postcss-normalize-unicode: 8.0.1(postcss@8.5.23) - postcss-normalize-url: 8.0.1(postcss@8.5.23) - postcss-normalize-whitespace: 8.0.1(postcss@8.5.23) - postcss-ordered-values: 8.0.1(postcss@8.5.23) - postcss-reduce-initial: 8.0.1(postcss@8.5.23) - postcss-reduce-transforms: 8.0.1(postcss@8.5.23) - postcss-svgo: 8.0.1(postcss@8.5.23) - postcss-unique-selectors: 8.0.1(postcss@8.5.23) - cssnano-utils@5.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 - cssnano-utils@6.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - cssnano@7.1.9(postcss@8.5.23): dependencies: cssnano-preset-default: 7.0.17(postcss@8.5.23) lilconfig: 3.1.3 postcss: 8.5.23 - cssnano@8.0.2(postcss@8.5.23): - dependencies: - cssnano-preset-default: 8.0.2(postcss@8.5.23) - lilconfig: 3.1.3 - postcss: 8.5.23 - csso@5.0.5: dependencies: css-tree: 2.2.1 @@ -12537,35 +11412,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -12836,42 +11682,6 @@ snapshots: expect-type@1.3.0: {} - express@4.22.2(supports-color@10.2.2): - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.6(supports-color@10.2.2) - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9(supports-color@10.2.2) - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2(supports-color@10.2.2) - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.8 - qs: 6.15.3 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2(supports-color@10.2.2) - serve-static: 1.16.3(supports-color@10.2.2) - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 @@ -12954,10 +11764,6 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -12972,18 +11778,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2(supports-color@10.2.2): - dependencies: - debug: 2.6.9(supports-color@10.2.2) - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - finalhandler@2.1.1(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -13015,8 +11809,6 @@ snapshots: flatted@3.4.2: {} - fnv1a-64@0.1.2: {} - for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -13030,8 +11822,6 @@ snapshots: fraction.js@5.3.4: {} - fresh@0.5.2: {} - fresh@2.0.0: {} fsevents@2.3.2: @@ -13743,16 +12533,12 @@ snapshots: memory-cache@0.2.0: {} - merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -13770,8 +12556,6 @@ snapshots: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} - mime@4.1.0: {} mimic-fn@4.0.0: {} @@ -13849,8 +12633,6 @@ snapshots: natural-compare@1.4.0: {} - negotiator@0.6.3: {} - negotiator@1.0.0: {} next@15.5.25(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -14095,141 +12877,7 @@ snapshots: unplugin-vue-router: 0.19.2(@vue/compiler-sfc@3.5.43)(vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3)) untyped: 2.0.0 vue: 3.5.43(typescript@5.9.3) - vue-router: 4.6.4(vue@3.5.43(typescript@5.9.3)) - optionalDependencies: - '@parcel/watcher': 2.5.6 - '@types/node': 24.7.2 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@biomejs/biome' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@farmfe/core' - - '@libsql/client' - - '@netlify/blobs' - - '@oxc-project/types' - - '@planetscale/database' - - '@rspack/core' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - '@vitejs/devtools' - - '@vue/compiler-sfc' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - bufferutil - - bun-types-no-globals - - cac - - commander - - db0 - - drizzle-orm - - encoding - - esbuild - - eslint - - idb-keyval - - ioredis - - less - - lightningcss - - magicast - - meow - - mysql2 - - optionator - - oxlint - - react-native-b4a - - rolldown - - rollup - - rollup-plugin-visualizer - - sass - - sass-embedded - - sqlite3 - - srvx - - stylelint - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - unloader - - uploadthing - - utf-8-validate - - vite - - vue-tsc - - webpack - - xml2js - - yaml - - nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0): - dependencies: - '@dxup/nuxt': 0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2) - '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@nuxt/nitro-server': 4.5.2(dc2235b049bcdc63cc648371a1423346) - '@nuxt/schema': 4.5.2 - '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))) - '@nuxt/vite-builder': 4.5.2(5ac2fde01a19fe5d6caf00292c982247) - '@unhead/vue': 3.3.1(@oxc-project/types@0.143.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - '@vue/shared': 3.5.41 - chokidar: 5.0.0 - compatx: 0.2.0 - consola: 3.4.2 - cookie-es: 3.1.1 - defu: 6.1.7 - devalue: 5.9.4 - errx: 0.1.2 - escape-string-regexp: 5.0.0 - exsolve: 1.1.1 - fnv1a-64: 0.1.2 - hookable: 6.1.1 - ignore: 7.0.6 - impound: 1.1.6 - jiti: 2.7.0 - klona: 2.0.6 - knitwork: 1.3.0 - magic-string: 1.1.0 - mlly: 1.8.2 - nanotar: 0.3.0 - nostics: 1.2.0 - nypm: 0.6.9 - object-identity: 0.2.3 - ofetch: 1.5.1 - ohash: 2.0.11 - on-change: 6.0.2 - oxc-walker: 1.1.1(@oxc-project/types@0.143.0)(oxc-parser@0.143.0)(rolldown@1.2.3) - pathe: 2.0.3 - perfect-debounce: 2.1.0 - picomatch: 4.0.5 - pkg-types: 2.3.1 - rolldown: 1.2.3 - rolldown-string: 0.3.1(rolldown@1.2.3) - rou3: 0.9.1 - scule: 1.3.0 - std-env: 4.2.0 - tinyglobby: 0.2.17 - ufo: 1.6.4 - ultrahtml: 1.7.0 - uncrypto: 0.1.3 - unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - undici: 8.11.2 - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unrouting: 0.2.2 - untyped: 2.0.0 - verkit: 0.3.2 - vue: 3.5.43(typescript@5.9.3) - vue-component-type-helpers: 3.3.9 - vue-router: 5.2.0(@vue/compiler-sfc@3.5.43)(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) + vue-router: 4.6.4(vue@3.5.43(typescript@5.9.3)) optionalDependencies: '@parcel/watcher': 2.5.6 '@types/node': 24.7.2 @@ -14240,9 +12888,6 @@ snapshots: - '@azure/identity' - '@azure/keyvault-secrets' - '@azure/storage-blob' - - '@babel/plugin-proposal-decorators' - - '@babel/plugin-syntax-jsx' - - '@babel/plugin-syntax-typescript' - '@biomejs/biome' - '@capacitor/preferences' - '@deno/kv' @@ -14251,17 +12896,13 @@ snapshots: - '@libsql/client' - '@netlify/blobs' - '@oxc-project/types' - - '@pinia/colada' - '@planetscale/database' - - '@rollup/plugin-babel' - '@rspack/core' - - '@unhead/cli' - '@upstash/redis' - '@vercel/blob' - '@vercel/functions' - '@vercel/kv' - '@vitejs/devtools' - - '@vitejs/devtools-kit' - '@vue/compiler-sfc' - aws4fetch - bare-abort-controller @@ -14284,10 +12925,9 @@ snapshots: - meow - mysql2 - optionator - - oxc-parser - oxlint - - pinia - react-native-b4a + - rolldown - rollup - rollup-plugin-visualizer - sass @@ -14318,8 +12958,6 @@ snapshots: object-assign@4.1.1: {} - object-identity@0.2.3: {} - object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -14525,8 +13163,6 @@ snapshots: lru-cache: 11.5.1 minipass: 7.1.3 - path-to-regexp@0.1.13: {} - path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -14585,72 +13221,35 @@ snapshots: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-colormin@8.0.1(postcss@8.5.23): - dependencies: - '@colordx/core': 5.4.3 - browserslist: 4.28.8 - caniuse-api: 4.0.0 - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-convert-values@7.0.12(postcss@8.5.23): dependencies: browserslist: 4.28.8 postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-convert-values@8.0.1(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-discard-comments@7.0.8(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-selector-parser: 7.1.4 - postcss-discard-comments@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-selector-parser: 7.1.4 - postcss-discard-duplicates@7.0.4(postcss@8.5.23): dependencies: postcss: 8.5.23 - postcss-discard-duplicates@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-discard-empty@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 - postcss-discard-empty@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-discard-overridden@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 - postcss-discard-overridden@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-merge-longhand@7.0.7(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 stylehacks: 7.0.11(postcss@8.5.23) - postcss-merge-longhand@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - stylehacks: 8.0.1(postcss@8.5.23) - postcss-merge-rules@7.0.11(postcss@8.5.23): dependencies: browserslist: 4.28.8 @@ -14659,24 +13258,11 @@ snapshots: postcss: 8.5.23 postcss-selector-parser: 7.1.4 - postcss-merge-rules@8.0.1(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - caniuse-api: 4.0.0 - cssnano-utils: 6.0.1(postcss@8.5.23) - postcss: 8.5.23 - postcss-selector-parser: 7.1.4 - postcss-minify-font-values@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-minify-font-values@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-minify-gradients@7.0.5(postcss@8.5.23): dependencies: '@colordx/core': 5.4.3 @@ -14684,13 +13270,6 @@ snapshots: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-minify-gradients@8.0.1(postcss@8.5.23): - dependencies: - '@colordx/core': 5.4.3 - cssnano-utils: 6.0.1(postcss@8.5.23) - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-minify-params@7.0.9(postcss@8.5.23): dependencies: browserslist: 4.28.8 @@ -14698,13 +13277,6 @@ snapshots: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-minify-params@8.0.1(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - cssnano-utils: 6.0.1(postcss@8.5.23) - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-minify-selectors@7.1.2(postcss@8.5.23): dependencies: browserslist: 4.28.8 @@ -14713,14 +13285,6 @@ snapshots: postcss: 8.5.23 postcss-selector-parser: 7.1.4 - postcss-minify-selectors@8.0.2(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - caniuse-api: 4.0.0 - cssesc: 3.0.0 - postcss: 8.5.23 - postcss-selector-parser: 7.1.4 - postcss-nested@7.0.2(postcss@8.5.23): dependencies: postcss: 8.5.23 @@ -14730,126 +13294,64 @@ snapshots: dependencies: postcss: 8.5.23 - postcss-normalize-charset@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-normalize-display-values@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-display-values@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-positions@7.0.4(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-positions@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@7.0.4(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-string@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-string@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-unicode@7.0.9(postcss@8.5.23): dependencies: browserslist: 4.28.8 postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@8.0.1(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-url@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-url@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-ordered-values@7.0.4(postcss@8.5.23): dependencies: cssnano-utils: 5.0.3(postcss@8.5.23) postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-ordered-values@8.0.1(postcss@8.5.23): - dependencies: - cssnano-utils: 6.0.1(postcss@8.5.23) - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-reduce-initial@7.0.9(postcss@8.5.23): dependencies: browserslist: 4.28.8 caniuse-api: 3.0.0 postcss: 8.5.23 - postcss-reduce-initial@8.0.1(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - caniuse-api: 4.0.0 - postcss: 8.5.23 - postcss-reduce-transforms@7.0.3(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - postcss-reduce-transforms@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -14861,22 +13363,11 @@ snapshots: postcss-value-parser: 4.2.0 svgo: 4.1.0 - postcss-svgo@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-value-parser: 4.2.0 - svgo: 4.1.0 - postcss-unique-selectors@7.0.7(postcss@8.5.23): dependencies: postcss: 8.5.23 postcss-selector-parser: 7.1.4 - postcss-unique-selectors@8.0.1(postcss@8.5.23): - dependencies: - postcss: 8.5.23 - postcss-selector-parser: 7.1.4 - postcss-value-parser@4.2.0: {} postcss@8.5.23: @@ -14924,11 +13415,6 @@ snapshots: punycode@2.3.1: {} - qs@6.15.3: - dependencies: - es-define-property: 1.0.1 - side-channel: 1.1.1 - qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -15070,12 +13556,6 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown-string@0.3.1(rolldown@1.2.3): - dependencies: - magic-string: 1.1.0 - optionalDependencies: - rolldown: 1.2.3 - rolldown@1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.95.0 @@ -15238,24 +13718,6 @@ snapshots: semver@7.8.5: {} - send@0.19.2(supports-color@10.2.2): - dependencies: - debug: 2.6.9(supports-color@10.2.2) - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - send@1.2.1(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -15284,15 +13746,6 @@ snapshots: dependencies: defu: 6.1.5 - serve-static@1.16.3(supports-color@10.2.2): - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - serve-static@2.2.1(supports-color@10.2.2): dependencies: encodeurl: 2.0.0 @@ -15587,12 +14040,6 @@ snapshots: postcss: 8.5.23 postcss-selector-parser: 7.1.4 - stylehacks@8.0.1(postcss@8.5.23): - dependencies: - browserslist: 4.28.8 - postcss: 8.5.23 - postcss-selector-parser: 7.1.4 - stylis@4.2.0: {} supports-color@10.2.2: {} @@ -15724,8 +14171,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@4.41.0: {} - type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -15865,8 +14310,6 @@ snapshots: undici@7.30.0: {} - undici@8.11.2: {} - unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -15875,22 +14318,6 @@ snapshots: dependencies: hookable: 6.1.1 - unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): - dependencies: - hookable: 6.1.1 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - optionalDependencies: - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - esbuild - - rolldown - - rollup - - unloader - - webpack - unicorn-magic@0.3.0: {} unicorn-magic@0.4.0: {} @@ -16019,11 +14446,6 @@ snapshots: rollup: 4.62.2 vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - unrouting@0.2.2: - dependencies: - escape-string-regexp: 5.0.0 - ufo: 1.6.4 - unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -16106,8 +14528,6 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - uuid@11.1.1: {} vary@1.1.2: {} @@ -16130,7 +14550,7 @@ snapshots: es-module-lexer: 2.1.0 obug: 2.1.3 pathe: 2.0.3 - vite: 7.3.5(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -16144,27 +14564,6 @@ snapshots: - tsx - yaml - vite-node@6.0.0(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): - dependencies: - cac: 7.0.0 - es-module-lexer: 2.1.0 - obug: 2.1.3 - pathe: 2.0.3 - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@types/node' - - '@vitejs/devtools' - - esbuild - - jiti - - less - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - vite-plugin-checker@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.6(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 @@ -16180,21 +14579,6 @@ snapshots: optionator: 0.9.4 typescript: 5.9.3 - vite-plugin-checker@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@5.9.3)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): - dependencies: - '@babel/code-frame': 7.29.7 - chokidar: 5.0.0 - npm-run-path: 6.0.0 - picocolors: 1.1.1 - picomatch: 4.0.5 - proper-lockfile: 4.1.2 - tiny-invariant: 1.3.3 - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - optionalDependencies: - eslint: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) - optionator: 0.9.4 - typescript: 5.9.3 - vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@0.30.21)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))))(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: ansis: 4.3.1 @@ -16238,22 +14622,6 @@ snapshots: vite@6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.23 - rollup: 4.62.2 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.7.2 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.33.0 - terser: 5.48.0 - yaml: 2.9.0 - - vite@7.3.5(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0): - dependencies: - esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 postcss: 8.5.23 @@ -16405,8 +14773,6 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.3.9: {} - vue-devtools-stub@0.1.0: {} vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): @@ -16450,40 +14816,6 @@ snapshots: '@vue/compiler-sfc': 3.5.43 vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vue-router@5.2.0(@vue/compiler-sfc@3.5.43)(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)): - dependencies: - '@babel/generator': 8.0.0 - '@vue-macros/common': 3.1.4(vue@3.5.43(typescript@5.9.3)) - '@vue/devtools-api': 8.2.1 - ast-walker-scope: 0.9.0 - chokidar: 5.0.0 - json5: 2.2.3 - local-pkg: 1.2.1 - magic-string: 0.30.21 - mlly: 1.8.2 - muggle-string: 0.4.1 - nostics: 1.2.0 - pathe: 2.0.3 - picomatch: 4.0.5 - scule: 1.3.0 - tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.3)(rollup@4.62.2)(vite@8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - unplugin-utils: 0.3.2 - vue: 3.5.43(typescript@5.9.3) - yaml: 2.9.0 - optionalDependencies: - '@vue/compiler-sfc': 3.5.43 - vite: 8.2.1(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - esbuild - - rolldown - - rollup - - unloader - - webpack - vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.43)(esbuild@0.28.1)(vue@3.5.43(typescript@5.9.3)): dependencies: '@babel/parser': 7.29.7 @@ -16583,10 +14915,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@5.0.0: - dependencies: - string-width: 7.2.0 - word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8b5bb9a..568b19a0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - packages/* - - samples/*/* + - playgrounds/* - samples/integrations/*/* - tests/* @@ -14,9 +14,9 @@ allowBuilds: overrides: # JUSTIFICATION: Fixes GHSA-v422-hmwv-36x6 β€” body-parser silently disables its size limit on an - # invalid `limit` value, allowing a DoS. Transitive via samples/express/quickstart's `express` - # dependency (express > body-parser). express@4's own body-parser range (~1.20.5) already - # permits 1.20.6; remove once a fresh install picks it up without the override. + # invalid `limit` value, allowing a DoS. Transitive via packages/express's `express` + # devDependency (express > body-parser). express's own body-parser range already permits + # 1.20.6; remove once a fresh install picks it up without the override. body-parser: 1.20.6 # JUSTIFICATION: Fixes GHSA-x5fp-wj9c-mxmx (array-limit bypass via bracket-key comma parsing) / # GHSA-4mjr-xmp4-gh2g (DoS via attacker-controlled isBuffer). Transitive via body-parser's own @@ -41,7 +41,7 @@ overrides: # JUSTIFICATION: Fixes GHSA-j22f-vq7h-c4qm / GHSA-mcm9-63f2-9j32 / GHSA-x5rw-q4pp-hg5g β€” devalue # `stringify`/`uneval` serializing shared memory, quadratic expansion of repeated primitive # strings in `uneval`, and an unhandled rejection from `stringifyAsync`. Transitive via - # packages/nuxt and samples/nuxt/quickstart's `nuxt` and `@nuxt/nitro-server`. Added 2026-10-02; + # packages/nuxt's `nuxt` and `@nuxt/nitro-server`. Added 2026-10-02; # remove once nuxt's own lockfile entry resolves devalue >=5.9.3 without the override. devalue: 5.9.4 # JUSTIFICATION: Fixes GHSA-52cp-r559-cp3m / GHSA-5p4m-2wfm-xmqj β€” js-yaml quadratic CPU @@ -60,8 +60,8 @@ overrides: nanoid@5: 5.1.16 # JUSTIFICATION: Fixes GHSA-qx2v-qp2m-jg93 (XSS via unescaped `` in stringify output), # GHSA-6g55-p6wh-862q / GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp (arbitrary file read via - # attacker-controlled `sourceMappingURL`). Transitive via packages/nextjs and - # samples/nextjs/quickstart's `next` dependency, which still bundles `postcss@8.4.31` internally + # attacker-controlled `sourceMappingURL`). Transitive via packages/nextjs's `next` dependency, + # which still bundles `postcss@8.4.31` internally # even on the latest 15.5.x release β€” this is a genuine upstream lag, not a stale lockfile; keep # the override until Next.js bumps its own postcss pin (https://github.com/vercel/next.js). postcss: 8.5.23 @@ -99,7 +99,7 @@ overrides: # JUSTIFICATION: Fixes 5 node-tar advisories (GHSA-w8wr-v893-vjvp, GHSA-23hp-3jrh-7fpw, # GHSA-8x88-c5mf-7j5w, GHSA-gvwx-54wh-qm9j, GHSA-r292-9mhp-454m) β€” PAX path/NUL-byte parsing # crashes, an unbounded-decompression DoS, and a mapHas/filesFilter stack-overflow DoS. Transitive - # via packages/nuxt and samples/nuxt/quickstart's SSR build tooling + # via packages/nuxt's SSR build tooling # (`nuxt` > `@nuxt/nitro-server` > `nitropack` > `@vercel/nft` > `@mapbox/node-pre-gyp` > `tar`). # `@mapbox/node-pre-gyp`@latest already depends on `tar: ^7.4.0`, which permits 7.5.21 β€” this pin # can likely be dropped once nitropack/@vercel/nft's own lockfile entries are refreshed. @@ -111,10 +111,10 @@ auditConfig: # only when running on Windows with the dev/serve command exposed on the network; it does not # affect `vite build`/`vitest run` output. Transitive via `vitest` > `vite` > `esbuild` # (pulled into every package that runs vitest). The only patched line is 0.28.x, and - # overriding to it breaks samples/vue/quickstart's production build: esbuild 0.28 dropped - # object-destructuring transform support for the sample's configured legacy browser targets - # (chrome87/edge88/firefox78/safari14). Revisit once esbuild backports the fix to 0.27.x or - # the sample's target list is modernized. + # overriding to it broke the Vue quickstart's production build (the quickstart now lives in + # thunder-id/samples): esbuild 0.28 dropped object-destructuring transform support for its + # configured legacy browser targets (chrome87/edge88/firefox78/safari14). Revisit whether the + # override now holds for the packages alone, or once esbuild backports the fix to 0.27.x. - GHSA-g7r4-m6w7-qqqr # JUSTIFICATION: GHSA-86w9-cpqp-85rv β€” node-forge accepts extra nested DigestAlgorithm elements # during RSA PKCS#1 v1.5 signature verification. No patched release exists (1.4.0 is the @@ -134,7 +134,7 @@ auditConfig: # GHSA-v5rq-49vh-5v5c (`@simple-git/argv-parser`) β€” simple-git's unsafe-operations guard can be # bypassed (`trailer..cmd`, config includes, long-option abbreviations, `VISUAL`), but # only when untrusted values reach `SimpleGitOptions.config` or the arguments passed to git. - # Transitive via packages/nuxt and samples/nuxt/quickstart's `nuxt` > `@nuxt/devtools` > + # Transitive via packages/nuxt's `nuxt` > `@nuxt/devtools` > # `simple-git`, where devtools (a dev-time tool) only runs `branch()`, `revparse(['--short', # 'HEAD'])` and `status()` with fixed arguments on the project's own checkout. The patched # 4.x line cannot be forced with an override: it drops the default export that diff --git a/samples/browser/quickstart/.env.example b/samples/browser/quickstart/.env.example deleted file mode 100644 index e38f4022..00000000 --- a/samples/browser/quickstart/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Base URL of your ThunderID deployment. ex: https://localhost:8090 -VITE_THUNDERID_BASE_URL=https://localhost:8090 - -# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview. -VITE_THUNDERID_CLIENT_ID=your-client-id-here diff --git a/samples/browser/quickstart/.stackblitzrc b/samples/browser/quickstart/.stackblitzrc deleted file mode 100644 index 5f0a2000..00000000 --- a/samples/browser/quickstart/.stackblitzrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "installDependencies": false, - "startCommand": "npm run prepare-dev && npm install && npm run dev" -} diff --git a/samples/browser/quickstart/README.md b/samples/browser/quickstart/README.md deleted file mode 100644 index 78ad0dd9..00000000 --- a/samples/browser/quickstart/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# ThunderID Browser Quickstart - -Open in StackBlitz - -A minimal Vite + vanilla JS app demonstrating sign-in and sign-out with the ThunderID JavaScript SDK (`@thunderid/browser`). - -## Prerequisites - -- Node.js 18+ -- A running ThunderID instance (default: `https://localhost:8090`) -- A configured application with an authorized redirect URI set to your app's origin - -## Getting started - -1. Copy the environment file and fill in your values: - ```sh - cp .env.example .env - ``` - -2. Edit `.env` with your application's credentials: - ``` - VITE_THUNDERID_CLIENT_ID= - VITE_THUNDERID_BASE_URL=https://localhost:8090 - ``` - -3. Install dependencies and start the dev server: - ```sh - pnpm install - pnpm dev - ``` - -Open [http://localhost:5173](http://localhost:5173) in your browser. - -## Learn more - -- [ThunderID Docs](https://thunderid.dev/docs) -- [`@thunderid/browser` SDK reference](https://thunderid.dev/docs/sdks/javascript/browser) diff --git a/samples/browser/quickstart/index.html b/samples/browser/quickstart/index.html deleted file mode 100644 index 8e933764..00000000 --- a/samples/browser/quickstart/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - ThunderID JavaScript Quickstart - - -
- - - diff --git a/samples/browser/quickstart/package.json b/samples/browser/quickstart/package.json deleted file mode 100644 index dbf28600..00000000 --- a/samples/browser/quickstart/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@thunderid/browser-quickstart", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "prepare-dev": "node scripts/prepare-dev.cjs", - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "@thunderid/browser": "workspace:*" - }, - "devDependencies": { - "vite": "^6.3.5" - } -} diff --git a/samples/browser/quickstart/scripts/prepare-dev.cjs b/samples/browser/quickstart/scripts/prepare-dev.cjs deleted file mode 100644 index 8e20fa43..00000000 --- a/samples/browser/quickstart/scripts/prepare-dev.cjs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('node:fs'); -const path = require('node:path'); - -const root = path.join(__dirname, '..'); - -const envExample = path.join(root, '.env.example'); -const envTarget = path.join(root, '.env'); -if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) { - // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) - // so the copied .env still trips the app's missing-env-var check until real - // values are filled in. - const envContent = fs - .readFileSync(envExample, 'utf8') - .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); - fs.writeFileSync(envTarget, envContent); -} - -const pkgPath = path.join(root, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -for (const field of ['dependencies', 'devDependencies']) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, version] of Object.entries(deps)) { - if (version.startsWith('workspace:')) { - deps[name] = 'latest'; - } - } -} - -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/browser/quickstart/src/auth.js b/samples/browser/quickstart/src/auth.js deleted file mode 100644 index bad532be..00000000 --- a/samples/browser/quickstart/src/auth.js +++ /dev/null @@ -1,18 +0,0 @@ -import { ThunderIDBrowserClient } from '@thunderid/browser' - -const REQUIRED_ENV_VARS = ['VITE_THUNDERID_CLIENT_ID', 'VITE_THUNDERID_BASE_URL'] - -export const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !import.meta.env[key]) - -const auth = new ThunderIDBrowserClient() - -if (missingEnvVars.length === 0) { - await auth.initialize({ - clientId: import.meta.env.VITE_THUNDERID_CLIENT_ID, - baseUrl: import.meta.env.VITE_THUNDERID_BASE_URL, - afterSignInUrl: window.location.origin, - afterSignOutUrl: window.location.origin, - }) -} - -export default auth diff --git a/samples/browser/quickstart/src/components/nav.js b/samples/browser/quickstart/src/components/nav.js deleted file mode 100644 index 25b1ca74..00000000 --- a/samples/browser/quickstart/src/components/nav.js +++ /dev/null @@ -1,171 +0,0 @@ -const ICON_MOON = `` -const ICON_SUN = `` -const ICON_KEY = `` -const ICON_USER = `` -const ICON_CHEVRON_LEFT = `` -const ICON_CHEVRON_DOWN = `` -const ICON_TECH = `JS` - -function escapeHtml(str) { - if (str == null) return '' - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function getInitials(user) { - const given = user?.given_name || '' - const family = user?.family_name || '' - if (given && family) return (given[0] + family[0]).toUpperCase() - const name = user?.name || user?.username || user?.email || '?' - const parts = name.trim().split(/\s+/) - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() - return name.slice(0, 2).toUpperCase() -} - -function getAvatarUrl(user) { - return user?.profile || user?.profileUrl || user?.picture || user?.URL || null -} - -function renderAvatar(user, className) { - const avatarUrl = getAvatarUrl(user) - if (avatarUrl) { - return `` - } - return `${escapeHtml(getInitials(user))}` -} - -function getFullName(user) { - const given = user?.given_name || '' - const family = user?.family_name || '' - if (given && family) return `${given} ${family}` - return user?.name || user?.username || 'User' -} - -export function renderSignedOutNav({ isDark, hideSignIn = false }) { - return ` - ` -} - -export function renderSignedInNav({ user, isDark, currentPage }) { - const name = escapeHtml(getFullName(user)) - const email = escapeHtml(user?.email || '') - - return ` - ` -} - -export function attachNavHandlers({ isDark, setIsDark, navigateTo, auth }) { - document.getElementById('dark-toggle')?.addEventListener('click', () => { - const next = !isDark() - setIsDark(next) - document.documentElement.classList.toggle('dark', next) - const dt = document.getElementById('dark-toggle') - if (dt) dt.innerHTML = next ? ICON_SUN : ICON_MOON - document.querySelectorAll('#hero-mark-top').forEach(el => - el.setAttribute('fill', next ? '#E8F4FF' : '#05213F') - ) - }) - - document.getElementById('nav-logo-btn')?.addEventListener('click', (e) => { - e.preventDefault() - navigateTo('home') - }) - - document.getElementById('nav-back-btn')?.addEventListener('click', () => { - navigateTo('home') - }) - - const trigger = document.getElementById('ud-trigger') - const menu = document.getElementById('ud-menu') - const wrapper = document.getElementById('ud-wrapper') - if (trigger && menu) { - trigger.addEventListener('click', (e) => { - e.stopPropagation() - menu.classList.toggle('ud-menu--open') - }) - document.addEventListener('click', (e) => { - if (!wrapper?.contains(e.target)) menu.classList.remove('ud-menu--open') - }, { once: true }) - } - - document.getElementById('ud-token-debug')?.addEventListener('click', () => { - menu?.classList.remove('ud-menu--open') - navigateTo('token') - }) - - document.getElementById('ud-manage-profile')?.addEventListener('click', () => { - menu?.classList.remove('ud-menu--open') - navigateTo('account') - }) - - document.getElementById('ud-sign-out')?.addEventListener('click', () => { - auth.signOut() - }) -} - -export function attachSignedOutNavHandlers({ auth }) { - document.getElementById('sign-in-btn')?.addEventListener('click', () => auth.signIn()) - document.getElementById('dark-toggle')?.addEventListener('click', () => { - const next = !document.documentElement.classList.contains('dark') - document.documentElement.classList.toggle('dark', next) - const dt = document.getElementById('dark-toggle') - if (dt) dt.innerHTML = next ? ICON_SUN : ICON_MOON - document.querySelectorAll('#hero-mark-top').forEach(el => - el.setAttribute('fill', next ? '#E8F4FF' : '#05213F') - ) - }) -} diff --git a/samples/browser/quickstart/src/components/profileFields.js b/samples/browser/quickstart/src/components/profileFields.js deleted file mode 100644 index f0fd0452..00000000 --- a/samples/browser/quickstart/src/components/profileFields.js +++ /dev/null @@ -1,305 +0,0 @@ -import { deepMerge, getUsersMe, getUsersMeMeta, updateMeProfile } from '@thunderid/browser' - -const ICON_PENCIL = `` - -// Attributes that are always read-only regardless of schema mutability -const ALWAYS_READONLY_KEYS = [ - 'attributes', - 'id', - 'isReadOnly', - 'isReadonly', - 'ouId', - 'sub', - 'username', - 'userName', - 'user_name', -] - -export function escapeHtml(str) { - if (str == null) return '' - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function formatLabel(key) { - return key - .split(/(?=[A-Z])|[_.]/) - .filter(Boolean) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' ') -} - -function fieldLabel(key, schemaEntry) { - return schemaEntry?.displayName || formatLabel(key) -} - -function isFieldEditable(key, schemaEntry) { - if (ALWAYS_READONLY_KEYS.includes(key)) return false - if (!schemaEntry) return false - if (schemaEntry.credential) return false - if (schemaEntry.readOnly || schemaEntry.mutability === 'READ_ONLY') return false - return true -} - -function getAvatarUrl(user) { - return user?.profile || user?.profileUrl || user?.picture || user?.URL || null -} - -// Deterministic gradient background derived from a name, matching `@thunderid/react`'s -// `Avatar` `background="random"` (default) behavior β€” same hash and HSL formula, so a -// given user gets the same avatar color here as they would in the React/Vue quickstarts. -function generateAvatarBackground(name) { - const hash = name.split('').reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) & 0xffffffff, 0) - const seed = Math.abs(hash) - const hue1 = seed % 360 - const hue2 = (hue1 + 60 + (seed % 120)) % 360 - const saturation = 70 + (seed % 20) - const lightness1 = 55 + (seed % 15) - const lightness2 = 60 + (seed % 15) - const angle = 45 + (seed % 91) - return `linear-gradient(${angle}deg, hsl(${hue1}, ${saturation}%, ${lightness1}%), hsl(${hue2}, ${saturation}%, ${lightness2}%))` -} - -function getInitials(user) { - const given = user?.given_name || '' - const family = user?.family_name || '' - if (given && family) return (given[0] + family[0]).toUpperCase() - const name = user?.name || user?.username || user?.email || '?' - const parts = name.trim().split(/\s+/) - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() - return name.slice(0, 2).toUpperCase() -} - -// `getDisplayName`: first + last name, falling back to -// username, email, then the `name` attribute. -function getDisplayName(user, attributes) { - const given = attributes?.given_name || user?.given_name - const family = attributes?.family_name || user?.family_name - if (given && family) return `${given} ${family}` - return user?.username || user?.email || attributes?.name || 'User' -} - -function renderAvatarInner(user, displayName) { - const avatarUrl = getAvatarUrl(user) - if (avatarUrl) { - return { className: '', html: `` } - } - return { - className: 'has-gradient', - html: escapeHtml(getInitials(user)), - style: `background:${generateAvatarBackground(displayName || 'User')}`, - } -} - -export function createFetcher(auth) { - return async (url, config) => { - const token = await auth.getAccessToken() - return fetch(url, { - ...config, - headers: { ...config.headers, Authorization: `Bearer ${token}` }, - }) - } -} - -// Fetches the schema and the current attribute values needed to render and validate the profile view. -export async function fetchProfileFormContext({ baseUrl, auth }) { - const fetcher = createFetcher(auth) - - // A failed fetch stays `null`, not `{}` β€” `supportsCredential` treats those differently: an - // unresolved schema is "not known yet" (every credential stays available), while `{}` is a - // known schema declaring nothing (every credential becomes unavailable). Collapsing a fetch - // failure into `{}` would make a transient network error look identical to "no credentials - // configured". - const [metaRes, profile] = await Promise.all([ - getUsersMeMeta({ baseUrl, fetcher }).catch(() => null), - getUsersMe({ baseUrl, fetcher }).catch(() => null), - ]) - - return { schema: metaRes?.schema ?? null, profile } -} - -function renderFieldRow(key, schemaEntry, value) { - if (schemaEntry?.credential) return '' - - const label = fieldLabel(key, schemaEntry) - const editable = isFieldEditable(key, schemaEntry) - const hasValue = value !== undefined && value !== null && value !== '' - - // BaseUserProfile `shouldShow`: an empty read-only field is hidden entirely rather than rendered as a dash. - if (!hasValue && !editable) return '' - - const displayValue = hasValue ? escapeHtml(String(value)) : `Enter your ${label.toLowerCase()}` - - return ` -
-
${escapeHtml(label)}
-
- ${displayValue} - ${editable ? `` : ''} -
-
` -} - -// Renders the "Personal info" section: avatar/name summary plus the schema-driven, -// per-field editable rows. Used by the account page's Personal info tab. -export function renderProfileFields(user, { schema = {}, profile } = {}) { - const attributes = profile?.attributes || {} - const displayName = getDisplayName(user, attributes) - const avatar = renderAvatarInner(user, displayName) - const email = escapeHtml(user?.email || user?.username || '') - - // `schema` may be `null` (the meta fetch failed) β€” a default parameter only covers - // `undefined`, not an explicit `null`, so this still needs its own fallback. - const rows = Object.entries(schema || {}) - .map(([key, schemaEntry]) => renderFieldRow(key, schemaEntry, attributes[key])) - .join('') - - return ` -
-
${avatar.html}
-
-
${escapeHtml(displayName)}
- ${email ? `
${email}
` : ''} -
-
- -
${rows}
` -} - -// Validates a field value against its schema entry (required + regex), matching -// BaseUserProfile `handleFieldSave`. Returns an error message, or -// `null` when valid. -function validateField(schemaEntry, label, value) { - if (!schemaEntry) return null - - if (schemaEntry.required && !value) { - return `${label} is required.` - } - - if (schemaEntry.regex && value) { - try { - if (!new RegExp(schemaEntry.regex).test(value)) { - return `${label} is not in a valid format.` - } - } catch { - // Invalid regex on the schema itself β€” nothing to enforce client-side. - } - } - - return null -} - -// Wires up inline per-field editing for the rows rendered by `renderProfileFields`. Safe to -// call once after that markup is in the DOM. -export function attachProfileFieldHandlers({ user, auth, schema, profile, onSaved }) { - // `schema` may be `null` (the meta fetch failed) β€” a default parameter only covers - // `undefined`, not an explicit `null`, so this still needs its own fallback. - const resolvedSchema = schema || {} - const errorEl = document.getElementById('profile-error') - const fieldList = document.getElementById('profile-field-list') - const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL - const fetcher = createFetcher(auth) - - // Tracks the latest known attributes so successive per-field edits merge against - // up-to-date values without refetching the whole profile on every save. - let currentAttributes = { ...(profile?.attributes || {}) } - - const refreshHeader = () => { - const mergedUser = { ...user, ...currentAttributes } - const displayName = getDisplayName(mergedUser, currentAttributes) - - const avatarEl = document.querySelector('.profile-summary-avatar') - if (avatarEl) { - const avatar = renderAvatarInner(mergedUser, displayName) - avatarEl.className = `profile-summary-avatar ${avatar.className}` - avatarEl.setAttribute('style', avatar.style || '') - avatarEl.innerHTML = avatar.html - } - - const nameEl = document.querySelector('.profile-summary-name') - if (nameEl) nameEl.textContent = displayName - } - - const showError = (message) => { - if (!errorEl) return - errorEl.hidden = false - errorEl.textContent = message - } - const clearError = () => { - if (!errorEl) return - errorEl.hidden = true - errorEl.textContent = '' - } - - const startEdit = (row, key, schemaEntry) => { - const display = row.querySelector('.profile-field-row-display') - if (!display) return - const currentValue = currentAttributes[key] ?? '' - - display.innerHTML = ` - -
- - -
` - - const input = display.querySelector('input') - input?.focus() - - const cancel = () => { - row.outerHTML = renderFieldRow(key, schemaEntry, currentAttributes[key]) - } - - const save = async () => { - const value = input?.value.trim() || '' - const label = fieldLabel(key, schemaEntry) - const fieldError = validateField(schemaEntry, label, value) - if (fieldError) { - showError(fieldError) - return - } - clearError() - - const saveBtn = display.querySelector('[data-action="save"]') - if (saveBtn) saveBtn.disabled = true - - try { - const mergedAttributes = deepMerge(currentAttributes, { [key]: value }) - const updatedUser = await updateMeProfile({ baseUrl, payload: mergedAttributes, fetcher }) - - currentAttributes = { ...currentAttributes, [key]: updatedUser?.[key] ?? value } - row.outerHTML = renderFieldRow(key, schemaEntry, currentAttributes[key]) - refreshHeader() - - onSaved?.({ ...user, ...currentAttributes }) - } catch (err) { - showError(err?.message || 'Failed to update profile. Please try again.') - if (saveBtn) saveBtn.disabled = false - } - } - - display.addEventListener('click', (e) => { - const action = e.target.closest('[data-action]')?.dataset.action - if (action === 'save') save() - if (action === 'cancel') cancel() - }) - - input?.addEventListener('keydown', (e) => { - if (e.key === 'Enter') save() - if (e.key === 'Escape') cancel() - }) - } - - fieldList?.addEventListener('click', (e) => { - const editBtn = e.target.closest('[data-action="edit"]') - if (!editBtn) return - const row = editBtn.closest('.profile-field-row') - const key = row?.dataset.field - if (!row || !key) return - startEdit(row, key, resolvedSchema[key]) - }) -} diff --git a/samples/browser/quickstart/src/main.js b/samples/browser/quickstart/src/main.js deleted file mode 100644 index 06eb46ae..00000000 --- a/samples/browser/quickstart/src/main.js +++ /dev/null @@ -1,101 +0,0 @@ -import './style.css' -import auth, { missingEnvVars } from './auth.js' -import { renderSignedOutNav, renderSignedInNav, attachNavHandlers, attachSignedOutNavHandlers } from './components/nav.js' -import { mountAccountPage } from './pages/account.js' -import { renderSignedOut, renderHome, renderConfigNeeded, startCountdown, attachSignedOutHandlers, attachConfigNeededHandlers } from './pages/home.js' -import { renderTokenDebug, attachTokenHandlers } from './pages/token.js' - -let isDark = false -let currentPage = 'home' -let user = null -let idToken = null -let rawToken = null -let timer = null - -function navigateTo(page) { - if (timer) { clearInterval(timer); timer = null } - currentPage = page - renderSignedInPage() -} - -function renderSignedInPage() { - const app = document.getElementById('app') - if (!app) return - - if (currentPage === 'account') { - app.innerHTML = renderSignedInNav({ user, isDark, currentPage }) + '
' - attachNavHandlers({ - isDark: () => isDark, - setIsDark: (v) => { isDark = v }, - navigateTo, - auth, - }) - mountAccountPage(document.getElementById('account-root'), { - user, - auth, - onUserUpdated: (updatedUser) => { - user = updatedUser - }, - }) - return - } - - let content - if (currentPage === 'token') { - content = renderTokenDebug({ rawToken }) - } else { - content = renderHome({ user, idToken }) - } - - app.innerHTML = renderSignedInNav({ user, isDark, currentPage }) + content - - attachNavHandlers({ - isDark: () => isDark, - setIsDark: (v) => { isDark = v }, - navigateTo, - auth, - }) - - if (currentPage === 'token') { - attachTokenHandlers({ rawToken }) - } else if (currentPage === 'home') { - timer = startCountdown({ idToken }) - } -} - -async function renderApp() { - const app = document.getElementById('app') - if (!app) return - - if (missingEnvVars.length > 0) { - app.innerHTML = renderSignedOutNav({ isDark, hideSignIn: true }) + renderConfigNeeded(missingEnvVars) - attachSignedOutNavHandlers({ auth }) - attachConfigNeededHandlers() - return - } - - if (timer) { clearInterval(timer); timer = null } - - await auth.signIn({ callOnlyOnRedirect: true }) - - const signedIn = await auth.isSignedIn() - - if (signedIn) { - user = await auth.getUser() - try { idToken = await auth.getDecodedIdToken() } catch { idToken = null } - try { rawToken = await auth.getAccessToken() } catch { rawToken = null } - currentPage = 'home' - renderSignedInPage() - } else { - user = null - idToken = null - rawToken = null - currentPage = 'home' - - app.innerHTML = renderSignedOutNav({ isDark }) + renderSignedOut() - attachSignedOutNavHandlers({ auth }) - attachSignedOutHandlers({ auth }) - } -} - -renderApp() diff --git a/samples/browser/quickstart/src/pages/account.js b/samples/browser/quickstart/src/pages/account.js deleted file mode 100644 index 3e2fcb3b..00000000 --- a/samples/browser/quickstart/src/pages/account.js +++ /dev/null @@ -1,307 +0,0 @@ -import { - CredentialConstants, - evaluateChangePasswordForm, - mapCredentialUpdateError, - resolveChangeCredentialPolicy, - supportsCredential, - updateMeCredentials, -} from '@thunderid/browser' -import { createFetcher, escapeHtml, fetchProfileFormContext, renderProfileFields, attachProfileFieldHandlers } from '../components/profileFields.js' - -const ICON_HOME = `` -const ICON_PERSON = `` -const ICON_SHIELD = `` -const ICON_EYE = `` -const ICON_CHECK = `` -const ICON_X = `` -const ICON_CHEVRON = `` - -const TABS = [ - { id: 'home', label: 'Home', icon: ICON_HOME }, - { id: 'personal', label: 'Personal info', icon: ICON_PERSON }, - { id: 'security', label: 'Security', icon: ICON_SHIELD }, -] - -const TILES = [ - { id: 'personal', icon: ICON_PERSON, tone: 'blue', title: 'Personal info', description: 'Name, email, phone, and profile photo' }, - { id: 'security', icon: ICON_SHIELD, tone: 'green', title: 'Security', description: 'Password and other credentials' }, -] - -// Mirrors the React/Vue quickstarts' Security tab: one credential per row. -const CREDENTIALS = [ - { - attribute: CredentialConstants.PASSWORD, - title: 'Password', - description: 'Used to sign in to your account.', - cta: 'Change password', - submitLabel: 'Update Password', - }, -] - -function renderHomeTab(user) { - const givenName = user?.given_name || user?.givenName || user?.name || user?.username || 'there' - - const tiles = TILES.map( - (tile) => ` - `, - ).join('') - - return ` - - - ` -} - -function renderCredentialCard(credential, { schema, openAttribute }) { - const isOpen = openAttribute === credential.attribute - - return ` - ` -} - -// The form fields, always rendered so the "unavailable" state has something to sit behind -// (blurred, disabled) rather than showing nothing at all β€” matches -// `BaseChangeCredential`'s unavailable rendering in the React/Vue SDKs. -function renderCredentialFields(credential) { - const nameLower = credential.title.toLowerCase() - const newFieldId = `cred-${credential.attribute}-new` - const confirmFieldId = `cred-${credential.attribute}-confirm` - - return ` -
- -
- - -
-
- -
- -
- - -
-
- - -
- -
` -} - -function renderCredentialForm(credential, schema) { - const unavailable = !supportsCredential(schema, credential.attribute) - const fields = renderCredentialFields(credential) - - const body = unavailable - ? ` -
-
${fields}
-
- ${escapeHtml(credential.title)} changes unavailable -
Please contact your administrator.
-
-
` - : `
${fields}
` - - return `` -} - -function renderSecurityTab({ schema, openAttribute }) { - const cards = CREDENTIALS.map((credential) => renderCredentialCard(credential, { schema, openAttribute })).join('') - - return ` - - - ` -} - -function renderPersonalTab(user, { schema, profile }) { - return ` - - - ` -} - -function renderAccountPage({ tab, user, schema, profile, openAttribute }) { - const nav = TABS.map( - (t) => ` - `, - ).join('') - - let content - if (tab === 'personal') { - content = renderPersonalTab(user, { schema, profile }) - } else if (tab === 'security') { - content = renderSecurityTab({ schema, openAttribute }) - } else { - content = renderHomeTab(user) - } - - return ` - ` -} - -function attachCredentialForm(formEl, { attribute, schema, baseUrl, auth, onDone }) { - const policy = resolveChangeCredentialPolicy(schema, attribute) - const newInput = formEl.querySelector('[data-cred-field="newValue"]') - const confirmInput = formEl.querySelector('[data-cred-field="confirmValue"]') - const reqBox = formEl.querySelector('[data-cred-requirements]') - const reqList = formEl.querySelector('[data-cred-requirements-list]') - const submitBtn = formEl.querySelector('[data-cred-submit]') - const errorBox = formEl.querySelector('[data-cred-error]') - const successBox = formEl.querySelector('[data-cred-success]') - - const evaluate = () => { - const { ruleResults, isValid } = evaluateChangePasswordForm( - { newPassword: newInput.value, confirmPassword: confirmInput.value }, - policy, - ) - - if (ruleResults.length > 0) { - reqBox.hidden = false - reqList.innerHTML = ruleResults - .map( - (result) => - `
  • ${result.passed ? ICON_CHECK : ICON_X} Matches the required format
  • `, - ) - .join('') - } else { - reqBox.hidden = true - } - - submitBtn.disabled = !isValid - } - - newInput.addEventListener('input', evaluate) - confirmInput.addEventListener('input', evaluate) - - formEl.querySelectorAll('[data-cred-toggle-visibility]').forEach((btn) => { - btn.addEventListener('click', () => { - const input = btn.previousElementSibling - input.type = input.type === 'password' ? 'text' : 'password' - }) - }) - - submitBtn.addEventListener('click', async () => { - errorBox.hidden = true - successBox.hidden = true - submitBtn.disabled = true - - try { - const fetcher = createFetcher(auth) - await updateMeCredentials({ baseUrl, payload: { [attribute]: newInput.value }, fetcher }) - - successBox.hidden = false - newInput.value = '' - confirmInput.value = '' - evaluate() - onDone?.() - } catch (err) { - const { message } = mapCredentialUpdateError(err) - errorBox.hidden = false - errorBox.textContent = message || 'An error occurred while updating your credential. Please try again.' - submitBtn.disabled = false - } - }) -} - -// Self-mounting Account page: fetches the schema/profile once, then owns its own tab and -// per-credential open/closed state, re-rendering just its own container on every change -// rather than the whole app shell. -export async function mountAccountPage(container, { user, auth, initialTab = 'home', onUserUpdated }) { - const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL - - let tab = initialTab - let openAttribute = null - let currentUser = user - - const { schema, profile } = await fetchProfileFormContext({ baseUrl, auth }) - - const render = () => { - container.innerHTML = renderAccountPage({ tab, user: currentUser, schema, profile, openAttribute }) - attachHandlers() - } - - const attachHandlers = () => { - container.querySelectorAll('[data-tab]').forEach((btn) => { - btn.addEventListener('click', () => { - tab = btn.dataset.tab - openAttribute = null - render() - }) - }) - - if (tab === 'personal') { - attachProfileFieldHandlers({ - user: currentUser, - auth, - schema, - profile, - onSaved: (updatedUser) => { - currentUser = updatedUser - onUserUpdated?.(updatedUser) - }, - }) - } - - if (tab === 'security') { - // The same button opens and closes the form: clicking it while its own credential is - // already open collapses it, same as clicking the chevron. - container.querySelectorAll('[data-cred-toggle]').forEach((btn) => { - btn.addEventListener('click', () => { - const attribute = btn.dataset.credToggle - openAttribute = openAttribute === attribute ? null : attribute - render() - }) - }) - - const formEl = container.querySelector('[data-cred-form]') - if (formEl) { - attachCredentialForm(formEl, { - attribute: formEl.dataset.credForm, - schema, - baseUrl, - auth, - onDone: () => { - openAttribute = null - render() - }, - }) - } - } - } - - render() -} diff --git a/samples/browser/quickstart/src/pages/home.js b/samples/browser/quickstart/src/pages/home.js deleted file mode 100644 index 24be3a57..00000000 --- a/samples/browser/quickstart/src/pages/home.js +++ /dev/null @@ -1,321 +0,0 @@ -const HERO_MARK = ` - - - -` - -const ICON_CLOCK = `` -const ICON_TIMER = `` -const ICON_BUILDING = `` -const ICON_ARROW = `` - -const NEXT_STEPS = [ - { - n: '01', - title: 'Explore use cases', - body: 'See what you can build β€” auth flows for web, mobile, APIs, and agents.', - cta: 'Browse use cases', - href: 'https://thunderid.dev/docs/next/use-cases/overview/', - }, - { - n: '02', - title: 'Learn about flows', - body: 'Understand how authorization code, PKCE, client credentials, and device flows work.', - cta: 'Read guide', - href: 'https://thunderid.dev/docs/next/guides/flows/what-are-flows/', - }, - { - n: '03', - title: 'Style your experience', - body: 'Customize the login UI, branding, and email templates to match your product.', - cta: 'Design guide', - href: 'https://thunderid.dev/docs/next/guides/design/overview/', - }, - { - n: '04', - title: 'Explore SDK APIs', - body: 'Full Browser SDK reference β€” client methods, PKCE configuration, and token management.', - cta: 'SDK reference', - href: 'https://thunderid.dev/docs/next/sdks/browser/overview/', - }, -] - -function escapeHtml(str) { - if (str == null) return '' - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function getInitials(user) { - const given = user?.given_name || '' - const family = user?.family_name || '' - if (given && family) return (given[0] + family[0]).toUpperCase() - const name = user?.name || user?.username || user?.email || '?' - const parts = name.trim().split(/\s+/) - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() - return name.slice(0, 2).toUpperCase() -} - -function getAvatarUrl(user) { - return user?.profile || user?.profileUrl || user?.picture || user?.URL || null -} - -function renderAvatar(user, className) { - const avatarUrl = getAvatarUrl(user) - if (avatarUrl) { - return `
    ` - } - return `
    ${escapeHtml(getInitials(user))}
    ` -} - -function greeting(name) { - const h = new Date().getHours() - const tod = h < 12 ? 'morning' : h < 17 ? 'afternoon' : 'evening' - return `Good ${tod}, ${name}.` -} - -function formatTime(unixSeconds) { - if (!unixSeconds) return 'β€”' - return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) -} - -export function formatCountdown(secsLeft) { - if (secsLeft <= 0) return { text: 'Expired', color: '#d95757' } - if (secsLeft < 300) { - const m = Math.floor(secsLeft / 60), s = secsLeft % 60 - return { text: `${m}m ${s}s`, color: '#e88b3a' } - } - if (secsLeft < 3600) { - const m = Math.floor(secsLeft / 60), s = secsLeft % 60 - return { text: `${m}m ${s}s`, color: '#2fbd6b' } - } - const hr = Math.floor(secsLeft / 3600), m = Math.floor((secsLeft % 3600) / 60) - return { text: `${hr}h ${m}m`, color: '#2fbd6b' } -} - -export function renderSignedOut() { - return ` -
    -
    -
    ${HERO_MARK}
    -
    - - Open source - -
    -

    Auth for Modern Apps and Agents

    -

    - ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of the box. - Clone the Quickstart and ship auth before lunch. -

    -
    - -
    -
    -
    -
    - OAuth 2.0 - Authorization standard -
    -
    - < 5 min - Integration time -
    -
    - Apache 2.0 - License -
    -
    -
    -
    ` -} - -export function attachSignedOutHandlers({ auth }) { - document.getElementById('hero-sign-in-btn')?.addEventListener('click', () => auth.signIn()) -} - -export function renderConfigNeeded(missing) { - const origin = typeof window !== 'undefined' ? window.location.origin : '' - - return ` -
    -
    -
    ${HERO_MARK}
    -
    - - Setup required - -
    -

    Configuration needed

    -

    - This quickstart can't reach ThunderID yet. Follow the steps below, - then restart the dev server. -

    - -
    -
    Step 1 · Set environment variables
    -
      - ${missing.map(key => `
    • ${escapeHtml(key)}
    • `).join('')} -
    -

    - Copy .env.example to .env, fill in the - values from your ThunderID application, then run npm run dev again. -

    -
    - -
    -
    Step 2 · Allow this origin for CORS
    -
    -

    - Sign-in requests from this origin will be blocked by the browser - until it's added to your ThunderID deployment's allowed CORS - origins. In the ThunderID Console, go to - Settings → CORS → Allowed origins and - add it. -

    -
    - ${escapeHtml(origin)} - -
    -
    -
    - -
    -
    Step 3 · Register redirect URIs
    -
    -

    - This origin also doubles as this app's Authorized redirect URI - and Post-Logout Redirect URI. In the ThunderID - Console, open this application and go to - Advanced Settings → OAuth2 Configuration, - then add it to both fields below. -

    -
    -
    -
    Authorized redirect URI
    -
    - ${escapeHtml(origin)} - -
    -
    -
    -
    Post-Logout Redirect URI
    -
    - ${escapeHtml(origin)} - -
    -
    -
    -
    -
    - -

    - Need more info? Take a look at the - Browser quickstart guide. -

    -
    -
    ` -} - -export function attachConfigNeededHandlers() { - if (!navigator.clipboard) return - document.querySelectorAll('.config-copy-btn').forEach((btn) => { - btn.addEventListener('click', () => { - navigator.clipboard.writeText(btn.getAttribute('data-copy') || '').then(() => { - const original = btn.textContent - btn.textContent = 'Copied!' - setTimeout(() => { btn.textContent = original }, 1500) - }) - }) - }) -} - -export function renderHome({ user, idToken }) { - const now = Math.floor(Date.now() / 1000) - const givenName = user?.given_name || user?.name || user?.username || 'there' - const email = user?.email || '' - - const authTime = idToken?.auth_time - const exp = idToken?.exp - const secsLeft = exp ? Math.max(0, exp - now) : null - const countdown = secsLeft !== null ? formatCountdown(secsLeft) : null - const orgName = escapeHtml(idToken?.org_name || idToken?.org_handle || 'Default') - - return ` -
    -
    - ${renderAvatar(user, 'home-avatar-initials')} -
    -

    ${escapeHtml(greeting(givenName))}

    -
    - ${email ? `${escapeHtml(email)}` : ''} - - - Session active - -
    -
    -
    - -
    -
    -
    ${ICON_CLOCK}
    -
    -
    ${formatTime(authTime)}
    -
    Signed in at
    -
    -
    -
    -
    ${ICON_TIMER}
    -
    -
    ${countdown ? countdown.text : 'β€”'}
    -
    Session expires in
    -
    -
    -
    -
    ${ICON_BUILDING}
    -
    -
    ${orgName}
    -
    Organisation
    -
    -
    -
    - -
    What's next
    - -
    ` -} - -export function startCountdown({ idToken }) { - const timer = setInterval(() => { - const el = document.getElementById('countdown-value') - if (!el) { clearInterval(timer); return } - const now = Math.floor(Date.now() / 1000) - const exp = idToken?.exp - const secsLeft = exp ? Math.max(0, exp - now) : null - if (secsLeft !== null) { - const cd = formatCountdown(secsLeft) - el.textContent = cd.text - el.style.color = cd.color - } - }, 1000) - return timer -} diff --git a/samples/browser/quickstart/src/pages/token.js b/samples/browser/quickstart/src/pages/token.js deleted file mode 100644 index 9c65551f..00000000 --- a/samples/browser/quickstart/src/pages/token.js +++ /dev/null @@ -1,118 +0,0 @@ -const ICON_COPY = `` - -function escapeHtml(str) { - if (str == null) return '' - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function decodeJwtPart(part) { - try { - const base64 = part.replace(/-/g, '+').replace(/_/g, '/') - const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=') - return JSON.parse(atob(padded)) - } catch { - return null - } -} - -export function renderTokenDebug({ rawToken }) { - const now = Math.floor(Date.now() / 1000) - const parts = rawToken ? rawToken.split('.') : [] - const jwtHeader = parts[0] ? decodeJwtPart(parts[0]) : null - const jwtPayload = parts[1] ? decodeJwtPart(parts[1]) : null - - const exp = jwtPayload?.exp - const expiresIn = exp ? exp - now : null - const expired = expiresIn !== null && expiresIn <= 0 - const minsLeft = expiresIn !== null ? Math.abs(Math.floor(expiresIn / 60)) : null - - const issuer = jwtPayload?.iss - const audience = Array.isArray(jwtPayload?.aud) ? jwtPayload.aud.join(', ') : jwtPayload?.aud - const scopes = jwtPayload?.scope - - return ` -
    -
    -
    -

    Token debug

    -

    Inspect your access token and decoded claims.

    -
    - ${expiresIn !== null ? ` -
    - - ${expired ? 'Expired' : `Valid Β· expires in ${minsLeft} min`} -
    ` : ''} -
    - - ${rawToken ? ` -
    -
    - - -
    - - ${escapeHtml(parts[0] || '')} - . - ${escapeHtml(parts[1] || '')} - . - ${escapeHtml(parts[2] || '')} - -
    ` : ` -
    -
    - -
    - Loading… -
    `} - -
    -
    -
    JWT Header
    -
    ${jwtHeader ? escapeHtml(JSON.stringify(jwtHeader, null, 2)) : '…'}
    -
    -
    -
    JWT Payload
    -
    ${jwtPayload ? escapeHtml(JSON.stringify(jwtPayload, null, 2)) : '…'}
    -
    -
    - - ${issuer || audience || scopes ? ` -
    - ${issuer ? ` -
    -
    Issuer
    -
    ${escapeHtml(issuer)}
    -
    ` : ''} - ${audience ? ` -
    -
    Audience
    -
    ${escapeHtml(audience)}
    -
    ` : ''} - ${scopes ? ` -
    -
    Scopes
    -
    ${escapeHtml(scopes)}
    -
    ` : ''} -
    ` : ''} -
    ` -} - -export function attachTokenHandlers({ rawToken }) { - document.getElementById('copy-btn')?.addEventListener('click', async () => { - if (!rawToken || !navigator.clipboard) return - await navigator.clipboard.writeText(rawToken) - const btn = document.getElementById('copy-btn') - if (btn) { - btn.innerHTML = `${ICON_COPY} Copied!` - setTimeout(() => { - const b = document.getElementById('copy-btn') - if (b) b.innerHTML = `${ICON_COPY} Copy` - }, 1500) - } - }) -} diff --git a/samples/browser/quickstart/src/style.css b/samples/browser/quickstart/src/style.css deleted file mode 100644 index 7b5ba13b..00000000 --- a/samples/browser/quickstart/src/style.css +++ /dev/null @@ -1,1595 +0,0 @@ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg: #F7F9FC; - --card: #ffffff; - --border: #DDE3EC; - --text: #05213F; - --muted: #5A7085; - --blue: #3688FF; - --blue-hover: #2270e8; - --blue-subtle: rgba(54, 136, 255, 0.08); - --secondary: #424242; - --shadow-sm: 0 1px 3px rgba(5, 33, 63, 0.08); - --shadow-md: 0 4px 16px rgba(5, 33, 63, 0.10); - --radius: 10px; - --radius-sm: 6px; -} - -.dark { - --bg: #080f1c; - --card: #0f1a2d; - --border: rgba(255, 255, 255, 0.09); - --text: #E0EAFF; - --muted: rgba(224, 234, 255, 0.48); - --blue-subtle: rgba(54, 136, 255, 0.14); - --secondary: #8b8b8b; - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); -} - -html { - color-scheme: light; -} - -html.dark { - color-scheme: dark; -} - -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: var(--bg); - color: var(--text); - min-height: 100vh; - line-height: 1.5; - transition: background 0.2s ease, color 0.2s ease; -} - -/* Nav */ -.nav { - display: flex; - align-items: center; - gap: 12px; - padding: 0 28px; - height: 52px; - border-bottom: 1px solid var(--border); - background: rgba(247, 249, 252, 0.92); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - position: sticky; - top: 0; - z-index: 100; -} - -.dark .nav { - background: rgba(8, 15, 28, 0.92); -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - text-decoration: none; - color: inherit; - cursor: pointer; - flex: 1; -} - -.wordmark { - display: flex; - flex-direction: column; - gap: 1px; -} - -.wordmark-name { - font-size: 14px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--text); - line-height: 1; -} - -.wordmark-sub { - font-size: 8.5px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--blue); - line-height: 1; -} - -.nav-actions { - display: flex; - align-items: center; - gap: 8px; -} - -/* Hero */ -.hero { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 96px 32px 80px; - max-width: 560px; - margin: 0 auto; -} - -.hero-mark { - margin-bottom: 32px; - opacity: 0.9; -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--muted); - margin-bottom: 28px; -} - -.hero-badge-line { - display: block; - width: 24px; - height: 1px; - background: var(--border); - flex-shrink: 0; -} - -.hero-title { - font-size: 42px; - font-weight: 700; - letter-spacing: -0.05em; - line-height: 1.1; - color: var(--text); - margin-bottom: 18px; -} - -.hero-subtitle { - font-size: 16px; - color: var(--muted); - line-height: 1.8; - letter-spacing: -0.01em; - max-width: 420px; - margin-bottom: 36px; -} - -.hero-ctas { - display: flex; - gap: 10px; - flex-wrap: wrap; - justify-content: center; - margin-bottom: 52px; -} - -.hero-divider { - width: 100%; - border: none; - border-top: 1px solid var(--border); - margin: 4px 0; -} - -.hero-stats { - width: 100%; - max-width: 480px; - display: grid; - grid-template-columns: repeat(3, 1fr); -} - -.stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 16px 8px; -} - -.stat:not(:last-child) { - border-right: 1px solid var(--border); -} - -.stat-value { - font-size: 15px; - font-weight: 700; - color: var(--text); -} - -.stat-label { - font-size: 12px; - color: var(--muted); -} - -/* Config notice */ -.config-badge { - color: #e88b3a; -} - -.config-list { - width: 100%; - max-width: 420px; - text-align: left; - list-style: none; - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 0 16px; - margin: 4px 0 28px; -} - -.config-list-item { - padding: 10px 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; - color: var(--text); -} - -.config-list-item:not(:last-child) { - border-bottom: 1px solid var(--border); -} - -.config-hint { - font-size: 14px; - color: var(--muted); - line-height: 1.7; - max-width: 420px; -} - -.config-hint code { - background: var(--blue-subtle); - border-radius: 4px; - padding: 2px 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; -} - -.config-step { - width: 100%; - max-width: 420px; - margin: 0 auto 28px; -} - -.config-step-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--muted); - margin-bottom: 10px; - text-align: left; -} - -.config-step .config-list, -.config-step .config-hint { - margin-left: 0; - margin-right: 0; -} - -.config-box { - text-align: left; - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 16px; -} - -.config-box-body { - font-size: 13px; - color: var(--muted); - line-height: 1.6; - margin-bottom: 14px; -} - -.config-value-row { - display: flex; - align-items: center; - gap: 8px; -} - -.config-value { - flex: 1; - min-width: 0; - overflow-x: auto; - white-space: nowrap; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: var(--text); - background: var(--blue-subtle); - border-radius: 4px; - padding: 8px 10px; -} - -.config-value-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--muted); - margin-bottom: 4px; -} - -.config-value-group { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-docs-note { - font-size: 13px; - color: var(--muted); - max-width: 420px; - margin: 0 auto; -} - -.config-docs-note a { - color: var(--blue); - font-weight: 600; -} - -/* Buttons */ -.btn-primary, -.btn-outline, -.btn-ghost { - display: inline-flex; - align-items: center; - justify-content: center; - height: 46px; - padding: 0 24px; - border-radius: 8px; - font-size: 15px; - font-weight: 600; - cursor: pointer; - transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease; - white-space: nowrap; - text-decoration: none; - font-family: inherit; -} - -.btn-primary { - background: var(--blue); - color: #ffffff; - border: 1.5px solid var(--blue); -} - -.btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.btn-primary:hover:not(:disabled) { - opacity: 0.88; -} - -.btn-outline { - background: transparent; - color: var(--blue); - border: 1.5px solid rgba(54, 136, 255, 0.4); -} - -.btn-outline:hover { - background: var(--blue-subtle); -} - -.btn-ghost { - background: transparent; - color: var(--muted); - border: none; -} - -.btn-ghost:hover { - background: var(--blue-subtle); - color: var(--text); -} - -.nav .btn-primary, -.nav .btn-outline, -.nav .btn-ghost { - height: 36px; - padding: 0 16px; - font-size: 14px; -} - -.dark-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - padding: 0; - background: transparent; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--muted); - cursor: pointer; - transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; - flex-shrink: 0; -} - -.dark-toggle:hover { - color: var(--text); - background: var(--border); -} - -/* User card */ -.user-card { - position: relative; - background: var(--card); - border: 1px solid var(--border); - border-radius: 16px; - padding: 40px 48px; - box-shadow: var(--shadow-md); - text-align: left; - min-width: 320px; - max-width: 460px; - width: 100%; - margin-top: 24px; -} - -.user-avatar { - width: 64px; - height: 64px; - border-radius: 50%; - background: var(--blue); - color: #ffffff; - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.5px; - margin-bottom: 20px; - box-shadow: 0 4px 12px rgba(54, 136, 255, 0.3); -} - -.user-details { - display: flex; - flex-direction: column; - gap: 12px; -} - -.user-name { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.4px; - color: var(--text); - margin-bottom: 4px; -} - -.user-field { - display: flex; - flex-direction: column; - gap: 2px; -} - -.user-field-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--muted); -} - -.user-field-value { - font-size: 14px; - color: var(--text); - font-weight: 500; -} - -.user-badge { - display: inline-flex; - align-items: center; - gap: 5px; - margin-top: 24px; - font-size: 12px; - font-weight: 600; - color: #16a34a; - background: #f0fdf4; - border: 1px solid #bbf7d0; - padding: 4px 10px; - border-radius: 100px; -} - -.dark .user-badge { - color: #4ade80; - background: rgba(74, 222, 128, 0.1); - border-color: rgba(74, 222, 128, 0.25); -} - -/* Nav back button */ -.nav-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 0 10px; - border-radius: 6px; - border: none; - background: transparent; - color: var(--muted); - font-size: 13px; - font-family: inherit; - letter-spacing: -0.01em; - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; -} - -.nav-back-btn:hover { - background: var(--border); - color: var(--text); -} - -/* User dropdown */ -.ud-wrapper { - position: relative; -} - -.ud-trigger { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px 4px 4px; - border-radius: 24px; - border: 1px solid var(--border); - background: transparent; - color: var(--text); - font-size: 13px; - font-weight: 600; - cursor: pointer; - font-family: inherit; - transition: background 0.15s ease, border-color 0.15s ease; -} - -.ud-trigger:hover { - background: var(--blue-subtle); - border-color: rgba(54, 136, 255, 0.3); -} - -.ud-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background: var(--blue); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 700; - flex-shrink: 0; - overflow: hidden; -} - -.ud-avatar img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.ud-avatar--header { - width: 36px; - height: 36px; - font-size: 13px; -} - -.ud-name { - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ud-menu { - display: none; - position: absolute; - right: 0; - top: calc(100% + 6px); - min-width: 200px; - background: var(--card); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: var(--shadow-md); - overflow: hidden; - z-index: 200; -} - -.ud-menu.ud-menu--open { - display: block; -} - -.ud-header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 14px; -} - -.ud-header-name { - font-size: 13px; - font-weight: 600; - color: var(--text); -} - -.ud-header-email { - font-size: 12px; - color: var(--muted); - margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ud-divider { - height: 1px; - background: var(--border); -} - -.ud-item { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 10px 14px; - border: none; - background: transparent; - color: var(--text); - font-size: 13px; - font-weight: 500; - cursor: pointer; - text-align: left; - font-family: inherit; - transition: background 0.15s ease; -} - -.ud-item:hover { - background: var(--blue-subtle); -} - -/* Signed-in home page */ -.home-main { - width: 100%; - max-width: 900px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -.home-avatar-initials { - width: 52px; - height: 52px; - border-radius: 50%; - background: var(--blue); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - font-weight: 700; - letter-spacing: -0.3px; - flex-shrink: 0; - box-shadow: 0 4px 12px rgba(54, 136, 255, 0.3); - overflow: hidden; -} - -.home-avatar-initials img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.home-greeting { - display: flex; - align-items: flex-start; - gap: 16px; - margin-bottom: 36px; -} - -.home-greeting-text { - min-width: 0; -} - -.home-greeting-name { - margin: 0 0 6px; - font-size: 26px; - font-weight: 700; - color: var(--text); - letter-spacing: -0.04em; -} - -.home-greeting-meta { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: var(--muted); - letter-spacing: -0.01em; - flex-wrap: wrap; -} - -.home-dot { - display: inline-block; - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--border); - flex-shrink: 0; -} - -.home-session-active { - display: inline-flex; - align-items: center; - gap: 5px; - color: #2fbd6b; -} - -.home-session-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: #2fbd6b; - flex-shrink: 0; -} - -.home-stats { - display: flex; - gap: 0; - padding: 20px 0; - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - margin-bottom: 52px; -} - -.home-stat { - flex: 1; - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.home-stat--bordered { - padding-left: 32px; - border-left: 1px solid var(--border); -} - -.home-stat-icon { - width: 32px; - height: 32px; - border-radius: 8px; - background: rgba(54, 136, 255, 0.1); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: #3688FF; -} - -.home-stat-value { - font-size: 14px; - font-weight: 700; - color: var(--text); - letter-spacing: -0.02em; -} - -.home-stat-value--mono { - font-family: monospace; - letter-spacing: 0.01em; -} - -.home-stat-label { - font-size: 11px; - font-weight: 600; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.07em; - margin-top: 2px; -} - -.home-next-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--muted); - margin-bottom: 20px; -} - -.home-next-list { - display: flex; - flex-direction: column; -} - -.home-next-item { - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 16px; - align-items: start; - padding: 20px 0; - border-top: 1px solid var(--border); - text-decoration: none; - color: inherit; - cursor: pointer; - transition: background 0.2s ease; - border-radius: 4px; -} - -.home-next-item:hover .home-next-cta { - opacity: 1; -} - -.home-next-n { - font-size: 11px; - font-weight: 700; - color: #3688FF; - letter-spacing: 0.05em; - padding-top: 2px; - font-variant-numeric: tabular-nums; -} - -.home-next-body { - min-width: 0; -} - -.home-next-title { - font-size: 14px; - font-weight: 600; - color: var(--text); - letter-spacing: -0.02em; - margin-bottom: 4px; -} - -.home-next-desc { - font-size: 13px; - color: var(--muted); - line-height: 1.6; - letter-spacing: -0.01em; -} - -.home-next-cta { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 13px; - font-weight: 600; - color: #3688FF; - white-space: nowrap; - opacity: 0.7; - transition: opacity 0.2s ease; - padding-top: 2px; -} - -/* Token debug page */ -.token-main { - width: 100%; - max-width: 780px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -.token-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 36px; - flex-wrap: wrap; -} - -.token-title { - margin: 0 0 5px; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.04em; - color: var(--text); -} - -.token-subtitle { - margin: 0; - font-size: 13px; - color: var(--muted); - letter-spacing: -0.01em; -} - -.token-badge { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 700; - letter-spacing: -0.01em; - flex-shrink: 0; -} - -.token-badge--expired { - background: rgba(220, 38, 38, 0.08); - border: 1px solid rgba(220, 38, 38, 0.22); - color: #dc2626; -} - -.token-badge--valid { - background: rgba(47, 189, 107, 0.08); - border: 1px solid rgba(47, 189, 107, 0.22); - color: #2fbd6b; -} - -.token-badge-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; -} - -.token-raw-section { - margin-bottom: 20px; -} - -.token-raw-label-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.token-section-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--muted); -} - -.token-copy-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 5px; - background: transparent; - border: 1px solid var(--border); - font-size: 11px; - font-weight: 600; - color: var(--muted); - cursor: pointer; - font-family: inherit; - letter-spacing: -0.01em; - transition: border-color 0.15s ease, color 0.15s ease; -} - -.token-copy-btn:hover { - border-color: #3688FF; - color: #3688FF; -} - -.token-raw { - display: block; - font-size: 11px; - font-family: monospace; - word-break: break-all; - line-height: 1.8; - background: #0b1120; - padding: 14px 16px; - border-radius: 8px; - overflow-x: auto; -} - -.token-raw--loading { - color: rgba(255, 255, 255, 0.3); -} - -.token-part--header { color: #ff7b72; } -.token-part--payload { color: #79c0ff; } -.token-part--signature { color: #3fb950; } -.token-dot { color: rgba(255, 255, 255, 0.2); } - -.token-decoded-grid { - display: grid; - grid-template-columns: 1fr 1.7fr; - gap: 12px; - margin-bottom: 20px; -} - -.token-code-box { - background: #0b1120; - border-radius: 10px; - overflow: hidden; -} - -.token-code-box-header { - padding: 9px 14px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.token-code-box-header--header { - color: #ff7b72; - border-bottom: 1px solid rgba(255, 123, 114, 0.15); -} - -.token-code-box-header--payload { - color: #79c0ff; - border-bottom: 1px solid rgba(121, 192, 255, 0.15); -} - -.token-code-pre { - margin: 0; - padding: 14px; - font-size: 11.5px; - line-height: 1.8; - font-family: monospace; - color: #c9d1d9; - overflow-x: auto; - max-height: 220px; -} - -.token-meta-row { - display: flex; - gap: 32px; - padding-top: 20px; - border-top: 1px solid var(--border); - flex-wrap: wrap; -} - -.token-meta-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--muted); - margin-bottom: 5px; -} - -.token-meta-value { - font-size: 12px; - font-family: monospace; - color: var(--text); - letter-spacing: -0.01em; -} - -/* Personal info (account page) */ -.profile-summary { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 12px; - margin-bottom: 12px; -} - -.profile-summary-avatar { - width: 70px; - height: 70px; - border-radius: 50%; - border: 1px solid var(--border); - background: var(--card); - color: var(--text); - display: flex; - align-items: center; - justify-content: center; - font-size: 28px; - font-weight: 600; - overflow: hidden; -} - -.profile-summary-avatar.has-gradient { - color: #fff; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - border: none; -} - -.profile-summary-avatar img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.profile-error { - font-size: 12px; - color: #dc2626; - background: rgba(220, 38, 38, 0.08); - border-radius: var(--radius-sm); - padding: 8px 10px; - margin-bottom: 14px; -} - -.profile-summary-name { - font-size: 24px; - font-weight: 600; - color: var(--text); -} - -.profile-summary-subtitle { - font-size: 14px; - color: var(--muted); -} - -.profile-field-list { - display: flex; - flex-direction: column; -} - -.profile-field-row { - display: flex; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid var(--border); -} - -.profile-field-row-label { - font-size: 14px; - font-weight: 500; - color: var(--muted); - width: 120px; - flex-shrink: 0; - line-height: 28px; -} - -.profile-field-row-display { - flex: 1; - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.profile-field-row-value { - flex: 1; - font-size: 14px; - color: var(--text); - line-height: 28px; - word-break: break-word; - text-align: left; -} - -.profile-field-row-value.placeholder { - font-style: italic; - opacity: 0.7; -} - -.profile-field-edit-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - flex-shrink: 0; - border: none; - background: transparent; - color: var(--muted); - border-radius: var(--radius-sm); - cursor: pointer; - opacity: 0.7; -} - -.profile-field-edit-btn:hover { - opacity: 1; - color: var(--blue); -} - -.profile-field-row-input { - flex: 1; - padding: 4px 8px; - font-size: 14px; - font-family: inherit; - color: var(--text); - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - min-width: 0; -} - -.profile-field-row-input:focus { - outline: none; - border-color: var(--blue); -} - -.profile-field-row-actions { - display: flex; - gap: 6px; -} - -.profile-field-btn { - display: inline-flex; - align-items: center; - justify-content: center; - height: 28px; - padding: 0 12px; - border: none; - border-radius: var(--radius-sm); - font-size: 12px; - font-weight: 600; - font-family: inherit; - cursor: pointer; - white-space: nowrap; - transition: opacity 0.15s ease; -} - -.profile-field-btn--save { - background: var(--blue); - color: #ffffff; -} - -.profile-field-btn--save:hover:not(:disabled) { - opacity: 0.9; -} - -.profile-field-btn--save:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.profile-field-btn--cancel { - background: var(--secondary); - color: #ffffff; -} - -.profile-field-btn--cancel:hover { - opacity: 0.9; -} - -/* Account page (Home / Personal info / Security, left-nav layout) */ -.account-page { - display: flex; - align-items: flex-start; - min-height: calc(100vh - 52px); -} - -.account-sidebar { - width: 280px; - flex-shrink: 0; - padding: 40px 24px; - position: sticky; - top: 52px; -} - -.account-sidebar-title { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--text); - margin: 0 0 24px 16px; -} - -.account-nav { - display: flex; - flex-direction: column; - gap: 2px; -} - -.account-nav-item { - display: flex; - align-items: center; - gap: 14px; - width: 100%; - padding: 11px 16px; - border: none; - border-radius: 20px; - background: transparent; - color: var(--muted); - font-size: 14px; - font-weight: 500; - font-family: inherit; - text-align: left; - cursor: pointer; - white-space: nowrap; -} - -.account-nav-item:hover { - background: var(--border); - color: var(--text); -} - -.account-nav-item--active, -.account-nav-item--active:hover { - background: var(--blue-subtle); - color: var(--blue); -} - -.account-content { - flex: 1; - min-width: 0; - display: flex; - justify-content: center; - padding: 40px 32px 80px; - border-left: 1px solid var(--border); -} - -.account-content-inner { - width: 100%; - max-width: 720px; -} - -.account-content-title { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--text); - margin: 0 0 6px; -} - -.account-content-subtitle { - font-size: 14px; - color: var(--muted); - margin: 0 0 32px; -} - -.account-tiles { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 8px; -} - -.account-tile { - text-align: left; - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 22px; - cursor: pointer; - font-family: inherit; - display: flex; - flex-direction: column; -} - -.account-tile:hover { - border-color: var(--blue); -} - -.account-tile-icon { - width: 36px; - height: 36px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; -} - -.account-tile-icon--blue { - background: var(--blue-subtle); - color: var(--blue); -} - -.account-tile-icon--green { - background: rgba(16, 185, 129, 0.12); - color: #10b981; -} - -.account-tile-title { - font-size: 15px; - font-weight: 600; - color: var(--text); - margin-bottom: 4px; -} - -.account-tile-desc { - font-size: 12.5px; - color: var(--muted); - line-height: 1.5; -} - -/* Generic bordered content box, matching the Home tiles and Security cards. */ -.account-box { - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 24px; -} - -/* Security tab: one credential per row, collapsed into an expandable form */ -.account-security-list { - display: flex; - flex-direction: column; - gap: 16px; -} - -.account-security-card { - background: var(--card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px 22px; -} - -.account-security-card-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.account-security-card-title { - font-size: 14px; - font-weight: 600; - color: var(--text); - margin-bottom: 2px; -} - -.account-security-card-desc { - font-size: 12.5px; - color: var(--muted); -} - -.account-security-card-cta { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - padding: 0; - background: none; - border: none; - color: var(--blue); - font-size: 13px; - font-weight: 500; - font-family: inherit; - cursor: pointer; -} - -.account-security-card-cta:hover { - opacity: 0.8; -} - -.account-security-card-chevron { - display: inline-flex; - transition: transform 0.2s; -} - -.account-security-card-chevron--open { - transform: rotate(180deg); -} - -.account-security-card-form { - margin-top: 18px; - padding-top: 18px; - border-top: 1px solid var(--border); -} - -.account-security-card-error { - font-size: 12px; - color: #dc2626; - background: rgba(220, 38, 38, 0.08); - border-radius: var(--radius-sm); - padding: 8px 10px; - margin-bottom: 14px; -} - -.account-security-card-success { - font-size: 12px; - color: #059669; - background: rgba(5, 150, 105, 0.08); - border-radius: var(--radius-sm); - padding: 8px 10px; - margin-bottom: 14px; -} - -/* Change-credential form fields */ -.cred-field { - margin-bottom: 14px; -} - -.cred-label { - display: block; - font-size: 13px; - font-weight: 500; - color: var(--text); - margin-bottom: 6px; -} - -.cred-input-wrap { - position: relative; -} - -.cred-input { - width: 100%; - padding: 9px 38px 9px 12px; - font-size: 14px; - font-family: inherit; - color: var(--text); - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius-sm); -} - -.cred-input:focus { - outline: none; - border-color: var(--blue); -} - -.cred-eye-btn { - position: absolute; - top: 50%; - right: 8px; - transform: translateY(-50%); - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - background: transparent; - color: var(--muted); - cursor: pointer; -} - -.cred-requirements { - margin: -6px 0 14px; -} - -.cred-requirements-heading { - font-size: 12px; - color: var(--muted); - margin-bottom: 6px; -} - -.cred-requirements-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 4px; -} - -.cred-requirements-list li { - display: flex; - align-items: center; - gap: 8px; - font-size: 12.5px; - color: var(--muted); -} - -.cred-requirements-list li.passed { - color: #059669; -} - -.cred-requirements-list li svg { - flex-shrink: 0; -} - -.cred-form-actions { - display: flex; - justify-content: flex-end; -} - -.cred-unavailable { - position: relative; -} - -.cred-unavailable-content { - filter: blur(3px); - opacity: 0.55; - pointer-events: none; - user-select: none; -} - -.cred-unavailable-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - gap: 4px; - padding: 16px; - background: rgba(217, 119, 6, 0.08); - border: 1px solid rgba(217, 119, 6, 0.3); - border-radius: var(--radius-sm); - color: #d97706; - font-size: 13px; -} - -.cred-unavailable-overlay strong { - font-size: 13.5px; -} diff --git a/samples/browser/quickstart/vite.config.js b/samples/browser/quickstart/vite.config.js deleted file mode 100644 index 0515e7ae..00000000 --- a/samples/browser/quickstart/vite.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' - -export default defineConfig({ - build: { - target: 'esnext', - }, -}) diff --git a/samples/express/quickstart/.env.example b/samples/express/quickstart/.env.example deleted file mode 100644 index 968abea4..00000000 --- a/samples/express/quickstart/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Base URL of your ThunderID deployment. ex: https://localhost:8090 -THUNDERID_BASE_URL=https://localhost:8090 - -# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview. -THUNDERID_CLIENT_ID=your-client-id-here -# OAuth2 Client Secret for this app. Shown once when the application is created; -# if lost, regenerate it from the app's Credentials tab (under Edit). Server-only, never expose to the browser. -THUNDERID_CLIENT_SECRET=your-client-secret-here - -# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. -NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/express/quickstart/.stackblitzrc b/samples/express/quickstart/.stackblitzrc deleted file mode 100644 index 5f0a2000..00000000 --- a/samples/express/quickstart/.stackblitzrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "installDependencies": false, - "startCommand": "npm run prepare-dev && npm install && npm run dev" -} diff --git a/samples/express/quickstart/README.md b/samples/express/quickstart/README.md deleted file mode 100644 index 14055ff5..00000000 --- a/samples/express/quickstart/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# ThunderID Express Quickstart - -Open in StackBlitz - -A minimal Express.js **API** protected by ThunderID access tokens, using the ThunderID JavaScript -SDK (`@thunderid/express`). Unlike the browser-focused quickstarts, this sample doesn't have a -sign-in button β€” it's a resource server. Open [http://localhost:3000](http://localhost:3000) and -you'll get API documentation (endpoints, curl examples, a downloadable Postman collection) instead -of a login screen. - -Protected routes validate the `Authorization: Bearer ` header against ThunderID's OIDC -`/oauth2/userinfo` endpoint β€” the same check any OAuth 2.0 resource server performs. A `/login` β†’ -`/token` helper is included purely so you have a fast way to mint a demo access token to test with. - -## Prerequisites - -- Node.js 18+ -- A running ThunderID instance (default: `https://localhost:8090`) -- A configured application with `http://localhost:3000/login` added as an authorized redirect URI - -## Getting started - -1. Copy the environment file and fill in your values: - ```sh - cp .env.example .env - ``` - -2. Edit `.env` with your application's credentials: - ``` - THUNDERID_CLIENT_ID= - THUNDERID_CLIENT_SECRET= - THUNDERID_BASE_URL=https://localhost:8090 - ``` - -3. Install dependencies and start the server: - ```sh - pnpm install - pnpm start - ``` - -Open [http://localhost:3000](http://localhost:3000) for the API documentation page. - -## Trying the API - -- `GET /api/public` β€” no auth required. -- `GET /api/protected`, `GET /api/me`, and `PATCH /api/me/credentials` β€” require `Authorization: Bearer `. - -To get a token to test with, open [http://localhost:3000/login](http://localhost:3000/login), -sign in once, and your access token (decoded claims included) will be shown at -[http://localhost:3000/token](http://localhost:3000/token). Then: - -```sh -curl http://localhost:3000/api/me \ - -H "Authorization: Bearer $ACCESS_TOKEN" -``` - -Prefer a GUI? Download the Postman collection from the API docs page (or directly at -`/postman-collection.json`), paste your token into the `accessToken` collection variable, and run -the requests from there. - -## Back-channel logout - -The demo session behind `/login` and `/token` can be ended by ThunderID itself. When the user's -ThunderID session ends somewhere else, for example by signing out of another application, -ThunderID posts a logout token to this app, and the SDK ends the local session. The route is one -line in `index.mjs`: - -```js -app.post('/backchannel-logout', handleBackchannelLogout()); -``` - -To try it: - -1. In the ThunderID Console, open this application, go to **Advanced Settings β†’ OAuth2 - Configuration**, and set **Back-Channel Logout URI** to - `http://localhost:3000/backchannel-logout`. -2. ThunderID refuses localhost and private network addresses by default. For local testing only, - turn that off in the server's `deployment.yaml` and restart it: - ```yaml - oauth: - logout: - backchannel: - reject_private_addresses: false - ``` -3. Sign in at [http://localhost:3000/login](http://localhost:3000/login), then sign in to a second - application in the same browser, so both share one ThunderID session. -4. Sign out of the second application. Open [http://localhost:3000/token](http://localhost:3000/token): - you are sent back to sign-in, because this app's session was ended too. - -The Bearer-token API routes (`/api/*`) are not affected: they check each access token with -ThunderID on every request and hold no session. - -The SDK keeps sessions in memory by default. If you run more than one instance of an app, give the -SDK a shared store, since ThunderID's request reaches only one of them. - -## Learn more - -- [ThunderID Docs](https://thunderid.dev/docs) -- [`@thunderid/express` SDK reference](https://thunderid.dev/docs/sdks/javascript/express) diff --git a/samples/express/quickstart/index.mjs b/samples/express/quickstart/index.mjs deleted file mode 100644 index 2b2c1b3a..00000000 --- a/samples/express/quickstart/index.mjs +++ /dev/null @@ -1,406 +0,0 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -import express from 'express'; -import cookieParser from 'cookie-parser'; -import { - thunderID, - handleBackchannelLogout, - handleSignIn, - handleSignOut, - protect, - updateMeCredentials, -} from '@thunderid/express'; -import {verifyBearerToken} from './lib/auth.mjs'; -import {layout, esc, escAttr, COPY_ICON} from './lib/layout.mjs'; -import {thunderMark} from './lib/thunderMark.mjs'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const app = express(); -const port = 3000; -const baseUrl = process.env.THUNDERID_BASE_URL || 'https://localhost:8090'; - -const REQUIRED_ENV_VARS = ['THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET']; -const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]); - -app.use(cookieParser()); -app.use(express.json()); -app.use(express.static(path.join(__dirname, 'public'))); - -// The thunderID() middleware is only used here to power the `/login` and -// `/token` convenience routes, which exist so you have a fast way to obtain -// a real access token to try against the API below. The API routes -// themselves (`/api/*`) don't rely on cookies or sessions at all β€” see -// lib/auth.mjs. -if (missingEnvVars.length === 0) { - app.use( - thunderID({ - baseUrl, - clientId: process.env.THUNDERID_CLIENT_ID, - clientSecret: process.env.THUNDERID_CLIENT_SECRET, - // afterSignInUrl doubles as the OAuth2 redirect_uri sent to ThunderID, so - // it must match the redirect URI registered for this app (`/login`, - // where handleSignIn() below actually exchanges the code). Where the - // user lands once sign-in completes is controlled separately via - // onSignIn. - afterSignInUrl: 'http://localhost:3000/login', - // Same story as afterSignInUrl: this is the post_logout_redirect_uri - // sent to ThunderID, so it must match handleSignOut()'s mount path - // (`/logout`) for the `?state=sign_out_success` callback below to fire. - afterSignOutUrl: 'http://localhost:3000/logout', - onSignIn: (res) => res.redirect('/'), - onSignOut: (res) => res.redirect('/'), - }), - ); -} - -const requireBearer = verifyBearerToken(baseUrl); - -async function getSession(req) { - const client = req.thunderIDAuth; - const sessionId = client && req.cookies?.[client.getSessionCookieName()]; - if (!client || !sessionId) return {signedIn: false, accessToken: null, user: null}; - const signedIn = (await client.isSignedIn(sessionId)) ?? false; - if (!signedIn) return {signedIn: false, accessToken: null, user: null}; - const [accessToken, user] = await Promise.all([client.getAccessToken(sessionId), client.getUser(sessionId)]); - return {signedIn, accessToken, user}; -} - -// ── Configuration notice ──────────────────────────────────────────────── - -function renderConfigNeeded() { - return layout({ - title: 'Configuration needed', - body: `
    -
    -
    ${thunderMark(40)}
    -
    Setup required
    -

    Configuration needed

    -

    This quickstart can't reach ThunderID yet. Follow the steps below, then - restart the server.

    - -
    -
    Step 1 · Set environment variables
    -
      - ${missingEnvVars.map((key) => `
    • ${esc(key)}
    • `).join('')} -
    -

    Copy .env.example to .env, fill in the values from - your ThunderID application, then run npm run dev again.

    -
    - -
    -
    Step 2 · Register redirect URIs
    -
    -

    Sign-in and sign-out are handled by this app's server, so no CORS - configuration is needed. In the ThunderID Console, open this application and go to - Advanced Settings → OAuth2 Configuration, then add the exact URIs below.

    -
    -
    -
    Authorized redirect URI
    - http://localhost:3000/login -
    -
    -
    Post-Logout Redirect URI
    - http://localhost:3000/logout -
    -
    -
    Back-Channel Logout URI (optional)
    - http://localhost:3000/backchannel-logout -
    -
    -
    -
    - -

    Need more info? Take a look at the - Express quickstart guide.

    -
    -
    `, - }); -} - -app.use((req, res, next) => { - if (missingEnvVars.length > 0 && req.path !== '/styles.css') { - return res.status(503).send(renderConfigNeeded()); - } - next(); -}); - -// ── API documentation / landing page ──────────────────────────────────── - -function curlBlock(lines) { - return `
    ${esc(lines.join('\n'))}
    `; -} - -/** Joins a multi-line, backslash-continued curl command into one pasteable line. */ -function toSingleLineCommand(lines) { - return lines - .map((line) => line.trim().replace(/\\$/, '').trim()) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function copyButton(text) { - return ``; -} - -function endpoint({method, path: routePath, summary, protectedRoute, curl, sample}) { - return ` -
    - - ${esc(method)} - ${esc(routePath)} - ${esc(summary)} - ${protectedRoute ? 'Protected' : 'Public'} - -
    -
    - Request - ${copyButton(toSingleLineCommand(curl))} -
    - ${curlBlock(curl)} -
    Sample response
    -
    ${esc(sample)}
    -
    -
    `; -} - -function bearerLine(accessToken) { - return accessToken ? `Bearer ${accessToken}` : 'Bearer $ACCESS_TOKEN'; -} - -app.get('/', async (req, res) => { - const {signedIn, accessToken, user} = await getSession(req); - - const hero = ` -
    -
    -
    ${thunderMark(40)}
    -
    Open source
    -

    Auth for Modern Apps and Agents

    -

    ThunderID gives you OAuth 2.0, PKCE, MFA, and JWT out of - the box. Clone the Quickstart and ship auth before lunch.

    -
    -
    -
    OAuth 2.0Authorization standard
    -
    < 5 minIntegration time
    -
    Apache 2.0License
    -
    -
    -
    `; - - const authCard = signedIn - ? `
    -

    You're signed in. Here's your access token β€” it's already substituted into the curl - examples below, so you can copy and run them as-is.

    -
    - - ${copyButton(accessToken || '')} -
    - ${esc(accessToken)} -

    See the full decoded token, expiry, and claims at /token.

    -
    ` - : `
    - -
    ${curlBlock(['Authorization: Bearer '])}
    -

    Sign in above for a demo token, or obtain one via OAuth 2.0 in your own app.

    -
    `; - - const body = `${hero} -
    - - ${authCard} - - - ${endpoint({ - method: 'GET', - path: '/api/public', - summary: 'No authentication required.', - protectedRoute: false, - curl: ['curl http://localhost:3000/api/public'], - sample: JSON.stringify({message: 'This endpoint is public. No token needed.'}, null, 2), - })} - ${endpoint({ - method: 'GET', - path: '/api/protected', - summary: 'Returns a sample resource for the authenticated caller.', - protectedRoute: true, - curl: [ - 'curl http://localhost:3000/api/protected \\', - ` -H "Authorization: ${bearerLine(accessToken)}"`, - ], - sample: JSON.stringify( - {message: 'You are authenticated.', subject: ''}, - null, - 2, - ), - })} - ${endpoint({ - method: 'GET', - path: '/api/me', - summary: "Proxies ThunderID's userinfo claims for the token's owner.", - protectedRoute: true, - curl: ['curl http://localhost:3000/api/me \\', ` -H "Authorization: ${bearerLine(accessToken)}"`], - sample: JSON.stringify({sub: '...', email: 'jane@example.com', given_name: 'Jane'}, null, 2), - })} - ${endpoint({ - method: 'PATCH', - path: '/api/me/credentials', - summary: 'Updates credentials (such as password) for the authenticated caller.', - protectedRoute: true, - curl: [ - 'curl -X PATCH http://localhost:3000/api/me/credentials \\', - ` -H "Authorization: ${bearerLine(accessToken)}" \\`, - ' -H "Content-Type: application/json" \\', - ' -d \'{"password": "n3wP@ssword!"}\'', - ], - sample: '204 No Content', - })} - - -
    -

    Prefer a GUI? Download the collection below β€” it ships with requests for every - endpoint above and a {{accessToken}} variable you can fill in from - /token.

    - -
    -
    - `; - res.send(layout({title: 'API docs', signedIn, user, body})); -}); - -// ── Demo token helper (browser-based, cookie session) ─────────────────── - -app.get('/login', handleSignIn()); -app.get('/logout', handleSignOut()); - -// ThunderID posts a logout token here when the session behind the demo cookie ends somewhere -// else, such as a sign-out from another application. The SDK validates the token and ends the -// local session, so `/token` sends you back to sign-in. Register this path as the application's -// Back-Channel Logout URI to use it (see the README). -app.post('/backchannel-logout', handleBackchannelLogout()); - -app.get('/token', protect((res) => res.redirect('/login')), async (req, res) => { - const {accessToken, user} = await getSession(req); - - const parts = accessToken ? accessToken.split('.') : []; - const decodePart = (part) => { - try { - return JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); - } catch { - return null; - } - }; - const header = parts[0] ? decodePart(parts[0]) : null; - const payload = parts[1] ? decodePart(parts[1]) : null; - const issuer = payload?.iss; - const audience = Array.isArray(payload?.aud) ? payload.aud.join(', ') : payload?.aud; - const scopes = payload?.scope; - - const body = `
    -
    -
    -

    Token debug

    -

    Inspect your access token and decoded claims.

    -
    -
    Valid
    -
    - -
    -
    - - ${copyButton(accessToken || '')} -
    - ${esc(parts[0])}.${esc(parts[1])}.${esc(parts[2])} -
    - -
    -
    -
    JWT Header
    -
    ${esc(JSON.stringify(header, null, 2))}
    -
    -
    -
    JWT Payload
    -
    ${esc(JSON.stringify(payload, null, 2))}
    -
    -
    - - ${ - issuer || audience || scopes - ? `
    - ${issuer ? `
    Issuer
    ${esc(issuer)}
    ` : ''} - ${audience ? `
    Audience
    ${esc(audience)}
    ` : ''} - ${scopes ? `
    Scopes
    ${esc(scopes)}
    ` : ''} -
    ` - : '' - } - - -
    `; - res.send(layout({title: 'Token debug', signedIn: true, showBack: true, user, body})); -}); - -// ── API routes (bearer-token protected, no cookies involved) ──────────── - -app.get('/api/public', (_req, res) => { - res.json({message: 'This endpoint is public. No token needed.'}); -}); - -app.get('/api/protected', requireBearer, (req, res) => { - res.json({ - message: 'You are authenticated.', - subject: req.thunderIDUserInfo.sub ?? req.thunderIDUserInfo.id, - }); -}); - -app.get('/api/me', requireBearer, (req, res) => { - res.json(req.thunderIDUserInfo); -}); - -app.patch('/api/me/credentials', requireBearer, async (req, res) => { - try { - const authHeader = req.headers.authorization; - const token = authHeader?.split(' ')[1]; - await updateMeCredentials({ - baseUrl, - headers: {Authorization: `Bearer ${token}`}, - payload: req.body, - }); - res.status(204).end(); - } catch (error) { - res.status(error?.statusCode || 500).json({ - error: error?.code || 'CREDENTIAL_UPDATE_FAILED', - message: error?.message || 'Failed to update credentials', - }); - } -}); - -// ── Postman collection download ───────────────────────────────────────── - -app.get('/postman-collection.json', (_req, res) => { - res.download(path.join(__dirname, 'postman', 'ThunderID-Express-Quickstart.postman_collection.json')); -}); - -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); diff --git a/samples/express/quickstart/lib/auth.mjs b/samples/express/quickstart/lib/auth.mjs deleted file mode 100644 index 61be3f38..00000000 --- a/samples/express/quickstart/lib/auth.mjs +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Bearer-token protection for the API routes in this quickstart. - * - * This is a resource-server style check: it does not depend on a browser - * session or cookie. Any client (curl, Postman, another service) can call - * a protected endpoint by sending a ThunderID-issued access token in the - * `Authorization: Bearer ` header. The token is validated by asking - * ThunderID's OIDC userinfo endpoint whether it's still valid β€” the same - * endpoint any OAuth 2.0 resource server would call. - */ -export function verifyBearerToken(baseUrl) { - return async function bearerAuth(req, res, next) { - const header = req.headers.authorization || ''; - const [scheme, token] = header.split(' '); - - if (scheme !== 'Bearer' || !token) { - res.set('WWW-Authenticate', 'Bearer realm="thunderid-express-quickstart"'); - return res.status(401).json({ - error: 'unauthorized', - message: 'Missing or malformed Authorization header. Expected: Authorization: Bearer ', - }); - } - - let response; - try { - response = await fetch(`${baseUrl}/oauth2/userinfo`, { - headers: {Authorization: `Bearer ${token}`}, - }); - } catch { - return res.status(502).json({ - error: 'bad_gateway', - message: 'Could not reach ThunderID to validate the access token.', - }); - } - - if (!response.ok) { - res.set('WWW-Authenticate', 'Bearer error="invalid_token"'); - return res.status(401).json({ - error: 'invalid_token', - message: 'The access token is invalid or expired. Get a fresh one at /token.', - }); - } - - req.thunderIDUserInfo = await response.json(); - next(); - }; -} diff --git a/samples/express/quickstart/lib/expressLogo.mjs b/samples/express/quickstart/lib/expressLogo.mjs deleted file mode 100644 index 733e4bcf..00000000 --- a/samples/express/quickstart/lib/expressLogo.mjs +++ /dev/null @@ -1,10 +0,0 @@ -export function expressLogo(size = 24) { - return ` - - - - - - -`; -} diff --git a/samples/express/quickstart/lib/layout.mjs b/samples/express/quickstart/lib/layout.mjs deleted file mode 100644 index 9b4f97d5..00000000 --- a/samples/express/quickstart/lib/layout.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import {expressLogo} from './expressLogo.mjs'; - -export function esc(value) { - if (value === null || value === undefined) return ''; - return String(value) - .replace(/&/g, '&') - .replace(//g, '>'); -} - -/** Safe for embedding inside a double-quoted HTML attribute. */ -export function escAttr(value) { - return esc(value).replace(/"/g, '"'); -} - -const MOON_ICON = ``; -const SUN_ICON = ``; -const CHEVRON_LEFT_ICON = ``; -const KEY_ICON = ``; -const SIGN_OUT_ICON = ``; -export const COPY_ICON = ``; - -function userMenu(user) { - const displayName = user?.name || [user?.given_name, user?.family_name].filter(Boolean).join(' ') || user?.username || user?.email || 'Account'; - const initial = displayName.charAt(0).toUpperCase(); - - return `
    - - ${esc(initial)} - ${esc(displayName)} - -
    -
    - ${esc(initial)} - ${esc(displayName)} -
    - ${KEY_ICON}Token debug - ${SIGN_OUT_ICON}Sign out -
    -
    `; -} - -function nav({signedIn, showBack, user}) { - const authAction = signedIn ? userMenu(user) : `Sign in`; - - return ``; -} - -/** - * Wraps page content in the shared ThunderID quickstart shell. Uses the same - * nav/hero/button classnames and design tokens as the React and Next.js - * quickstarts so the sample apps read as one family. - */ -export function layout({title, body, signedIn = false, showBack = false, user = null}) { - return ` - - - - -${esc(title)} Β· ThunderID Express Quickstart - - - - -
    - ${nav({signedIn, showBack, user})} - ${body} -
    - - -`; -} diff --git a/samples/express/quickstart/lib/thunderMark.mjs b/samples/express/quickstart/lib/thunderMark.mjs deleted file mode 100644 index 32c210d2..00000000 --- a/samples/express/quickstart/lib/thunderMark.mjs +++ /dev/null @@ -1,8 +0,0 @@ -export function thunderMark(height = 40) { - const width = Math.round((height / 257) * 207); - return ` - - - -`; -} diff --git a/samples/express/quickstart/package.json b/samples/express/quickstart/package.json deleted file mode 100644 index d903c381..00000000 --- a/samples/express/quickstart/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@thunderid/express-quickstart", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "prepare-dev": "node scripts/prepare-dev.cjs", - "start": "node --env-file-if-exists=.env index.mjs", - "dev": "node --env-file-if-exists=.env --watch index.mjs" - }, - "dependencies": { - "@thunderid/express": "workspace:*", - "cookie-parser": "^1.4.7", - "express": "^4.21.2" - } -} diff --git a/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json b/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json deleted file mode 100644 index d2acf645..00000000 --- a/samples/express/quickstart/postman/ThunderID-Express-Quickstart.postman_collection.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "info": { - "name": "ThunderID Express Quickstart", - "description": "Sample requests for the ThunderID Express API quickstart. Public endpoints work as-is; protected endpoints need an `accessToken` collection variable β€” grab one from http://localhost:3000/token after signing in through the sample app, or run the OAuth 2.0 flow yourself against your ThunderID instance.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - {"key": "baseUrl", "value": "http://localhost:3000"}, - {"key": "thunderIdBaseUrl", "value": "https://localhost:8090"}, - {"key": "accessToken", "value": ""} - ], - "item": [ - { - "name": "Public endpoint", - "request": { - "method": "GET", - "header": [], - "url": {"raw": "{{baseUrl}}/api/public", "host": ["{{baseUrl}}"], "path": ["api", "public"]} - } - }, - { - "name": "Protected resource", - "request": { - "method": "GET", - "header": [{"key": "Authorization", "value": "Bearer {{accessToken}}"}], - "url": {"raw": "{{baseUrl}}/api/protected", "host": ["{{baseUrl}}"], "path": ["api", "protected"]} - } - }, - { - "name": "Current user (userinfo)", - "request": { - "method": "GET", - "header": [{"key": "Authorization", "value": "Bearer {{accessToken}}"}], - "url": {"raw": "{{baseUrl}}/api/me", "host": ["{{baseUrl}}"], "path": ["api", "me"]} - } - }, - { - "name": "Update credentials", - "request": { - "method": "PATCH", - "header": [ - {"key": "Authorization", "value": "Bearer {{accessToken}}"}, - {"key": "Content-Type", "value": "application/json"} - ], - "body": { - "mode": "raw", - "raw": "{\n \"password\": \"n3wP@ssword!\"\n}" - }, - "url": {"raw": "{{baseUrl}}/api/me/credentials", "host": ["{{baseUrl}}"], "path": ["api", "me", "credentials"]} - } - }, - { - "name": "ThunderID userinfo (direct)", - "request": { - "method": "GET", - "header": [{"key": "Authorization", "value": "Bearer {{accessToken}}"}], - "url": {"raw": "{{thunderIdBaseUrl}}/oauth2/userinfo", "host": ["{{thunderIdBaseUrl}}"], "path": ["oauth2", "userinfo"]}, - "description": "Calls ThunderID directly, bypassing the sample app, to show what /api/me is proxying." - } - } - ] -} diff --git a/samples/express/quickstart/public/styles.css b/samples/express/quickstart/public/styles.css deleted file mode 100644 index 26fffc19..00000000 --- a/samples/express/quickstart/public/styles.css +++ /dev/null @@ -1,991 +0,0 @@ -/* ── Reset ── */ -*, -::before, -::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -/* ── Design tokens (shared with the ThunderID React / Next.js quickstarts) ── */ -:root { - --color-bg: #f7f9fc; - --color-card: #ffffff; - --color-border: #dde3ec; - --color-text: #05213f; - --color-text-muted: #5a7085; - --color-muted: #5a7085; - --color-primary: #3688ff; - --color-primary-hover: #1f72f0; - --color-nav-bg: rgba(247, 249, 252, 0.92); - --color-nav-border: #dde3ec; - --color-code-bg: #0b1120; - --nav-height: 52px; - --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06), 0 4px 12px 0 rgba(5, 33, 63, 0.08); - --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; - --radius-card: 12px; - --radius-btn: 8px; - --transition: 0.2s ease; -} - -.dark, -html.dark { - --color-bg: #080f1c; - --color-card: #0f1a2d; - --color-border: rgba(255, 255, 255, 0.09); - --color-text: #e0eaff; - --color-text-muted: rgba(224, 234, 255, 0.48); - --color-muted: rgba(224, 234, 255, 0.48); - --color-nav-bg: rgba(8, 15, 28, 0.92); -} - -html, -body { - height: 100%; -} - -body { - font-family: var(--font-sans); - background: var(--color-bg); - color: var(--color-text); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - line-height: 1.5; -} - -a { - color: var(--color-primary); -} - -.app { - min-height: 100vh; - background: var(--color-bg); - color: var(--color-text); - transition: background var(--transition), color var(--transition); -} - -/* ── Nav ── */ -.nav { - position: fixed; - top: 0; - left: 0; - right: 0; - height: var(--nav-height); - display: flex; - align-items: center; - padding: 0 28px; - background: var(--color-nav-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid var(--color-nav-border); - z-index: 100; - gap: 12px; -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - text-decoration: none; - color: inherit; -} - -.wordmark-name { - font-size: 14px; - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.1; - color: var(--color-text); -} - -.nav-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.nav-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 0 10px; - border-radius: 6px; - color: var(--color-muted); - font-size: 13px; - text-decoration: none; -} - -.nav-back-btn:hover { - background: var(--color-border); - color: var(--color-text); -} - -/* ── Dark-mode toggle ── */ -.dark-toggle { - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border-radius: 8px; - border: 1px solid var(--color-border); - background: transparent; - color: var(--color-muted); - cursor: pointer; - flex-shrink: 0; - transition: border-color var(--transition), color var(--transition), background var(--transition); -} - -.dark-toggle:hover { - background: var(--color-border); - color: var(--color-text); -} - -.dark-toggle .icon-sun { - display: none; -} - -.dark .dark-toggle .icon-moon { - display: none; -} - -.dark .dark-toggle .icon-sun { - display: flex; -} - -/* ── Buttons ── */ -button { - font-family: var(--font-sans); -} - -.btn-primary, -.btn-outline, -.btn-ghost { - display: inline-flex; - align-items: center; - justify-content: center; - height: 46px; - padding: 0 24px; - border-radius: var(--radius-btn); - font-size: 15px; - font-weight: 600; - cursor: pointer; - border: none; - transition: opacity var(--transition), background var(--transition), border-color var(--transition); - white-space: nowrap; - text-decoration: none; - font-family: inherit; -} - -.btn-primary { - background: var(--color-primary); - color: #fff; - border: 1.5px solid var(--color-primary); -} - -.btn-primary:hover { - opacity: 0.88; -} - -.btn-outline { - background: transparent; - color: var(--color-primary); - border: 1.5px solid rgba(54, 136, 255, 0.4); -} - -.btn-outline:hover { - background: rgba(54, 136, 255, 0.08); -} - -.btn-ghost { - background: transparent; - color: var(--color-muted); -} - -.btn-ghost:hover { - background: rgba(0, 0, 0, 0.04); -} - -.nav .btn-primary, -.nav .btn-outline, -.nav .btn-ghost { - height: 36px; - padding: 0 16px; - font-size: 14px; -} - -/* ── User menu (signed-in nav) ── */ -.user-menu { - position: relative; -} - -.user-menu-trigger { - display: flex; - align-items: center; - gap: 8px; - height: 36px; - padding: 0 12px 0 6px; - border-radius: 999px; - background: transparent; - border: none; - cursor: pointer; - list-style: none; - color: var(--color-text); - font-size: 13px; - font-weight: 600; -} - -.user-menu-trigger:hover { - background: var(--color-border); -} - -.user-menu-trigger::-webkit-details-marker { - display: none; -} - -.user-menu-trigger:focus, -.user-menu-trigger:focus-visible { - outline: none; -} - -.user-avatar-sm { - width: 26px; - height: 26px; - border-radius: 50%; - background: var(--color-primary); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - flex-shrink: 0; -} - -.user-avatar-lg { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--color-primary); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - font-weight: 700; - flex-shrink: 0; -} - -.user-menu-list { - position: absolute; - top: calc(100% + 8px); - right: 0; - min-width: 220px; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - overflow: hidden; - z-index: 200; -} - -.user-menu-header { - display: flex; - align-items: center; - gap: 12px; - padding: 16px; -} - -.user-menu-header-name { - font-size: 14px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.02em; -} - -.user-menu-item { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - font-size: 13.5px; - font-weight: 600; - color: var(--color-text); - text-decoration: none; - border-top: 1px solid var(--color-border); -} - -.user-menu-item:hover { - background: var(--color-bg); -} - -/* ── Hero ── */ -.hero { - margin-top: var(--nav-height); - padding: 88px 32px 56px; -} - -.hero-inner { - width: 100%; - max-width: 560px; - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.hero-mark { - display: flex; - justify-content: center; - margin-bottom: 32px; - color: var(--color-primary); -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-muted); - margin-bottom: 28px; -} - -.hero-badge-line { - display: block; - height: 1px; - width: 24px; - background: var(--color-border); - flex-shrink: 0; -} - -.config-badge { - color: #e88b3a; -} - -.hero-title { - font-size: 42px; - font-weight: 700; - letter-spacing: -0.05em; - line-height: 1.1; - color: var(--color-text); - margin: 0 0 18px; -} - -.hero-subtitle { - font-size: 16px; - color: var(--color-muted); - max-width: 440px; - line-height: 1.8; - letter-spacing: -0.01em; - margin: 0 auto 36px; -} - -.hero-divider { - width: 100%; - border: none; - border-top: 1px solid var(--color-border); - margin: 4px 0; -} - -.hero-stats { - width: 100%; - display: grid; - grid-template-columns: repeat(3, 1fr); -} - -.hero-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 16px 8px; - position: relative; -} - -.hero-stat + .hero-stat::before { - content: ''; - position: absolute; - left: 0; - top: 20%; - height: 60%; - width: 1px; - background: var(--color-border); -} - -.hero-stat-value { - font-size: 15px; - font-weight: 700; - color: var(--color-text); -} - -.hero-stat-label { - font-size: 12px; - color: var(--color-muted); -} - -/* ── Content shell (non-hero pages / sections below the hero) ── */ -.page { - width: 100%; - max-width: 780px; - margin: 0 auto; - margin-top: var(--nav-height); - padding: 40px 28px 96px; -} - -.docs-shell { - width: 100%; - max-width: 780px; - margin: 0 auto; - padding: 0 28px 96px; -} - -.eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-text-muted); - margin-bottom: 16px; -} - -.eyebrow-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #2fbd6b; - flex-shrink: 0; -} - -.page-title { - font-size: 28px; - font-weight: 700; - letter-spacing: -0.04em; - line-height: 1.15; - margin-bottom: 12px; -} - -.page-subtitle { - font-size: 15px; - color: var(--color-text-muted); - max-width: 560px; - line-height: 1.7; - margin-bottom: 32px; -} - -.section-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin: 40px 0 14px; -} - -.section-label:first-of-type { - margin-top: 0; -} - -/* ── Cards ── */ -.card { - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - padding: 24px; -} - -.card + .card { - margin-top: 14px; -} - -.card p { - font-size: 13.5px; - color: var(--color-text-muted); - line-height: 1.7; -} - -.card p + p { - margin-top: 10px; -} - -.card p code { - background: rgba(54, 136, 255, 0.1); - border-radius: 4px; - padding: 2px 6px; - font-family: var(--font-mono); - font-size: 12.5px; - color: var(--color-text); -} - -/* ── Endpoint list (swagger-like) ── */ -.endpoint { - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - background: var(--color-card); - overflow: hidden; -} - -.endpoint + .endpoint { - margin-top: 12px; -} - -.endpoint-summary { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 20px; - cursor: pointer; - list-style: none; -} - -.endpoint-summary::-webkit-details-marker { - display: none; -} - -.method-badge { - flex-shrink: 0; - font-family: var(--font-mono); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.03em; - padding: 4px 9px; - border-radius: 5px; - color: #fff; - background: #2fbd6b; -} - -.endpoint-path { - font-family: var(--font-mono); - font-size: 14px; - font-weight: 600; - color: var(--color-text); -} - -.endpoint-desc { - font-size: 13px; - color: var(--color-text-muted); - flex: 1; - min-width: 0; -} - -.tag { - flex-shrink: 0; - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 3px 9px; - border-radius: 999px; -} - -.tag-public { - color: #2fbd6b; - background: rgba(47, 189, 107, 0.12); -} - -.tag-protected { - color: #e88b3a; - background: rgba(232, 139, 58, 0.12); -} - -.endpoint-body { - padding: 0 20px 20px; - border-top: 1px solid var(--color-border); -} - -.endpoint-body-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-muted); - margin: 16px 0 8px; -} - -/* ── Code blocks ── */ -.code-block { - position: relative; - background: var(--color-code-bg); - border-radius: 8px; - padding: 14px 16px; - overflow-x: auto; -} - -.code-block pre, -.code-block code { - font-family: var(--font-mono); - font-size: 12.5px; - line-height: 1.7; - color: #c9d1d9; - background: none; - padding: 0; - white-space: pre; -} - -/* ── Config notice ── */ -.config-list { - text-align: left; - list-style: none; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - padding: 0 16px; - margin: 4px 0 24px; - width: 100%; - max-width: 420px; -} - -.config-list-item { - padding: 10px 0; - font-family: var(--font-mono); - font-size: 13px; -} - -.config-list-item:not(:last-child) { - border-bottom: 1px solid var(--color-border); -} - -.config-hint { - font-size: 14px; - color: var(--color-muted); - line-height: 1.7; - max-width: 420px; - margin: 0 auto; -} - -.config-hint code { - background: rgba(54, 136, 255, 0.08); - border-radius: 4px; - padding: 2px 6px; - font-family: var(--font-mono); - font-size: 13px; -} - -.config-step { - width: 100%; - max-width: 420px; - margin: 0 auto 28px; -} - -.config-step-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 10px; - text-align: left; -} - -.config-step .config-list, -.config-step .config-hint { - margin-left: 0; - margin-right: 0; -} - -.config-box { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 16px; -} - -.config-box-body { - font-size: 13px; - color: var(--color-muted); - line-height: 1.6; - margin-bottom: 14px; -} - -.config-value-group { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-value-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-muted); - margin-bottom: 4px; -} - -.config-value { - display: block; - overflow-x: auto; - white-space: nowrap; - font-family: var(--font-mono); - font-size: 12px; - color: var(--color-text); - background: rgba(54, 136, 255, 0.08); - border-radius: 4px; - padding: 8px 10px; -} - -.config-docs-note { - font-size: 13px; - color: var(--color-muted); - max-width: 420px; - margin: 0 auto; -} - -.config-docs-note a { - color: var(--color-primary); - font-weight: 600; -} - -/* ── Token debug page ── */ -.token-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 36px; - flex-wrap: wrap; -} - -.token-title { - margin: 0 0 5px; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.04em; - color: var(--color-text); -} - -.token-subtitle { - margin: 0; - font-size: 13px; - color: var(--color-muted); - letter-spacing: -0.01em; -} - -.token-badge { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 700; - letter-spacing: -0.01em; - flex-shrink: 0; - background: rgba(47, 189, 107, 0.08); - border: 1px solid rgba(47, 189, 107, 0.22); - color: #2fbd6b; -} - -.token-badge--expired { - background: rgba(220, 38, 38, 0.08); - border: 1px solid rgba(220, 38, 38, 0.22); - color: #dc2626; -} - -.token-badge-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; -} - -.token-raw-section { - margin-bottom: 20px; -} - -.token-raw-label-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.token-section-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); -} - -.token-copy-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 5px; - background: transparent; - border: 1px solid var(--color-border); - font-size: 11px; - font-weight: 600; - color: var(--color-muted); - cursor: pointer; - font-family: var(--font-sans); - letter-spacing: -0.01em; - transition: border-color 0.2s, color 0.2s; -} - -.token-copy-btn:hover { - border-color: var(--color-primary); - color: var(--color-primary); -} - -.token-raw { - display: block; - font-size: 11px; - font-family: var(--font-mono); - word-break: break-all; - line-height: 1.8; - color: #c9d1d9; - background: var(--color-code-bg); - padding: 14px 16px; - border-radius: 8px; - overflow-x: auto; -} - -.token-part--header { - color: #ff7b72; -} - -.token-part--payload { - color: #79c0ff; -} - -.token-part--signature { - color: #3fb950; -} - -.token-dot { - color: rgba(255, 255, 255, 0.2); -} - -.token-decoded-grid { - display: grid; - grid-template-columns: 1fr 1.7fr; - gap: 12px; - margin-bottom: 20px; -} - -.token-code-box { - background: var(--color-code-bg); - border-radius: 10px; - overflow: hidden; -} - -.token-code-box-header { - padding: 9px 14px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.token-code-box-header--header { - color: #ff7b72; - border-bottom: 1px solid rgba(255, 123, 114, 0.15); -} - -.token-code-box-header--payload { - color: #79c0ff; - border-bottom: 1px solid rgba(121, 192, 255, 0.15); -} - -.token-code-pre { - margin: 0; - padding: 14px; - font-size: 11.5px; - line-height: 1.8; - font-family: var(--font-mono); - color: #c9d1d9; - overflow-x: auto; - max-height: 220px; -} - -.token-meta-row { - display: flex; - gap: 32px; - padding-top: 20px; - border-top: 1px solid var(--color-border); - flex-wrap: wrap; -} - -.token-meta-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 5px; -} - -.token-meta-value { - font-size: 12px; - font-family: var(--font-mono); - color: var(--color-text); - letter-spacing: -0.01em; -} - -/* ── Profile table ── */ -.kv-table { - width: 100%; - border-collapse: collapse; -} - -.kv-table th, -.kv-table td { - padding: 12px 0; - text-align: left; - font-size: 13.5px; - border-bottom: 1px solid var(--color-border); -} - -.kv-table th { - width: 140px; - font-weight: 600; - color: var(--color-muted); - text-transform: uppercase; - font-size: 11px; - letter-spacing: 0.06em; -} - -.kv-table td { - font-family: var(--font-mono); - word-break: break-all; -} - -.footer-note { - margin-top: 48px; - padding-top: 24px; - border-top: 1px solid var(--color-border); - font-size: 12.5px; - color: var(--color-muted); - display: flex; - gap: 20px; - flex-wrap: wrap; -} - -@media (max-width: 640px) { - .token-decoded-grid { - grid-template-columns: 1fr; - } - - .endpoint-summary { - flex-wrap: wrap; - } - - .hero-title { - font-size: 32px; - } -} diff --git a/samples/express/quickstart/scripts/prepare-dev.cjs b/samples/express/quickstart/scripts/prepare-dev.cjs deleted file mode 100644 index 8e20fa43..00000000 --- a/samples/express/quickstart/scripts/prepare-dev.cjs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('node:fs'); -const path = require('node:path'); - -const root = path.join(__dirname, '..'); - -const envExample = path.join(root, '.env.example'); -const envTarget = path.join(root, '.env'); -if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) { - // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) - // so the copied .env still trips the app's missing-env-var check until real - // values are filled in. - const envContent = fs - .readFileSync(envExample, 'utf8') - .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); - fs.writeFileSync(envTarget, envContent); -} - -const pkgPath = path.join(root, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -for (const field of ['dependencies', 'devDependencies']) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, version] of Object.entries(deps)) { - if (version.startsWith('workspace:')) { - deps[name] = 'latest'; - } - } -} - -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/nextjs/quickstart/.env.example b/samples/nextjs/quickstart/.env.example deleted file mode 100644 index 66cdaddf..00000000 --- a/samples/nextjs/quickstart/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# Base URL of your ThunderID deployment. ex: https://localhost:8090 -NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090 - -# Flow Secret for this app. Sent in the `Flow-Secret` header to authenticate this app when the -# native (embedded) flow starts. Shown once when the application is created; if lost, regenerate it -# from the app's Credentials tab (under Edit). Server-only, never expose to the browser. -THUNDERID_FLOW_SECRET=your-flow-secret-here - -# Secret used to encrypt this app's session cookie. Generate locally, not from the console. -THUNDERID_SECRET=generate-with-openssl-rand-base64-32 - -# Application ID (spId) for this app. ThunderID Console -> your application -> Overview. -NEXT_PUBLIC_THUNDERID_APPLICATION_ID=your-application-id-here -# Local app route that renders the sign-in page. Not from the console. -NEXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin -# Local app route that renders the sign-up page. Not from the console. -NEXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup - -# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. -NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/nextjs/quickstart/.stackblitzrc b/samples/nextjs/quickstart/.stackblitzrc deleted file mode 100644 index 5f0a2000..00000000 --- a/samples/nextjs/quickstart/.stackblitzrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "installDependencies": false, - "startCommand": "npm run prepare-dev && npm install && npm run dev" -} diff --git a/samples/nextjs/quickstart/README.md b/samples/nextjs/quickstart/README.md deleted file mode 100644 index e8e46eed..00000000 --- a/samples/nextjs/quickstart/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# ThunderID Next.js Quickstart - -Open in StackBlitz - -A minimal Next.js 15 App Router application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT out of the box. - -## Prerequisites - -- Node.js 18+ -- pnpm -- A ThunderID application - -## Getting started - -1. Copy the example environment file: - - ```bash - cp .env.example .env - ``` - -2. Fill in your ThunderID credentials in `.env`. By default the file is set up for the native flow: - - ```dotenv - NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090 - NEXT_PUBLIC_THUNDERID_APPLICATION_ID= - THUNDERID_FLOW_SECRET= - NEXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin - NEXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup - THUNDERID_SECRET= - ``` - - The Flow Secret is generated by the server when the application is created, shown once, and only - retrievable afterward from the app's Credentials tab (under Edit). It's a separate credential from - the OAuth2 Client Secret used by the redirect-based flow below β€” an embedded app (no OAuth2 - configuration) has a Flow Secret and no client secret; a redirect-based `authorization_code` app has - a client secret and no Flow Secret. - -3. Start the development server: - - ```bash - pnpm dev - ``` - - The app is now running at [http://localhost:3000](http://localhost:3000). - -
    -

    Redirect-based flow

    - -By default this quickstart uses the native (embedded) flow, where sign-in/sign-up render inline on this -app's own `/signin` and `/signup` routes with no redirect to ThunderID's hosted pages. - -To send the user to ThunderID's hosted sign-in page instead, switch to the redirect-based flow: - -1. Register a redirect URI for your application (see the app's config notice in the console for the - exact value to use). -2. Regenerate `.env` for the redirect flow: - - ```bash - npm run prepare-dev:redirect - ``` - - This comments out the native-flow vars (`NEXT_PUBLIC_THUNDERID_APPLICATION_ID`, - `NEXT_PUBLIC_THUNDERID_SIGN_IN_URL`, `NEXT_PUBLIC_THUNDERID_SIGN_UP_URL`, `THUNDERID_FLOW_SECRET`) and - adds: - - ```dotenv - NEXT_PUBLIC_THUNDERID_CLIENT_ID= - THUNDERID_CLIENT_SECRET= - ``` - - Both values come from the application's Credentials tab in the console. To switch back to the native - flow, run `node scripts/prepare-dev.cjs --flow=native` (or manually re-enable the native-flow vars and - comment out the two above). -3. Fill in `NEXT_PUBLIC_THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`, then restart the dev - server. - -
    - -## Back-channel logout - -ThunderID can end this app's session itself. When the user's ThunderID session ends somewhere else, -for example by signing out of another application, ThunderID posts a logout token to this app. The -route is one line in `app/api/auth/backchannel-logout/route.ts`: - -```ts -export const {POST} = backchannelLogout() -``` - -The session lives in a cookie that only the browser can delete, so the SDK records the logout and -treats every later request with that session as signed out. - -To try it, use the redirect-based flow above, since the logout token is addressed to the client ID: - -1. In the ThunderID Console, open this application, go to **Advanced Settings β†’ OAuth2 - Configuration**, and set **Back-Channel Logout URI** to - `http://localhost:3000/api/auth/backchannel-logout`. -2. ThunderID refuses localhost and private network addresses by default. For local testing only, - turn that off in the server's `deployment.yaml` and restart it: - ```yaml - oauth: - logout: - backchannel: - reject_private_addresses: false - ``` -3. Sign in at [http://localhost:3000](http://localhost:3000), then sign in to a second application - in the same browser, so both share one ThunderID session. -4. Sign out of the second application, then reload this app: you are signed out here too. - -Things to know before production: - -- The SDK records ended sessions in memory by default. If you run more than one instance, pass a - shared `store` to both `backchannelLogout()` and `thunderIDProxy()`, since ThunderID's request - reaches only one instance. -- Route protection in `thunderIDProxy()` sees the in-memory record only when the proxy runs on the - Node.js runtime. On Next.js 15, add `runtime: 'nodejs'` to the `config` export of your - `middleware.ts`. On the Edge runtime, use a shared `store`. -- An access token already handed to the browser or to another API stays valid until it expires. diff --git a/samples/nextjs/quickstart/app/account/page.tsx b/samples/nextjs/quickstart/app/account/page.tsx deleted file mode 100644 index d0e17a40..00000000 --- a/samples/nextjs/quickstart/app/account/page.tsx +++ /dev/null @@ -1,212 +0,0 @@ -'use client' -import { ChangeCredential, SignedIn, SignedOut, User, UserProfile } from '@thunderid/nextjs' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' -import { useState } from 'react' -import Nav from '../components/Nav' - -function HomeIcon() { - return ( - - ) -} - -function PersonIcon() { - return ( - - ) -} - -function ShieldIcon() { - return ( - - ) -} - -const TABS = [ - { id: 'home', label: 'Home', icon: HomeIcon }, - { id: 'personal', label: 'Personal info', icon: PersonIcon }, - { id: 'security', label: 'Security', icon: ShieldIcon }, -] - -const CREDENTIALS = [ - { attribute: 'password', title: 'Password', description: 'Used to sign in to your account.', cta: 'Change password' }, -] - -function ChevronIcon() { - return ( - - - - ) -} - -function CredentialCard({ attribute, title, description, cta }: { attribute: string; title: string; description: string; cta: string }) { - const [open, setOpen] = useState(false) - const close = () => setOpen(false) - - return ( -
    -
    -
    -
    {title}
    -
    {description}
    -
    - -
    - {open && ( -
    - -
    - )} -
    - ) -} - -const TILES = [ - { - id: 'personal', - icon: PersonIcon, - tone: 'blue', - title: 'Personal info', - description: 'Name, email, phone, and profile photo', - }, - { - id: 'security', - icon: ShieldIcon, - tone: 'green', - title: 'Security', - description: 'Password and other credentials', - }, -] - -// Google Account-style layout: a persistent left nav switching between tabs of -// content on the right, rather than a single scrolling page or a modal. -function AccountTabs() { - const router = useRouter() - const searchParams = useSearchParams() - const requestedTab = searchParams.get('tab') - const [tab, setTab] = useState(TABS.some((t) => t.id === requestedTab) ? requestedTab! : 'home') - - const selectTab = (id: string) => { - setTab(id) - router.replace(id === 'home' ? '/account' : `/account?tab=${id}`) - } - - return ( -
    - - -
    -
    - {tab === 'home' && ( - - {(user: { givenName?: string; given_name?: string; displayName?: string; username?: string } | null) => { - const givenName = user?.givenName ?? user?.given_name ?? user?.displayName ?? user?.username ?? 'there' - - return ( - <> -

    Hi, {givenName}

    -

    - Manage your info and security across ThunderID apps. -

    -
    - {TILES.map(({ id, icon: Icon, tone, title, description }) => ( - - ))} -
    - - ) - }} -
    - )} - - {tab === 'personal' && ( - <> -

    Personal info

    -

    Manage the basic profile info others may see.

    - - - )} - - {tab === 'security' && ( - <> -

    Security

    -

    Manage your password and other credentials.

    -
    - {CREDENTIALS.map((credential) => ( - - ))} -
    - - )} -
    -
    -
    - ) -} - -export default function AccountPage() { - return ( -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/app/actions.ts b/samples/nextjs/quickstart/app/actions.ts deleted file mode 100644 index 6a72ed31..00000000 --- a/samples/nextjs/quickstart/app/actions.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use server' - -import { thunderid } from '@thunderid/nextjs/server' - -/** - * Fetch the raw access token JWT for the current session. - * Tokens are kept server-side β€” this Server Action is the only way a - * client component can retrieve them without exposing the session cookie. - */ -export async function fetchAccessToken(): Promise { - try { - const sdk = await thunderid() - const sessionId = await sdk.getSessionId() - if (!sessionId) return undefined - return await sdk.getAccessToken(sessionId) - } catch { - return undefined - } -} diff --git a/samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts b/samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts deleted file mode 100644 index c09cc0df..00000000 --- a/samples/nextjs/quickstart/app/api/auth/backchannel-logout/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {backchannelLogout} from '@thunderid/nextjs/server' - -// ThunderID posts a logout token here when the user's session ends somewhere else, such as a -// sign-out from another application. The SDK records the logout, and from then on this app treats -// the matching session cookie as signed out. Register this path as the application's -// Back-Channel Logout URI to use it (see the README). -export const {POST} = backchannelLogout() diff --git a/samples/nextjs/quickstart/app/components/ConfigNotice.tsx b/samples/nextjs/quickstart/app/components/ConfigNotice.tsx deleted file mode 100644 index 4d581cb2..00000000 --- a/samples/nextjs/quickstart/app/components/ConfigNotice.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client' -import { useState } from 'react' -import ThunderMark from './ThunderMark' -import NextLogo from './icons/NextLogo' - -function MoonIcon() { - return ( - - - - ) -} - -function SunIcon() { - return ( - - - - - - - - ) -} - -export default function ConfigNotice({ missing, isRedirectFlow }: { missing: string[]; isRedirectFlow: boolean }) { - const [dark, setDark] = useState(false) - - const toggle = () => { - const next = !dark - setDark(next) - document.documentElement.classList.toggle('dark', next) - } - - return ( -
    - - -
    -
    -
    - -
    - -
    - - Setup required - -
    - -

    Configuration needed

    - -

    - This quickstart can't reach ThunderID yet. Follow the steps - below, then restart the dev server. -

    - -
    -
    Step 1 · Set environment variables
    - -
      - {missing.map((key) => ( -
    • {key}
    • - ))} -
    - -

    - Copy .env.example to .env, fill in the - values from your ThunderID application, then run npm run dev again. -

    -
    - - {isRedirectFlow && ( -
    -
    Step 2 · Register redirect URIs
    - -
    -

    - Sign-in and sign-out are handled by this app's server, so - no CORS configuration is needed. In the{' '} - ThunderID Console, open this application and go - to Advanced Settings → OAuth2 Configuration, - then add the exact URIs below. -

    - -
    -
    -
    Authorized redirect URI
    - http://localhost:3000 -
    -
    -
    Post-Logout Redirect URI
    - http://localhost:3000 -
    -
    -
    -
    - )} - -

    - Need more info? Take a look at the{' '} - - Next.js quickstart guide. - -

    -
    -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/app/components/HeroCtas.tsx b/samples/nextjs/quickstart/app/components/HeroCtas.tsx deleted file mode 100644 index ea8e91b4..00000000 --- a/samples/nextjs/quickstart/app/components/HeroCtas.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client' -import { SignInButton } from '@thunderid/nextjs' - -export default function HeroCtas() { - return ( -
    - - {({ signIn, isLoading }) => ( - - )} - -
    - ) -} diff --git a/samples/nextjs/quickstart/app/components/Nav.tsx b/samples/nextjs/quickstart/app/components/Nav.tsx deleted file mode 100644 index e660f18e..00000000 --- a/samples/nextjs/quickstart/app/components/Nav.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client' -import { SignedIn, SignedOut, SignInButton, UserDropdown } from '@thunderid/nextjs' -import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' -import { useState } from 'react' -import NextLogo from './icons/NextLogo' - -function MoonIcon() { - return ( - - - - ) -} - -function SunIcon() { - return ( - - - - - - - - ) -} - -function ChevronLeftIcon() { - return ( - - ) -} - -function KeyIcon() { - return ( - - ) -} - -export default function Nav() { - const [dark, setDark] = useState(false) - const pathname = usePathname() - const router = useRouter() - const isHome = pathname === '/' - - const toggle = () => { - const next = !dark - setDark(next) - document.documentElement.classList.toggle('dark', next) - } - - const menuItems = [ - { - label: Token debug, - href: '/token', - }, - ] - - return ( - - ) -} diff --git a/samples/nextjs/quickstart/app/components/ThunderMark.tsx b/samples/nextjs/quickstart/app/components/ThunderMark.tsx deleted file mode 100644 index d8cf0ab0..00000000 --- a/samples/nextjs/quickstart/app/components/ThunderMark.tsx +++ /dev/null @@ -1,36 +0,0 @@ -export default function ThunderMark({ - height = 40, - className, - darkMode = false, -}: { - height?: number - className?: string - darkMode?: boolean -}) { - const width = Math.round((height / 257) * 207) - - return ( - - - - - - ) -} diff --git a/samples/nextjs/quickstart/app/components/icons/NextLogo.tsx b/samples/nextjs/quickstart/app/components/icons/NextLogo.tsx deleted file mode 100644 index b78a7665..00000000 --- a/samples/nextjs/quickstart/app/components/icons/NextLogo.tsx +++ /dev/null @@ -1,17 +0,0 @@ -export default function NextLogo({ size = 16, fill = 'currentColor' }: { size?: number; fill?: string }) { - return ( - - ) -} diff --git a/samples/nextjs/quickstart/app/globals.css b/samples/nextjs/quickstart/app/globals.css deleted file mode 100644 index 6972b479..00000000 --- a/samples/nextjs/quickstart/app/globals.css +++ /dev/null @@ -1,1521 +0,0 @@ -/* ── Reset ── */ -*, -::before, -::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -/* ── Design tokens ── */ -:root { - --color-bg: #f7f9fc; - --color-card: #ffffff; - --color-border: #dde3ec; - --color-text: #05213f; - --color-text-muted: #5a7085; - --color-muted: #5a7085; - --color-primary: #3688ff; - --color-primary-hover: #1f72f0; - --color-nav-bg: rgba(247, 249, 252, 0.92); - --color-nav-border: #dde3ec; - --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06), - 0 4px 12px 0 rgba(5, 33, 63, 0.08); - --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', - sans-serif; - --radius-card: 12px; - --radius-btn: 8px; -} - -/* ── Dark mode ── */ -.dark, -html.dark { - --color-bg: #080f1c; - --color-card: #0f1a2d; - --color-border: rgba(255, 255, 255, 0.09); - --color-text: #E0EAFF; - --color-text-muted: rgba(224, 234, 255, 0.48); - --color-muted: rgba(224, 234, 255, 0.48); - --color-nav-bg: rgba(8, 15, 28, 0.92); - - /* ThunderID Design Tokens for Dark Mode */ - --thunderid-color-background-surface: #0f1a2d; - --thunderid-color-background-body: #080f1c; - --thunderid-color-background-disabled: rgba(255, 255, 255, 0.06); - --thunderid-color-background-muted: rgba(255, 255, 255, 0.05); - --thunderid-color-text-primary: #E0EAFF; - --thunderid-color-text-secondary: rgba(224, 234, 255, 0.65); - --thunderid-color-border: rgba(255, 255, 255, 0.09); - --thunderid-color-border-focus: var(--color-primary); - --thunderid-input-borderColor: rgba(255, 255, 255, 0.16); - --thunderid-input-focusBorderColor: var(--color-primary); - --thunderid-input-focusRing: 0 0 0 3px rgba(54, 136, 255, 0.25); - --thunderid-card-borderColor: rgba(255, 255, 255, 0.09); - --thunderid-card-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - --thunderid-color-action-hover: rgba(255, 255, 255, 0.06); -} - -/* ── Base ── */ -html, -body { - height: 100%; -} - -body { - font-family: var(--font-sans); - background: var(--color-bg); - color: var(--color-text); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - line-height: 1.5; -} - -/* ── App shell ── */ -.app { - min-height: 100vh; - display: flex; - flex-direction: column; - background: var(--color-bg); - color: var(--color-text); - transition: background 0.2s, color 0.2s; -} - -/* ── Navigation ── */ -.nav { - position: fixed; - inset-block-start: 0; - inset-inline: 0; - height: 52px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 28px; - background: var(--color-nav-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid var(--color-nav-border); - z-index: 100; - transition: background 0.2s, border-color 0.2s; -} - -.nav-brand { - display: flex; - align-items: center; - gap: 10px; - text-decoration: none; - color: var(--color-text); -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - text-decoration: none; - color: var(--color-text); -} - -.nav-wordmark { - font-size: 15px; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--color-text); -} - -.wordmark { - display: flex; - flex-direction: column; - gap: 1px; -} - -.wordmark-name { - font-size: 14px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - line-height: 1; -} - -.wordmark-sub { - font-size: 8.5px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-primary); - line-height: 1; -} - -.nav-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.dark-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - padding: 0; - background: transparent; - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - color: var(--color-text-muted); - cursor: pointer; - transition: color 0.15s, background 0.15s, border-color 0.15s; - flex-shrink: 0; -} - -.dark-toggle:hover { - color: var(--color-text); - background: var(--color-border); -} - -/* ── Buttons ── */ -button { - font-family: var(--font-sans); - cursor: pointer; - border: none; - border-radius: var(--radius-btn); - font-weight: 600; - transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s; - line-height: 1; - white-space: nowrap; -} - -button:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} - -.btn-primary, -.btn-outline, -.btn-ghost { - display: inline-flex; - align-items: center; - justify-content: center; - height: 46px; - padding: 0 24px; - font-size: 15px; - text-decoration: none; -} - -.btn-primary { - background: var(--color-primary); - color: #ffffff; - border: 1.5px solid var(--color-primary); -} - -.btn-primary:hover { - opacity: 0.88; -} - -.btn-outline { - background: transparent; - color: var(--color-primary); - border: 1.5px solid rgba(54, 136, 255, 0.4); -} - -.btn-outline:hover { - background: rgba(54, 136, 255, 0.08); -} - -.btn-ghost { - background: transparent; - color: var(--color-muted); - border: none; -} - -.btn-ghost:hover { - color: var(--color-text); - background: rgba(0, 0, 0, 0.04); -} - -/* Nav-sized buttons */ -.nav .btn-primary, -.nav .btn-outline, -.nav .btn-ghost { - height: 36px; - padding: 0 16px; - font-size: 14px; -} - -/* ── Hero section ── */ -.hero { - flex: 1; - padding: 96px 32px 80px; - min-height: 100vh; -} - -.hero-inner { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 0; - max-width: 560px; - width: 100%; - margin: 0 auto; -} - -.hero-mark { - display: flex; - justify-content: center; - margin-bottom: 32px; -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-text-muted); - margin-bottom: 28px; -} - -.hero-badge-line { - display: block; - width: 24px; - height: 1px; - background: var(--color-border); - flex-shrink: 0; -} - -.hero-title { - font-size: 42px; - font-weight: 700; - letter-spacing: -0.05em; - color: var(--color-text); - line-height: 1.1; - margin: 0 0 18px; -} - -.hero-subtitle { - font-size: 16px; - color: var(--color-text-muted); - max-width: 420px; - line-height: 1.8; - letter-spacing: -0.01em; - margin: 0 auto 36px; -} - -.hero-ctas { - display: flex; - gap: 12px; - flex-wrap: wrap; - justify-content: center; - margin-bottom: 52px; -} - -.hero-divider { - width: 100%; - border: none; - border-top: 1px solid var(--color-border); - margin: 4px 0; -} - -/* ── Stats row ── */ -.hero-stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - width: 100%; -} - -.hero-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 16px 8px; - position: relative; -} - -.hero-stat + .hero-stat::before { - content: ''; - position: absolute; - left: 0; - top: 20%; - height: 60%; - width: 1px; - background: var(--color-border); -} - -.hero-stat-value { - font-size: 15px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.02em; -} - -.hero-stat-label { - font-size: 12px; - color: var(--color-text-muted); -} - -/* ── Config notice ── */ -.config-badge { - color: #e88b3a; -} - -.config-list { - width: 100%; - max-width: 420px; - text-align: left; - list-style: none; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - padding: 0 16px; - margin: 4px 0 28px; -} - -.config-list-item { - padding: 10px 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; - color: var(--color-text); -} - -.config-list-item:not(:last-child) { - border-bottom: 1px solid var(--color-border); -} - -.config-hint { - font-size: 14px; - color: var(--color-text-muted); - line-height: 1.7; - max-width: 420px; - margin: 0 auto; -} - -.config-hint code { - background: rgba(54, 136, 255, 0.1); - border-radius: 4px; - padding: 2px 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; -} - -.config-step { - width: 100%; - max-width: 420px; - margin: 0 auto 28px; -} - -.config-step-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-text-muted); - margin-bottom: 10px; - text-align: left; -} - -.config-step .config-list, -.config-step .config-hint { - margin-left: 0; - margin-right: 0; -} - -.config-box { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 16px; -} - -.config-box-body { - font-size: 13px; - color: var(--color-text-muted); - line-height: 1.6; - margin-bottom: 14px; -} - -.config-value-group { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-value-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); - margin-bottom: 4px; -} - -.config-value { - display: block; - overflow-x: auto; - white-space: nowrap; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: var(--color-text); - background: rgba(54, 136, 255, 0.1); - border-radius: 4px; - padding: 8px 10px; -} - -.config-docs-note { - font-size: 13px; - color: var(--color-text-muted); - max-width: 420px; - margin: 0 auto; -} - -.config-docs-note a { - color: var(--color-primary); - font-weight: 600; -} - -/* ── User / welcome card ── */ -.user-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 32px; - max-width: 480px; - width: 100%; - box-shadow: var(--shadow-card); -} - -.user-avatar { - width: 48px; - height: 48px; - border-radius: 50%; - background: var(--color-primary); - color: #ffffff; - display: flex; - align-items: center; - justify-content: center; - font-size: 17px; - font-weight: 700; - letter-spacing: 0.02em; - flex-shrink: 0; -} - -.user-welcome { - font-size: 1.375rem; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--color-text); - text-align: center; -} - -.user-details { - display: flex; - flex-direction: column; - gap: 0; - width: 100%; - border: 1px solid var(--color-border); - border-radius: 8px; - overflow: hidden; -} - -.user-detail-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 16px; - gap: 16px; -} - -.user-detail-row + .user-detail-row { - border-top: 1px solid var(--color-border); -} - -.user-detail-row dt { - font-size: 13px; - color: var(--color-text-muted); - font-weight: 500; - flex-shrink: 0; -} - -.user-detail-row dd { - font-size: 13px; - color: var(--color-text); - font-weight: 500; - text-align: right; - word-break: break-all; -} - -/* ── Signed-in home shell ── */ -.home-shell { - min-height: calc(100vh - 52px); - margin-top: 52px; -} - -/* ── Signed-in home page ── */ -.home-main { - width: 100%; - max-width: 900px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -/* Greeting */ -.home-greeting { - display: flex; - align-items: flex-start; - gap: 16px; - margin-bottom: 36px; -} - -.home-avatar { - flex-shrink: 0; -} - -.home-greeting-text { - min-width: 0; -} - -.home-greeting-name { - margin: 0 0 6px; - font-size: 26px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.04em; -} - -.home-greeting-meta { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: var(--color-muted); - letter-spacing: -0.01em; - flex-wrap: wrap; -} - -.home-dot { - display: inline-block; - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--color-border); - flex-shrink: 0; -} - -.home-session-active { - display: inline-flex; - align-items: center; - gap: 5px; - color: #2fbd6b; -} - -.home-session-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: #2fbd6b; - flex-shrink: 0; -} - -/* Session stats row */ -.home-stats { - display: flex; - gap: 0; - padding: 20px 0; - border-top: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - margin-bottom: 52px; -} - -.home-stat { - flex: 1; - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.home-stat--bordered { - padding-left: 32px; - border-left: 1px solid var(--color-border); -} - -.home-stat-icon { - width: 32px; - height: 32px; - border-radius: 8px; - background: rgba(54, 136, 255, 0.1); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: #3688ff; -} - -.home-stat-value { - font-size: 14px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.02em; -} - -.home-stat-value--mono { - font-family: monospace; - letter-spacing: 0.01em; -} - -.home-stat-label { - font-size: 11px; - font-weight: 600; - color: var(--color-muted); - text-transform: uppercase; - letter-spacing: 0.07em; - margin-top: 2px; -} - -/* What's next */ -.home-next-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 20px; -} - -.home-next-list { - display: flex; - flex-direction: column; -} - -.home-next-item { - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 16px; - align-items: start; - padding: 20px 0; - border-top: 1px solid var(--color-border); - text-decoration: none; - color: inherit; - cursor: pointer; - transition: background 0.2s; - border-radius: 4px; -} - -.home-next-item:hover .home-next-cta { - opacity: 1; -} - -.home-next-n { - font-size: 11px; - font-weight: 700; - color: #3688ff; - letter-spacing: 0.05em; - padding-top: 2px; - font-variant-numeric: tabular-nums; -} - -.home-next-body { - min-width: 0; -} - -.home-next-title { - font-size: 14px; - font-weight: 600; - color: var(--color-text); - letter-spacing: -0.02em; - margin-bottom: 4px; -} - -.home-next-desc { - font-size: 13px; - color: var(--color-muted); - line-height: 1.6; - letter-spacing: -0.01em; -} - -.home-next-cta { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 13px; - font-weight: 600; - color: #3688ff; - white-space: nowrap; - opacity: 0.7; - transition: opacity 0.2s; - padding-top: 2px; -} - -/* ── Nav back button ── */ -.nav-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 0 10px; - border-radius: 6px; - border: none; - background: transparent; - color: var(--color-muted); - font-size: 13px; - font-family: var(--font-sans); - letter-spacing: -0.01em; - cursor: pointer; - transition: background 0.2s, color 0.2s; -} - -.nav-back-btn:hover { - background: var(--color-border); - color: var(--color-text); -} - -/* ── Token debug page ── */ -.token-main { - width: 100%; - max-width: 780px; - margin: 0 auto; - margin-top: 52px; - padding: 52px 28px 80px; -} - -.token-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 36px; - flex-wrap: wrap; -} - -.token-title { - margin: 0 0 5px; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.04em; - color: var(--color-text); -} - -.token-subtitle { - margin: 0; - font-size: 13px; - color: var(--color-muted); - letter-spacing: -0.01em; -} - -/* ── Sign in / Sign up pages ── */ -.auth-main { - width: 100%; - min-height: calc(100vh - 52px); - margin-top: 52px; - display: flex; - align-items: center; - justify-content: center; - padding: 40px 20px; -} - -.auth-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - padding: 40px 32px; - max-width: 420px; - width: 100%; -} - -.auth-title { - font-size: 1.5rem; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--color-text); - text-align: center; -} - -.auth-subtitle { - margin: 0 0 16px; - font-size: 13px; - color: var(--color-text-muted); - text-align: center; -} - -.auth-switch { - margin-top: 20px; - font-size: 13px; - color: var(--color-text-muted); - text-align: center; -} - -.auth-switch a { - color: var(--color-primary); - font-weight: 600; - text-decoration: none; -} - -.auth-switch a:hover { - text-decoration: underline; -} - -.token-badge { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 700; - letter-spacing: -0.01em; - flex-shrink: 0; -} - -.token-badge--expired { - background: rgba(220, 38, 38, 0.08); - border: 1px solid rgba(220, 38, 38, 0.22); - color: #dc2626; -} - -.token-badge--valid { - background: rgba(47, 189, 107, 0.08); - border: 1px solid rgba(47, 189, 107, 0.22); - color: #2fbd6b; -} - -.token-badge-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; -} - -/* Raw token section */ -.token-raw-section { - margin-bottom: 20px; -} - -.token-raw-label-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.token-section-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); -} - -.token-copy-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 5px; - background: transparent; - border: 1px solid var(--color-border); - font-size: 11px; - font-weight: 600; - color: var(--color-muted); - cursor: pointer; - font-family: var(--font-sans); - letter-spacing: -0.01em; - transition: border-color 0.2s, color 0.2s; -} - -.token-copy-btn:hover { - border-color: #3688ff; - color: #3688ff; -} - -.token-raw { - display: block; - font-size: 11px; - font-family: monospace; - word-break: break-all; - line-height: 1.8; - background: #0b1120; - padding: 14px 16px; - border-radius: 8px; - overflow-x: auto; -} - -.token-raw--loading { - color: rgba(255, 255, 255, 0.3); -} - -.token-part--header { color: #ff7b72; } -.token-part--payload { color: #79c0ff; } -.token-part--signature { color: #3fb950; } - -.token-dot { - color: rgba(255, 255, 255, 0.2); -} - -/* Decoded grid */ -.token-decoded-grid { - display: grid; - grid-template-columns: 1fr 1.7fr; - gap: 12px; - margin-bottom: 20px; -} - -.token-code-box { - background: #0b1120; - border-radius: 10px; - overflow: hidden; -} - -.token-code-box-header { - padding: 9px 14px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.token-code-box-header--header { - color: #ff7b72; - border-bottom: 1px solid rgba(255, 123, 114, 0.15); -} - -.token-code-box-header--payload { - color: #79c0ff; - border-bottom: 1px solid rgba(121, 192, 255, 0.15); -} - -.token-code-pre { - margin: 0; - padding: 14px; - font-size: 11.5px; - line-height: 1.8; - font-family: monospace; - color: #c9d1d9; - overflow-x: auto; - max-height: 220px; -} - -/* Meta row */ -.token-meta-row { - display: flex; - gap: 32px; - padding-top: 20px; - border-top: 1px solid var(--color-border); - flex-wrap: wrap; -} - -.token-meta-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 5px; -} - -.token-meta-value { - font-size: 12px; - font-family: monospace; - color: var(--color-text); - letter-spacing: -0.01em; -} - -/* ── Responsive ── */ -@media (max-width: 480px) { - .nav { - padding: 0 16px; - } - - .nav-wordmark { - display: none; - } - - .hero { - padding: 80px 16px 32px; - } - - .hero-title { - font-size: 2rem; - } - - .hero-stats { - grid-template-columns: 1fr; - } - - .hero-stat + .hero-stat::before { - top: 0; - left: 20%; - width: 60%; - height: 1px; - } - - .hero-ctas { - flex-direction: column; - align-items: stretch; - } - - .user-card { - padding: 24px 16px; - } -} - -/* ─── Account page (Google Account-style left-nav layout) ─────────── */ -.account-page { - display: flex; - align-items: flex-start; - min-height: calc(100vh - 52px); - margin-top: 52px; -} - -.account-sidebar { - width: 280px; - flex-shrink: 0; - padding: 40px 24px; - position: sticky; - top: 52px; -} - -.account-sidebar-title { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - margin: 0 0 24px 16px; -} - -.account-nav { - display: flex; - flex-direction: column; - gap: 2px; -} - -.account-nav-item { - display: flex; - align-items: center; - gap: 14px; - width: 100%; - padding: 11px 16px; - border: none; - border-radius: 20px; - background: transparent; - color: var(--color-muted); - font-size: 14px; - font-weight: 500; - font-family: inherit; - text-align: left; - cursor: pointer; - white-space: nowrap; - transition: background 0.2s, color 0.2s; -} - -.account-nav-item:hover { - background: var(--color-border); - color: var(--color-text); -} - -.account-nav-item--active, -.account-nav-item--active:hover { - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); -} - -.account-content { - flex: 1; - min-width: 0; - display: flex; - justify-content: center; - padding: 40px 32px 80px; - border-left: 1px solid var(--color-border); -} - -.account-content-inner { - width: 100%; - max-width: 720px; -} - -/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting - cramped in other contexts) fights this page's own responsive layout, refusing to shrink - below 600px even when the viewport is narrower. The Security/Home boxes have no such floor - and already shrink fluidly with their container, so match that here. */ -.account-content .thunderid-user-profile { - min-width: 0; - background: var(--color-card) !important; - border: 1px solid var(--color-border) !important; - border-radius: var(--radius-card) !important; - box-shadow: var(--shadow-card); - color: var(--color-text); - box-sizing: border-box; -} - -.account-content .thunderid-user-profile .thunderid-user-profile__hero { - border-bottom: 1px solid var(--color-border); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__hero-name { - color: var(--color-text); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__hero-subtitle { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field { - border-bottom: 1px solid var(--color-border); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-label { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-value { - color: var(--color-text); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-placeholder { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn:hover { - color: var(--color-primary); -} - -.account-content .thunderid-user-profile input, -.account-content .thunderid-user-profile select, -.account-content .thunderid-user-profile textarea { - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - color: var(--color-text); - padding: 6px 10px; -} - -.account-content-title { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - margin: 0 0 6px; -} - -.account-content-subtitle { - font-size: 14px; - color: var(--color-muted); - margin: 0 0 32px; -} - -.account-tiles { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 8px; -} - -.account-tile { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 22px; - cursor: pointer; - font-family: inherit; - display: flex; - flex-direction: column; - transition: border-color 0.2s; -} - -.account-tile:hover { - border-color: rgba(54, 136, 255, 0.35); -} - -.account-tile-icon { - width: 36px; - height: 36px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; -} - -.account-tile-icon--blue { - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); -} - -.account-tile-icon--green { - background: rgba(16, 185, 129, 0.12); - color: #10b981; -} - -.account-tile-title { - font-size: 15px; - font-weight: 600; - color: var(--color-text); - margin-bottom: 4px; -} - -.account-tile-desc { - font-size: 12.5px; - color: var(--color-muted); - line-height: 1.5; -} - -.account-security-list { - display: flex; - flex-direction: column; - gap: 16px; -} - -.account-security-card { - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 20px 22px; -} - -.account-security-card-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.account-security-card-title { - font-size: 14px; - font-weight: 600; - color: var(--color-text); - margin-bottom: 2px; -} - -.account-security-card-desc { - font-size: 12.5px; - color: var(--color-muted); -} - -.account-security-card-cta { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - padding: 0; - background: none; - border: none; - color: var(--color-primary); - font-size: 13px; - font-weight: 500; - font-family: inherit; - cursor: pointer; - transition: opacity 0.2s; -} - -.account-security-card-cta:hover { - opacity: 0.8; -} - -.account-security-card-chevron { - display: inline-flex; - transition: transform 0.2s; -} - -.account-security-card-chevron--open { - transform: rotate(180deg); -} - -.account-security-card-form { - margin-top: 18px; - padding-top: 18px; - border-top: 1px solid var(--color-border); -} - -@media (max-width: 760px) { - .account-page { - flex-direction: column; - } - .account-sidebar { - width: 100%; - position: static; - padding: 20px 16px 8px; - } - .account-nav { - flex-direction: row; - overflow-x: auto; - } - .account-content { - max-width: 100%; - border-left: none; - border-top: 1px solid var(--color-border); - padding: 24px 20px 60px; - } -} - -/* ── ThunderID inputs & forms dark mode ── */ -.dark .thunderid-password-field__wrapper, -html.dark .thunderid-password-field__wrapper, -.app.dark .thunderid-password-field__wrapper, -.dark .thunderid-text-field__wrapper, -html.dark .thunderid-text-field__wrapper, -.app.dark .thunderid-text-field__wrapper, -.dark .thunderid-text-field__input, -html.dark .thunderid-text-field__input, -.app.dark .thunderid-text-field__input { - background-color: rgba(255, 255, 255, 0.05) !important; - border-color: rgba(255, 255, 255, 0.16) !important; - color: var(--color-text) !important; -} - -.dark .thunderid-password-field__label, -html.dark .thunderid-password-field__label, -.app.dark .thunderid-password-field__label, -.dark .thunderid-text-field__label, -html.dark .thunderid-text-field__label, -.app.dark .thunderid-text-field__label, -.dark .thunderid-input-label, -html.dark .thunderid-input-label, -.app.dark .thunderid-input-label { - color: var(--color-text) !important; -} - -.dark .thunderid-password-field__input, -html.dark .thunderid-password-field__input, -.app.dark .thunderid-password-field__input { - background: transparent !important; - color: var(--color-text) !important; -} - -.dark .thunderid-password-field__input::placeholder, -html.dark .thunderid-password-field__input::placeholder, -.app.dark .thunderid-password-field__input::placeholder, -.dark .thunderid-text-field__input::placeholder, -html.dark .thunderid-text-field__input::placeholder, -.app.dark .thunderid-text-field__input::placeholder { - color: var(--color-text-muted) !important; -} - -.dark .thunderid-password-field__toggle, -html.dark .thunderid-password-field__toggle, -.app.dark .thunderid-password-field__toggle { - color: var(--color-text-muted) !important; - background: transparent !important; -} - -.dark .thunderid-password-field__toggle:hover, -html.dark .thunderid-password-field__toggle:hover, -.app.dark .thunderid-password-field__toggle:hover { - color: var(--color-text) !important; -} - -.dark .thunderid-change-credential__requirements-heading, -html.dark .thunderid-change-credential__requirements-heading, -.app.dark .thunderid-change-credential__requirements-heading, -.dark .thunderid-change-credential__requirement, -html.dark .thunderid-change-credential__requirement, -.app.dark .thunderid-change-credential__requirement { - color: var(--color-text-muted) !important; -} - -.dark .thunderid-change-credential__requirement--passed, -html.dark .thunderid-change-credential__requirement--passed, -.app.dark .thunderid-change-credential__requirement--passed { - color: #38c172 !important; -} - -/* ── ThunderID UserDropdown dark mode ── */ -.dark .thunderid-user-dropdown__menu, -html.dark .thunderid-user-dropdown__menu, -.app.dark .thunderid-user-dropdown__menu, -.dark .thunderid-user-dropdown__content, -html.dark .thunderid-user-dropdown__content, -.app.dark .thunderid-user-dropdown__content { - background-color: var(--color-card) !important; - border: 1px solid var(--color-border) !important; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important; - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__menu-header, -html.dark .thunderid-user-dropdown__menu-header, -.app.dark .thunderid-user-dropdown__menu-header, -.dark .thunderid-user-dropdown__header, -html.dark .thunderid-user-dropdown__header, -.app.dark .thunderid-user-dropdown__header { - border-bottom: 1px solid var(--color-border) !important; -} - -.dark .thunderid-user-dropdown__menu-header-name, -html.dark .thunderid-user-dropdown__menu-header-name, -.app.dark .thunderid-user-dropdown__menu-header-name, -.dark .thunderid-user-dropdown__header-name, -html.dark .thunderid-user-dropdown__header-name, -.app.dark .thunderid-user-dropdown__header-name { - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__menu-header-subtitle, -html.dark .thunderid-user-dropdown__menu-header-subtitle, -.app.dark .thunderid-user-dropdown__menu-header-subtitle, -.dark .thunderid-user-dropdown__header-subtitle, -html.dark .thunderid-user-dropdown__header-subtitle, -.app.dark .thunderid-user-dropdown__header-subtitle { - color: var(--color-text-muted) !important; -} - -.dark .thunderid-user-dropdown__menu-divider, -html.dark .thunderid-user-dropdown__menu-divider, -.app.dark .thunderid-user-dropdown__menu-divider, -.dark .thunderid-divider, -html.dark .thunderid-divider, -.app.dark .thunderid-divider { - background-color: var(--color-border) !important; - border-color: var(--color-border) !important; -} - -.dark .thunderid-user-dropdown__item, -html.dark .thunderid-user-dropdown__item, -.app.dark .thunderid-user-dropdown__item { - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__item:hover, -html.dark .thunderid-user-dropdown__item:hover, -.app.dark .thunderid-user-dropdown__item:hover { - background-color: rgba(255, 255, 255, 0.06) !important; - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__item--danger, -html.dark .thunderid-user-dropdown__item--danger, -.app.dark .thunderid-user-dropdown__item--danger { - color: #f87171 !important; -} - -.dark .thunderid-user-dropdown__item--danger:hover, -html.dark .thunderid-user-dropdown__item--danger:hover, -.app.dark .thunderid-user-dropdown__item--danger:hover { - background-color: rgba(239, 68, 68, 0.12) !important; -} - -.dark .thunderid-card, -html.dark .thunderid-card, -.app.dark .thunderid-card { - background-color: var(--color-card) !important; - border-color: var(--color-border) !important; - color: var(--color-text) !important; -} - diff --git a/samples/nextjs/quickstart/app/layout.tsx b/samples/nextjs/quickstart/app/layout.tsx deleted file mode 100644 index d67dade0..00000000 --- a/samples/nextjs/quickstart/app/layout.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { Metadata } from 'next' -import { ThunderIDProvider } from '@thunderid/nextjs/server' -import ConfigNotice from './components/ConfigNotice' -import './globals.css' - -export const dynamic = 'force-dynamic' - -export const metadata: Metadata = { - title: 'ThunderID Next.js Quickstart', - description: 'ThunderID authentication with Next.js', -} - -// Redirect-based flow (NEXT_PUBLIC_THUNDERID_CLIENT_ID set) sends the user to -// ThunderID's hosted pages and needs a registered redirect URI. The default, -// native flow renders sign-in/sign-up inline via the app's own routes and -// needs an application ID instead β€” no redirect URI or CORS setup required. -const isRedirectFlow = Boolean(process.env.NEXT_PUBLIC_THUNDERID_CLIENT_ID) - -const REQUIRED_ENV_VARS = [ - 'NEXT_PUBLIC_THUNDERID_BASE_URL', - 'THUNDERID_SECRET', - ...(isRedirectFlow - ? ['NEXT_PUBLIC_THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET'] - : ['NEXT_PUBLIC_THUNDERID_APPLICATION_ID', 'NEXT_PUBLIC_THUNDERID_SIGN_IN_URL', 'NEXT_PUBLIC_THUNDERID_SIGN_UP_URL', 'THUNDERID_FLOW_SECRET']), -] - -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { - const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]) - - return ( - - - {missingEnvVars.length > 0 ? ( - - ) : ( - - {children} - - )} - - - ) -} diff --git a/samples/nextjs/quickstart/app/not-found.tsx b/samples/nextjs/quickstart/app/not-found.tsx deleted file mode 100644 index 53bd47dd..00000000 --- a/samples/nextjs/quickstart/app/not-found.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Link from 'next/link' - -export default function NotFound() { - return ( -
    -
    -

    Page not found

    -

    The page you're looking for doesn't exist.

    - Return home -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/app/page.tsx b/samples/nextjs/quickstart/app/page.tsx deleted file mode 100644 index 825ba9f2..00000000 --- a/samples/nextjs/quickstart/app/page.tsx +++ /dev/null @@ -1,264 +0,0 @@ -'use client' -import { SignedIn, SignedOut, User, UserAvatar, useThunderID } from '@thunderid/nextjs' -import { useState, useEffect } from 'react' -import { fetchAccessToken } from './actions' -import HeroCtas from './components/HeroCtas' -import Nav from './components/Nav' -import ThunderMark from './components/ThunderMark' - -function greeting(name: string): string { - const h = new Date().getHours() - const tod = h < 12 ? 'morning' : h < 17 ? 'afternoon' : 'evening' - return `Good ${tod}, ${name}.` -} - -function formatTime(unixSeconds: number | undefined): string { - if (!unixSeconds) return 'β€”' - return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) -} - -function formatCountdown(secsLeft: number): { text: string; color: string } { - if (secsLeft <= 0) return { text: 'Expired', color: '#d95757' } - if (secsLeft < 300) { - const m = Math.floor(secsLeft / 60), s = secsLeft % 60 - return { text: `${m}m ${s}s`, color: '#e88b3a' } - } - if (secsLeft < 3600) { - const m = Math.floor(secsLeft / 60), s = secsLeft % 60 - return { text: `${m}m ${s}s`, color: '#2fbd6b' } - } - const hr = Math.floor(secsLeft / 3600), m = Math.floor((secsLeft % 3600) / 60) - return { text: `${hr}h ${m}m`, color: '#2fbd6b' } -} - -const NEXT_STEPS = [ - { - n: '01', - title: 'Explore use cases', - body: 'See what you can build β€” auth flows for web, mobile, APIs, and agents.', - cta: 'Browse use cases', - href: 'https://thunderid.dev/docs/next/use-cases/overview/', - }, - { - n: '02', - title: 'Learn about flows', - body: 'Understand how authorization code, PKCE, client credentials, and device flows work.', - cta: 'Read guide', - href: 'https://thunderid.dev/docs/next/guides/flows/what-are-flows/', - }, - { - n: '03', - title: 'Style your experience', - body: 'Customize the login UI, branding, and email templates to match your product.', - cta: 'Design guide', - href: 'https://thunderid.dev/docs/next/guides/design/overview/', - }, - { - n: '04', - title: 'Explore SDK APIs', - body: 'Full Next.js SDK reference β€” hooks, components, and configuration options.', - cta: 'SDK reference', - href: 'https://thunderid.dev/docs/next/sdks/nextjs/overview/', - }, -] - -function ClockIcon() { - return ( - - ) -} - -function TimerIcon() { - return ( - - ) -} - -function BuildingIcon() { - return ( - - ) -} - -function ArrowIcon() { - return ( - - ) -} - -function HomeContent() { - const { organizationHandle } = useThunderID() - const [accessToken, setAccessToken] = useState(null) - const [now, setNow] = useState(Math.floor(Date.now() / 1000)) - - useEffect(() => { - void fetchAccessToken().then((t) => { if (t) setAccessToken(t) }) - }, []) - - useEffect(() => { - const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000) - return () => clearInterval(id) - }, []) - - const jwtPayload = accessToken ? (() => { - try { - const part = accessToken.split('.')[1] ?? '' - const base64 = part.replace(/-/g, '+').replace(/_/g, '/') - const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=') - return JSON.parse(atob(padded)) as Record - } catch { return null } - })() : null - - const authTime = jwtPayload?.auth_time as number | undefined - const exp = jwtPayload?.exp as number | undefined - const secsLeft = exp ? Math.max(0, exp - now) : null - const countdown = secsLeft !== null ? formatCountdown(secsLeft) : null - - return ( - - {(user: { givenName?: string; given_name?: string; familyName?: string; email?: string } | null) => { - const givenName = user?.givenName ?? user?.given_name ?? user?.email?.split('@')[0] ?? 'there' - const email = user?.email - const ouName = organizationHandle ?? 'Default' - - return ( -
    -
    - -
    -

    {greeting(givenName)}

    -
    - {email && {email}} - {email && } - - - Session active - -
    -
    -
    - -
    -
    -
    -
    -
    {formatTime(authTime)}
    -
    Signed in at
    -
    -
    -
    -
    -
    -
    - {countdown ? countdown.text : 'β€”'} -
    -
    Session expires in
    -
    -
    -
    -
    -
    -
    {ouName}
    -
    Organisation
    -
    -
    -
    - -
    What's next
    - -
    - ) - }} -
    - ) -} - -export default function HomePage() { - return ( -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/app/signin/page.tsx b/samples/nextjs/quickstart/app/signin/page.tsx deleted file mode 100644 index 26ab4ce2..00000000 --- a/samples/nextjs/quickstart/app/signin/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' -import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { SignedIn, SignedOut, SignIn } from '@thunderid/nextjs' -import Nav from '../components/Nav' - -export default function SignInPage() { - const router = useRouter() - - return ( -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/app/signup/page.tsx b/samples/nextjs/quickstart/app/signup/page.tsx deleted file mode 100644 index d185861c..00000000 --- a/samples/nextjs/quickstart/app/signup/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -'use client' -import Link from 'next/link' -import { SignedIn, SignedOut, SignUp } from '@thunderid/nextjs' -import Nav from '../components/Nav' - -export default function SignUpPage() { - return ( -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/app/token/page.tsx b/samples/nextjs/quickstart/app/token/page.tsx deleted file mode 100644 index 9d7f7416..00000000 --- a/samples/nextjs/quickstart/app/token/page.tsx +++ /dev/null @@ -1,143 +0,0 @@ -'use client' -import { useState, useEffect } from 'react' -import { fetchAccessToken } from '../actions' -import Nav from '../components/Nav' - -function decodeJwtPart(part: string): Record | null { - try { - const base64 = part.replace(/-/g, '+').replace(/_/g, '/') - const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=') - return JSON.parse(atob(padded)) as Record - } catch { - return null - } -} - -function CopyIcon() { - return ( - - ) -} - -export default function TokenDebugPage() { - const [rawToken, setRawToken] = useState(null) - const [copied, setCopied] = useState(false) - const [now, setNow] = useState(Math.floor(Date.now() / 1000)) - - useEffect(() => { - void fetchAccessToken().then((t) => { if (t) setRawToken(t) }) - }, []) - - useEffect(() => { - const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000) - return () => clearInterval(id) - }, []) - - const parts = rawToken ? rawToken.split('.') : [] - const jwtHeader = parts[0] ? decodeJwtPart(parts[0]) : null - const jwtPayload = parts[1] ? decodeJwtPart(parts[1]) : null - - const exp = jwtPayload?.exp as number | undefined - const expiresIn = exp ? exp - now : null - const expired = expiresIn !== null && expiresIn <= 0 - const minsLeft = expiresIn !== null ? Math.abs(Math.floor(expiresIn / 60)) : null - - const handleCopy = () => { - if (rawToken && navigator.clipboard) { - void navigator.clipboard.writeText(rawToken) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - } - } - - const issuer = jwtPayload?.iss as string | undefined - const audience = Array.isArray(jwtPayload?.aud) ? (jwtPayload.aud as string[]).join(', ') : jwtPayload?.aud as string | undefined - const scopes = jwtPayload?.scope as string | undefined - - return ( -
    -
    - ) -} diff --git a/samples/nextjs/quickstart/next-env.d.ts b/samples/nextjs/quickstart/next-env.d.ts deleted file mode 100644 index 830fb594..00000000 --- a/samples/nextjs/quickstart/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/samples/nextjs/quickstart/next.config.ts b/samples/nextjs/quickstart/next.config.ts deleted file mode 100644 index e4f5738a..00000000 --- a/samples/nextjs/quickstart/next.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { NextConfig } from 'next' - -const nextConfig: NextConfig = {} - -export default nextConfig diff --git a/samples/nextjs/quickstart/package.json b/samples/nextjs/quickstart/package.json deleted file mode 100644 index 2f2379cd..00000000 --- a/samples/nextjs/quickstart/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@thunderid/nextjs-quickstart", - "private": true, - "version": "0.0.0", - "scripts": { - "prepare-dev": "node scripts/prepare-dev.cjs", - "prepare-dev:redirect": "node scripts/prepare-dev.cjs --flow=redirect", - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "@thunderid/nextjs": "workspace:*", - "next": "^15.5.25", - "react": "^19.2.3", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@types/node": "^24.7.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "typescript": "^5.9.3" - } -} diff --git a/samples/nextjs/quickstart/proxy.ts b/samples/nextjs/quickstart/proxy.ts deleted file mode 100644 index bada0bec..00000000 --- a/samples/nextjs/quickstart/proxy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {thunderIDProxy, createRouteMatcher} from '@thunderid/nextjs/server' - -const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']) - -export default thunderIDProxy(async (thunderid, request) => { - if (isProtectedRoute(request)) { - await thunderid.protectRoute() - } -}) - -export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], -} diff --git a/samples/nextjs/quickstart/scripts/prepare-dev.cjs b/samples/nextjs/quickstart/scripts/prepare-dev.cjs deleted file mode 100644 index 9811ec0d..00000000 --- a/samples/nextjs/quickstart/scripts/prepare-dev.cjs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('node:fs'); -const path = require('node:path'); - -const root = path.join(__dirname, '..'); - -const PREFIX = 'NEXT_PUBLIC_THUNDERID_'; -const NATIVE_FLOW_VARS = [ - `${PREFIX}APPLICATION_ID`, - `${PREFIX}SIGN_IN_URL`, - `${PREFIX}SIGN_UP_URL`, - 'THUNDERID_FLOW_SECRET', -]; -const REDIRECT_FLOW_VARS = [`${PREFIX}CLIENT_ID`, 'THUNDERID_CLIENT_SECRET']; -const REDIRECT_FLOW_PLACEHOLDERS = { - [`${PREFIX}CLIENT_ID`]: 'your-client-id-here', - THUNDERID_CLIENT_SECRET: 'your-client-secret-here', -}; - -const flowArg = process.argv.find((arg) => arg.startsWith('--flow=')); -const flowExplicitlyRequested = Boolean(flowArg); -const flow = flowArg ? flowArg.slice('--flow='.length) : 'native'; - -if (flowExplicitlyRequested && flow !== 'native' && flow !== 'redirect') { - console.error(`Unknown --flow value "${flow}". Expected "native" or "redirect".`); - process.exit(1); -} - -/** - * Toggles the leading `# ` on env var lines to match the selected flow, appending - * any vars for the selected flow that aren't present in the source file yet (the - * redirect-flow vars aren't checked into `.env.example`, so switching to `redirect` - * from a fresh copy needs to add them rather than just uncomment them). - */ -function applyFlow(envContent, selectedFlow) { - const varsToEnable = selectedFlow === 'redirect' ? REDIRECT_FLOW_VARS : NATIVE_FLOW_VARS; - const varsToDisable = selectedFlow === 'redirect' ? NATIVE_FLOW_VARS : REDIRECT_FLOW_VARS; - const found = new Set(); - - const lines = envContent.split('\n').map((line) => { - const enable = varsToEnable.find((key) => line.replace(/^#\s*/, '').startsWith(`${key}=`)); - if (enable) { - found.add(enable); - return line.replace(/^#\s*/, ''); - } - - const disable = varsToDisable.find((key) => line.startsWith(`${key}=`)); - if (disable) return `# ${line}`; - - return line; - }); - - const missing = varsToEnable.filter((key) => !found.has(key)); - if (missing.length > 0) { - lines.push('', `# ── ${selectedFlow} flow ─────────────────────────────────────────────────`); - for (const key of missing) lines.push(`${key}=${REDIRECT_FLOW_PLACEHOLDERS[key] ?? ''}`); - } - - return lines.join('\n'); -} - -const envExample = path.join(root, '.env.example'); -const envTarget = path.join(root, '.env'); - -// Only explicit `--flow=` invocations are allowed to (re)write an existing -// .env β€” a plain `npm run prepare-dev` (e.g. from .stackblitzrc's -// startCommand) stays a safe, idempotent copy-if-missing. -if (fs.existsSync(envExample) && (flowExplicitlyRequested || !fs.existsSync(envTarget))) { - const envSource = fs.existsSync(envTarget) ? envTarget : envExample; - const envContent = applyFlow(fs.readFileSync(envSource, 'utf8'), flow) - // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) - // so the copied .env still trips the app's missing-env-var check until real - // values are filled in. - .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); - fs.writeFileSync(envTarget, envContent); -} - -const pkgPath = path.join(root, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -for (const field of ['dependencies', 'devDependencies']) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, version] of Object.entries(deps)) { - if (version.startsWith('workspace:')) { - deps[name] = 'latest'; - } - } -} - -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/nextjs/quickstart/tsconfig.json b/samples/nextjs/quickstart/tsconfig.json deleted file mode 100644 index fba2bf37..00000000 --- a/samples/nextjs/quickstart/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/samples/node/quickstart/.env.example b/samples/node/quickstart/.env.example deleted file mode 100644 index a45eda11..00000000 --- a/samples/node/quickstart/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Base URL of your ThunderID deployment. ex: https://localhost:8090 -THUNDERID_BASE_URL=https://localhost:8090 - -# OAuth2 Client ID for this service. ThunderID Console -> your application -> Overview. -THUNDERID_CLIENT_ID=your-client-id -# OAuth2 Client Secret for this service. Shown once when the application is created; -# if lost, regenerate it from the app's Credentials tab (under Edit). -THUNDERID_CLIENT_SECRET=your-client-secret - -# Optional: space-separated scopes to request for this service. -THUNDERID_SCOPE= - -# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. -NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/node/quickstart/.stackblitzrc b/samples/node/quickstart/.stackblitzrc deleted file mode 100644 index 5f0a2000..00000000 --- a/samples/node/quickstart/.stackblitzrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "installDependencies": false, - "startCommand": "npm run prepare-dev && npm install && npm run dev" -} diff --git a/samples/node/quickstart/README.md b/samples/node/quickstart/README.md deleted file mode 100644 index 1aeb7ee0..00000000 --- a/samples/node/quickstart/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# ThunderID Node.js Service Quickstart - -Open in StackBlitz - -A minimal machine-to-machine (service-to-service) client, using the ThunderID Node.js SDK -(`@thunderid/node`). Unlike the other quickstarts, there's no user and no browser sign-in: this -service authenticates as **itself** with the OAuth 2.0 `client_credentials` grant, then uses the -resulting access token to call another piece of business logic. - -This is the pattern to use for background jobs, cron tasks, or one backend service calling -another, where there's no human in the loop to redirect through a login page. - -The sample is organized the way a real backend usually is, with authentication kept in its own -service instead of scattered across business logic: - -```text -node/quickstart/ -β”œβ”€β”€ index.mjs Wires the services together and prints the result -└── lib/ - β”œβ”€β”€ AuthService.mjs Acquires and caches this service's own access token - β”œβ”€β”€ InventoryService.mjs Business logic; asks AuthService for a token before answering - └── ui.mjs Terminal output, built on boxen and picocolors -``` - -## How it works - -- `lib/AuthService.mjs` initializes `ThunderIDNodeClient` with `grantType: 'client_credentials'`. - With that set, `getAccessToken()` authenticates as the service itself (no session, no sign-in) - and transparently fetches, caches, and refreshes the token, the same method a user-facing - quickstart would call to read an existing session's token, just authenticating as the service - itself instead of a signed-in user. -- `lib/InventoryService.mjs` is the business logic that needs a token before it can act. It asks - `AuthService` for one; the rest of the app never handles a token or an `Authorization` header - directly. In a real deployment, `InventoryService` would send that token to a separate inventory - backend over HTTP, in place of another service calling `AuthService`'s owner the same way. - -## Prerequisites - -- Node.js 18+ -- A running ThunderID instance (default: `https://localhost:8090`) -- A **Backend Service** application registered in ThunderID - -## Create an application - -1. Open the ThunderID Console (`https://localhost:8090/console`), navigate to **Applications**, and - click **Add Application**. -2. From the **Choose a type** page, select **Backend Service**. -3. Enter a name (e.g. `node-service-quickstart`) and create the application. Backend Service apps - are pre-configured for the `client_credentials` grant with `client_secret_basic` authentication, - no extra setup needed. -4. Copy the **Client ID** from the **General** tab, and the **Client Secret** from the window that - pops up when the application is created (shown only once), you'll need them below. - -## Getting started - -1. Copy the environment file and fill in your application's credentials: - ```sh - cp .env.example .env - ``` - ``` - THUNDERID_CLIENT_ID= - THUNDERID_CLIENT_SECRET= - THUNDERID_BASE_URL=https://localhost:8090 - ``` - -2. Install dependencies and run it: - ```sh - npm install - npm start - ``` - -The script authenticates once, then calls `InventoryService.getStock()` for a few SKUs and prints -the result before exiting: a scope line, then an `Inventory` panel with one line per SKU, green -for in-stock items, yellow for out-of-stock, red for a SKU that doesn't exist. - -## Learn more - -- [ThunderID Docs](https://thunderid.dev/docs) -- [OAuth 2.0 Client Credentials Grant (RFC 6749 Β§4.4)](https://www.rfc-editor.org/rfc/rfc6749#section-4.4) diff --git a/samples/node/quickstart/index.mjs b/samples/node/quickstart/index.mjs deleted file mode 100644 index 8d01c870..00000000 --- a/samples/node/quickstart/index.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import {AuthService} from './lib/AuthService.mjs'; -import {InventoryService} from './lib/InventoryService.mjs'; -import {printIntro, printConfigNeeded, printAuthenticated, printInventory} from './lib/ui.mjs'; - -printIntro(); - -const REQUIRED_ENV_VARS = ['THUNDERID_CLIENT_ID', 'THUNDERID_CLIENT_SECRET']; -const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]); - -if (missingEnvVars.length > 0) { - printConfigNeeded(missingEnvVars); - process.exit(1); -} - -const THUNDER_BASE_URL = process.env.THUNDERID_BASE_URL || 'https://localhost:8090'; - -// AuthService authenticates as this service itself (grantType: 'client_credentials'), -// no user and no browser sign-in, unlike the express/react/etc quickstarts, which -// authenticate a human through a login page. -const auth = new AuthService({ - baseUrl: THUNDER_BASE_URL, - clientId: process.env.THUNDERID_CLIENT_ID, - clientSecret: process.env.THUNDERID_CLIENT_SECRET, - scopes: process.env.THUNDERID_SCOPE, -}); - -const inventory = new InventoryService({auth}); - -const accessToken = await auth.getAccessToken(); -const {scope} = await auth.decodeAccessToken(accessToken); -printAuthenticated({baseUrl: THUNDER_BASE_URL, clientId: process.env.THUNDERID_CLIENT_ID, scope}); - -const rows = []; -for (const sku of ['SKU-100', 'SKU-200', 'SKU-300', 'SKU-999']) { - try { - const item = await inventory.getStock(sku); - rows.push({sku, name: item.name, stock: item.stock}); - } catch (error) { - rows.push({sku, error: error.message}); - } -} -printInventory(rows); diff --git a/samples/node/quickstart/lib/AuthService.mjs b/samples/node/quickstart/lib/AuthService.mjs deleted file mode 100644 index e827c150..00000000 --- a/samples/node/quickstart/lib/AuthService.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import {ThunderIDNodeClient} from '@thunderid/node'; - -/** - * Authenticates this service as itself with the OAuth 2.0 client_credentials - * grant, no user, no browser sign-in. Other services in this process (or a - * real deployment) ask AuthService for a token instead of talking to - * ThunderIDNodeClient directly, so the authentication concern stays in one - * place and out of business logic like InventoryService. - */ -export class AuthService { - #client = new ThunderIDNodeClient(); - #ready; - - constructor({baseUrl, clientId, clientSecret, scopes}) { - this.#ready = this.#client.initialize({ - baseUrl, - clientId, - clientSecret, - grantType: 'client_credentials', - scopes, - }); - } - - /** Fetches (or reuses the cached) access token for this service's own identity. */ - async getAccessToken() { - await this.#ready; - return this.#client.getAccessToken(); - } - - async decodeAccessToken(accessToken) { - return this.#client.decodeJwtToken(accessToken); - } -} diff --git a/samples/node/quickstart/lib/InventoryService.mjs b/samples/node/quickstart/lib/InventoryService.mjs deleted file mode 100644 index 9897c796..00000000 --- a/samples/node/quickstart/lib/InventoryService.mjs +++ /dev/null @@ -1,29 +0,0 @@ -const CATALOG = { - 'SKU-100': {sku: 'SKU-100', name: 'Wireless Mouse', stock: 42}, - 'SKU-200': {sku: 'SKU-200', name: 'Mechanical Keyboard', stock: 7}, - 'SKU-300': {sku: 'SKU-300', name: '4K Monitor', stock: 0}, -}; - -/** - * Business logic for a service that needs to call another backend. It asks - * AuthService for a token before answering, so the rest of the app never - * handles a token or an Authorization header directly. In a real deployment - * this would send the token to a separate inventory service over HTTP - * instead of just holding it in-process. - */ -export class InventoryService { - constructor({auth}) { - this.auth = auth; - } - - async getStock(sku) { - // A real backend would attach this as `Authorization: Bearer ${accessToken}` - // on the request to the inventory service; here it just proves the token - // was obtained before answering. - await this.auth.getAccessToken(); - - const item = CATALOG[sku]; - if (!item) throw new Error(`No item with SKU ${sku}`); - return item; - } -} diff --git a/samples/node/quickstart/lib/ui.mjs b/samples/node/quickstart/lib/ui.mjs deleted file mode 100644 index f234454d..00000000 --- a/samples/node/quickstart/lib/ui.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import boxen from 'boxen'; -import pc from 'picocolors'; - -const BRAND = '#3688FF'; -const ORANGE = '#e88b3a'; - -function indent(text) { - return text - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); -} - -function badge(label, colorFn) { - const line = pc.dim('──────'); - return `${line} ${pc.bold(colorFn(label.toUpperCase()))} ${line}`; -} - -function box(content, {title, borderColor}) { - return boxen(content, { - title, - titleAlignment: 'left', - padding: {top: 0, bottom: 0, left: 1, right: 1}, - borderStyle: 'round', - borderColor, - }); -} - -export function printIntro() { - console.log(); - console.log(indent(pc.bold('ThunderID Node Service Quickstart'))); - console.log(indent(pc.dim('Service-to-service auth via OAuth 2.0 client_credentials'))); - console.log(); -} - -export function printConfigNeeded(missingEnvVars) { - console.log(indent(badge('Setup required', pc.yellow))); - console.log(); - console.log(indent(pc.bold('Configuration needed'))); - console.log( - indent( - pc.dim( - "This quickstart can't reach ThunderID yet. Set the following environment variable(s), then run again.", - ), - ), - ); - console.log(); - console.log(indent(box(missingEnvVars.map((key) => pc.red(key)).join('\n'), {borderColor: ORANGE}))); - console.log(); - console.log( - indent( - pc.dim('Copy ') + - pc.cyan('.env.example') + - pc.dim(' to ') + - pc.cyan('.env') + - pc.dim(', fill in the client credentials for your application, then run ') + - pc.cyan('npm start') + - pc.dim(' again.'), - ), - ); - console.log(); - console.log( - indent( - pc.dim('Need more info? Take a look at the Node quickstart guide: ') + - pc.cyan('https://thunderid.dev/docs/next/getting-started/connect-your-application/node/'), - ), - ); - console.log(); -} - -export function printAuthenticated({baseUrl, clientId, scope}) { - console.log(indent(`${pc.bgGreen(pc.black(' ● AUTHENTICATED '))} ${pc.dim('client_credentials grant')}`)); - console.log(); - console.log(indent(`${pc.dim('BASE URL')} ${baseUrl}`)); - console.log(indent(`${pc.dim('CLIENT ID')} ${clientId}`)); - console.log(indent(`${pc.dim('SCOPE')} ${scope || pc.dim('(none requested)')}`)); - console.log(); -} - -export function printInventory(rows) { - const lines = rows.map(({sku, name, stock, error}) => { - const label = pc.bold(sku.padEnd(9)); - if (error) return `${label} ${pc.red(error)}`; - const stockText = stock > 0 ? pc.green(`${stock} in stock`) : pc.yellow('out of stock'); - return `${label} ${name.padEnd(20)} ${stockText}`; - }); - console.log(indent(box(lines.join('\n'), {title: 'Inventory', borderColor: BRAND}))); - console.log(); -} diff --git a/samples/node/quickstart/package.json b/samples/node/quickstart/package.json deleted file mode 100644 index a69445fe..00000000 --- a/samples/node/quickstart/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@thunderid/node-quickstart", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "prepare-dev": "node scripts/prepare-dev.cjs", - "start": "node --env-file-if-exists=.env index.mjs", - "dev": "node --env-file-if-exists=.env --watch index.mjs" - }, - "dependencies": { - "@thunderid/node": "workspace:*", - "boxen": "^8.0.1", - "picocolors": "^1.1.1" - } -} diff --git a/samples/node/quickstart/scripts/prepare-dev.cjs b/samples/node/quickstart/scripts/prepare-dev.cjs deleted file mode 100644 index 8e20fa43..00000000 --- a/samples/node/quickstart/scripts/prepare-dev.cjs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('node:fs'); -const path = require('node:path'); - -const root = path.join(__dirname, '..'); - -const envExample = path.join(root, '.env.example'); -const envTarget = path.join(root, '.env'); -if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) { - // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) - // so the copied .env still trips the app's missing-env-var check until real - // values are filled in. - const envContent = fs - .readFileSync(envExample, 'utf8') - .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); - fs.writeFileSync(envTarget, envContent); -} - -const pkgPath = path.join(root, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -for (const field of ['dependencies', 'devDependencies']) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, version] of Object.entries(deps)) { - if (version.startsWith('workspace:')) { - deps[name] = 'latest'; - } - } -} - -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/nuxt/quickstart/.env.example b/samples/nuxt/quickstart/.env.example deleted file mode 100644 index fbeed8e6..00000000 --- a/samples/nuxt/quickstart/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# Base URL of your ThunderID deployment. ex: https://localhost:8090 -NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090 - -# Flow Secret for this app. Sent in the `Flow-Secret` header to authenticate this app when the -# native (embedded) flow starts. Shown once when the application is created; if lost, regenerate it -# from the app's Credentials tab (under Edit). Server-only, never expose to the browser. -THUNDERID_FLOW_SECRET=your-flow-secret-here - -# Secret used to encrypt this app's session cookie. Generate locally, not from the console. -THUNDERID_SESSION_SECRET=generate-with-openssl-rand-base64-32 - -# Application ID (spId) for this app. ThunderID Console -> your application -> Overview. -NUXT_PUBLIC_THUNDERID_APPLICATION_ID=your-application-id-here -# Local app route that renders the sign-in page. Not from the console. -NUXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin -# Local app route that renders the sign-up page. Not from the console. -NUXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup - -# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. -NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/nuxt/quickstart/.stackblitzrc b/samples/nuxt/quickstart/.stackblitzrc deleted file mode 100644 index 5f0a2000..00000000 --- a/samples/nuxt/quickstart/.stackblitzrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "installDependencies": false, - "startCommand": "npm run prepare-dev && npm install && npm run dev" -} diff --git a/samples/nuxt/quickstart/README.md b/samples/nuxt/quickstart/README.md deleted file mode 100644 index 2a610109..00000000 --- a/samples/nuxt/quickstart/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# ThunderID Nuxt Quickstart - -Open in StackBlitz - -A minimal Nuxt 3 application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT out of the box. - -## Prerequisites - -- Node.js 18+ -- pnpm -- A ThunderID application - -## Getting started - -1. Copy the example environment file: - - ```bash - cp .env.example .env - ``` - -2. Fill in your ThunderID credentials in `.env`. By default the file is set up for the native flow: - - ```dotenv - NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090 - NUXT_PUBLIC_THUNDERID_APPLICATION_ID= - NUXT_PUBLIC_THUNDERID_SIGN_IN_URL=/signin - NUXT_PUBLIC_THUNDERID_SIGN_UP_URL=/signup - THUNDERID_FLOW_SECRET= - THUNDERID_SESSION_SECRET= - ``` - -3. Start the development server: - - ```bash - pnpm dev - ``` - - The app is now running at [http://localhost:3000](http://localhost:3000). - -
    -

    Redirect-based flow

    - -By default this quickstart uses the native (embedded) flow, where sign-in/sign-up render inline on this -app's own `/signin` and `/signup` routes with no redirect to ThunderID's hosted pages. - -To send the user to ThunderID's hosted sign-in page instead, switch to the redirect-based flow: - -1. Register a redirect URI for your application (see the app's config notice in the console for the - exact value to use). -2. Regenerate `.env` for the redirect flow: - - ```bash - npm run prepare-dev:redirect - ``` - - This comments out the native-flow vars (`NUXT_PUBLIC_THUNDERID_APPLICATION_ID`, - `NUXT_PUBLIC_THUNDERID_SIGN_IN_URL`, `NUXT_PUBLIC_THUNDERID_SIGN_UP_URL`) and adds: - - ```dotenv - NUXT_PUBLIC_THUNDERID_CLIENT_ID= - THUNDERID_CLIENT_SECRET= - ``` - - Both values come from the application's Credentials tab in the console. The redirect-based flow - doesn't use `THUNDERID_FLOW_SECRET` (that's only sent when the native flow starts), so it can stay - set in `.env` from step 2 above, unused. To switch back to the native flow, run - `node scripts/prepare-dev.cjs --flow=native` (or manually re-enable the native-flow vars and comment - out the two above). -3. Fill in `NUXT_PUBLIC_THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` in `.env`, then restart the - dev server. - -
    - -## Back-channel logout - -ThunderID can end this app's session itself. When the user's ThunderID session ends somewhere else, -for example by signing out of another application, ThunderID posts a logout token to this app. The -module serves the route once it is turned on in `nuxt.config.ts`: - -```ts -thunderid: { - backchannelLogout: { enabled: true }, -}, -``` - -The session lives in a cookie that only the browser can delete, so the SDK records the logout and -treats every later request with that session as signed out. - -To try it, use the redirect-based flow above, since the logout token is addressed to the client ID: - -1. In the ThunderID Console, open this application, go to **Advanced Settings β†’ OAuth2 - Configuration**, and set **Back-Channel Logout URI** to - `http://localhost:3000/api/auth/backchannel-logout`. -2. ThunderID refuses localhost and private network addresses by default. For local testing only, - turn that off in the server's `deployment.yaml` and restart it: - ```yaml - oauth: - logout: - backchannel: - reject_private_addresses: false - ``` -3. Sign in at [http://localhost:3000](http://localhost:3000), then sign in to a second application - in the same browser, so both share one ThunderID session. -4. Sign out of the second application, then reload this app: you are signed out here too. - -Things to know before production: - -- The SDK records ended sessions in memory by default. If you run more than one instance, name a - [Nitro storage](https://nitro.build/guide/storage) mount point that all of them share, since - ThunderID's request reaches only one instance: - ```ts - thunderid: { - backchannelLogout: { enabled: true, store: 'redis' }, - }, - nitro: { - storage: { redis: { driver: 'redis', url: process.env.REDIS_URL } }, - }, - ``` -- An access token already handed to the browser or to another API stays valid until it expires. diff --git a/samples/nuxt/quickstart/app/app.vue b/samples/nuxt/quickstart/app/app.vue deleted file mode 100644 index d463ae9a..00000000 --- a/samples/nuxt/quickstart/app/app.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/assets/styles.css b/samples/nuxt/quickstart/app/assets/styles.css deleted file mode 100644 index 82719ff9..00000000 --- a/samples/nuxt/quickstart/app/assets/styles.css +++ /dev/null @@ -1,1397 +0,0 @@ -*, -::before, -::after { - box-sizing: border-box; -} - -/* ── Design tokens ── */ -.app { - --color-bg: #f7f9fc; - --color-card: #ffffff; - --color-border: #dde3ec; - --color-text: #05213f; - --color-text-muted: #5a7085; - --color-primary: #3688ff; - --color-primary-hover: #1f72f0; - --color-nav-bg: rgba(247, 249, 252, 0.92); - --color-nav-border: #dde3ec; - --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06), 0 4px 12px 0 rgba(5, 33, 63, 0.08); - --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --radius-card: 12px; - --radius-btn: 8px; - - font-family: var(--font-sans); - background: var(--color-bg); - color: var(--color-text); - min-height: 100vh; - display: flex; - flex-direction: column; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - transition: background 0.2s, color 0.2s; -} - -/* ── Dark mode ── */ -.app.dark, -.dark, -html.dark { - --color-bg: #080f1c; - --color-card: #0f1a2d; - --color-border: rgba(255, 255, 255, 0.09); - --color-text: #E0EAFF; - --color-text-muted: rgba(224, 234, 255, 0.48); - --color-nav-bg: rgba(8, 15, 28, 0.92); - - /* ThunderID Design Tokens for Dark Mode */ - --thunderid-color-background-surface: #0f1a2d; - --thunderid-color-background-body: #080f1c; - --thunderid-color-background-disabled: rgba(255, 255, 255, 0.06); - --thunderid-color-background-muted: rgba(255, 255, 255, 0.05); - --thunderid-color-text-primary: #E0EAFF; - --thunderid-color-text-secondary: rgba(224, 234, 255, 0.65); - --thunderid-color-border: rgba(255, 255, 255, 0.09); - --thunderid-color-border-focus: var(--color-primary); - --thunderid-input-borderColor: rgba(255, 255, 255, 0.16); - --thunderid-input-focusBorderColor: var(--color-primary); - --thunderid-input-focusRing: 0 0 0 3px rgba(54, 136, 255, 0.25); - --thunderid-card-borderColor: rgba(255, 255, 255, 0.09); - --thunderid-card-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - --thunderid-color-action-hover: rgba(255, 255, 255, 0.06); -} - -/* ── Navigation ── */ -.nav { - position: fixed; - inset-block-start: 0; - inset-inline: 0; - height: 52px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - background: var(--color-nav-bg); - border-bottom: 1px solid var(--color-nav-border); - z-index: 100; - transition: background 0.2s, border-color 0.2s; -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - text-decoration: none; - color: var(--color-text); -} - -.wordmark { - display: flex; - flex-direction: column; - gap: 1px; -} - -.wordmark-name { - font-size: 14px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - line-height: 1; -} - -.wordmark-sub { - font-size: 8.5px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-primary); - line-height: 1; -} - -.dark-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: transparent; - border: none; - border-radius: var(--radius-btn); - color: var(--color-text-muted); - cursor: pointer; - transition: color 0.15s, background 0.15s; -} - -.dark-toggle:hover { - color: var(--color-text); - background: rgba(54, 136, 255, 0.06); -} - -.nav-actions { - display: flex; - align-items: center; - gap: 8px; -} - -/* ── Nav back button ── */ -.nav-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 0 10px; - border-radius: 6px; - border: none; - background: transparent; - color: var(--color-text-muted); - font-size: 13px; - font-family: var(--font-sans); - letter-spacing: -0.01em; - cursor: pointer; - text-decoration: none; - transition: background 0.2s, color 0.2s; -} - -.nav-back-btn:hover { - background: var(--color-border); - color: var(--color-text); -} - -/* ── Buttons ── */ -button { - font-family: var(--font-sans); - cursor: pointer; - border: none; - border-radius: var(--radius-btn); - font-weight: 500; - transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s; - line-height: 1; - white-space: nowrap; -} - -button:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} - -.btn-primary { - background: var(--color-primary); - color: #ffffff; - border: 1.5px solid var(--color-primary); -} - -.btn-primary:hover { - background: var(--color-primary-hover); - border-color: var(--color-primary-hover); -} - -.btn-outline { - background: transparent; - color: var(--color-text); - border: 1.5px solid rgba(54, 136, 255, 0.4); -} - -.btn-outline:hover { - border-color: var(--color-primary); - color: var(--color-primary); -} - -.btn-ghost { - background: transparent; - color: var(--color-text-muted); - border: none; -} - -.btn-ghost:hover { - color: var(--color-text); - background: rgba(54, 136, 255, 0.06); -} - -.btn-sm { - padding: 6px 14px; - font-size: 13px; - height: 32px; -} - -.btn-lg { - padding: 0 24px; - font-size: 15px; - height: 46px; -} - -/* ── Hero section ── */ -.hero { - flex: 1; - padding: 96px 32px 80px; - min-height: 100vh; -} - -.hero-inner { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 20px; - max-width: 560px; - width: 100%; - margin: 0 auto; -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.hero-badge-line { - display: block; - width: 24px; - height: 1px; - background: var(--color-border); - flex-shrink: 0; -} - -.hero-title { - font-size: 42px; - font-weight: 700; - letter-spacing: -0.05em; - color: var(--color-text); - line-height: 1.1; - margin: 0; -} - -.hero-subtitle { - font-size: 16px; - color: var(--color-text-muted); - max-width: 420px; - line-height: 1.8; - letter-spacing: -0.01em; -} - -.hero-ctas { - display: flex; - gap: 12px; - flex-wrap: wrap; - justify-content: center; - margin-top: 4px; -} - -.hero-divider { - width: 100%; - border: none; - border-top: 1px solid var(--color-border); - margin: 8px 0; -} - -/* ── Stats row ── */ -.hero-stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - width: 100%; - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - overflow: hidden; -} - -.hero-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 20px 16px; - position: relative; -} - -.hero-stat + .hero-stat::before { - content: ''; - position: absolute; - left: 0; - top: 20%; - height: 60%; - width: 1px; - background: var(--color-border); -} - -.hero-stat-value { - font-size: 15px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.02em; -} - -.hero-stat-label { - font-size: 12px; - color: var(--color-text-muted); -} - -/* ── Config notice ── */ -.config-badge { - color: #e88b3a; -} - -.config-list { - width: 100%; - max-width: 420px; - text-align: left; - list-style: none; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - padding: 0 16px; - margin: 4px 0 28px; -} - -.config-list-item { - padding: 10px 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; - color: var(--color-text); -} - -.config-list-item:not(:last-child) { - border-bottom: 1px solid var(--color-border); -} - -.config-hint { - font-size: 14px; - color: var(--color-text-muted); - line-height: 1.7; - max-width: 420px; - margin: 0 auto; -} - -.config-hint code { - background: rgba(54, 136, 255, 0.1); - border-radius: 4px; - padding: 2px 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; -} - -.config-step { - width: 100%; - max-width: 420px; - margin: 0 auto 28px; -} - -.config-step-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-text-muted); - margin-bottom: 10px; - text-align: left; -} - -.config-step .config-list, -.config-step .config-hint { - margin-left: 0; - margin-right: 0; -} - -.config-box { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 16px; -} - -.config-box-body { - font-size: 13px; - color: var(--color-text-muted); - line-height: 1.6; - margin-bottom: 14px; -} - -.config-value-group { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-value-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); - margin-bottom: 4px; -} - -.config-value { - display: block; - overflow-x: auto; - white-space: nowrap; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: var(--color-text); - background: rgba(54, 136, 255, 0.1); - border-radius: 4px; - padding: 8px 10px; -} - -.config-docs-note { - font-size: 13px; - color: var(--color-text-muted); - max-width: 420px; - margin: 0 auto; -} - -.config-docs-note a { - color: var(--color-primary); - font-weight: 600; -} - -/* ── Responsive ── */ -@media (max-width: 480px) { - .nav { - padding: 0 16px; - } - - .hero { - padding: 80px 16px 32px; - } - - .hero-title { - font-size: 2rem; - } - - .hero-stats { - grid-template-columns: 1fr; - } - - .hero-stat + .hero-stat::before { - top: 0; - left: 20%; - width: 60%; - height: 1px; - } - - .hero-ctas { - flex-direction: column; - align-items: stretch; - } -} - -/* ── Signed-in shell ── */ -.home-shell { - min-height: calc(100vh - 52px); - margin-top: 52px; -} - -/* ── Signed-in home page ── */ -.home-main { - width: 100%; - max-width: 900px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -/* Greeting */ -.home-greeting { - display: flex; - align-items: flex-start; - gap: 16px; - margin-bottom: 36px; -} - -.home-greeting-text { - min-width: 0; -} - -.home-avatar { - width: 52px; - height: 52px; - border-radius: 50%; - flex-shrink: 0; - object-fit: cover; -} - -.home-avatar--fallback { - display: flex; - align-items: center; - justify-content: center; - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); - font-size: 18px; - font-weight: 700; - letter-spacing: -0.02em; -} - -.home-greeting-name { - margin: 0 0 6px; - font-size: 26px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.04em; -} - -.home-greeting-meta { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: var(--color-text-muted); - letter-spacing: -0.01em; - flex-wrap: wrap; -} - -.home-dot { - display: inline-block; - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--color-border); - flex-shrink: 0; -} - -.home-session-active { - display: inline-flex; - align-items: center; - gap: 5px; - color: #2fbd6b; -} - -.home-session-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: #2fbd6b; - flex-shrink: 0; -} - -/* Session stats row */ -.home-stats { - display: flex; - gap: 0; - padding: 20px 0; - border-top: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - margin-bottom: 52px; -} - -.home-stat { - flex: 1; - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.home-stat--bordered { - padding-left: 32px; - border-left: 1px solid var(--color-border); -} - -.home-stat-icon { - width: 32px; - height: 32px; - border-radius: 8px; - background: rgba(54, 136, 255, 0.1); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: #3688FF; -} - -.home-stat-value { - font-size: 14px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.02em; -} - -.home-stat-value--mono { - font-family: var(--font-mono, monospace); - letter-spacing: 0.01em; -} - -.home-stat-label { - font-size: 11px; - font-weight: 600; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.07em; - margin-top: 2px; -} - -/* What's next */ -.home-next-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-text-muted); - margin-bottom: 20px; -} - -.home-next-list { - display: flex; - flex-direction: column; -} - -.home-next-item { - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 16px; - align-items: start; - padding: 20px 0; - border-top: 1px solid var(--color-border); - text-decoration: none; - color: inherit; - cursor: pointer; - transition: background 0.2s; - border-radius: 4px; -} - -.home-next-item:hover .home-next-cta { - opacity: 1; -} - -.home-next-n { - font-size: 11px; - font-weight: 700; - color: #3688FF; - letter-spacing: 0.05em; - padding-top: 2px; - font-variant-numeric: tabular-nums; -} - -.home-next-body { - min-width: 0; -} - -.home-next-title { - font-size: 14px; - font-weight: 600; - color: var(--color-text); - letter-spacing: -0.02em; - margin-bottom: 4px; -} - -.home-next-desc { - font-size: 13px; - color: var(--color-text-muted); - line-height: 1.6; - letter-spacing: -0.01em; -} - -.home-next-cta { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 13px; - font-weight: 600; - color: #3688FF; - white-space: nowrap; - opacity: 0.7; - transition: opacity 0.2s; - padding-top: 2px; -} - -/* ── Sign in / Sign up pages ── */ -.auth-main { - width: 100%; - min-height: calc(100vh - 52px); - margin-top: 52px; - display: flex; - align-items: center; - justify-content: center; - padding: 40px 20px; -} - -.auth-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - padding: 40px 32px; - max-width: 420px; - width: 100%; -} - -.auth-title { - font-size: 1.5rem; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--color-text); - text-align: center; -} - -.auth-subtitle { - margin: 0 0 16px; - font-size: 13px; - color: var(--color-text-muted); - text-align: center; -} - -.auth-switch { - margin-top: 20px; - font-size: 13px; - color: var(--color-text-muted); - text-align: center; -} - -/* ── Profile page ── */ -.profile-main { - width: 100%; - max-width: 780px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -/* ── Token debug page ── */ -.token-main { - width: 100%; - max-width: 780px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -.token-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 36px; - flex-wrap: wrap; -} - -.token-title { - margin: 0 0 5px; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.04em; - color: var(--color-text); -} - -.token-subtitle { - margin: 0; - font-size: 13px; - color: var(--color-text-muted); - letter-spacing: -0.01em; -} - -.token-badge { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 700; - letter-spacing: -0.01em; - flex-shrink: 0; -} - -.token-badge--expired { - background: rgba(220, 38, 38, 0.08); - border: 1px solid rgba(220, 38, 38, 0.22); - color: #dc2626; -} - -.token-badge--valid { - background: rgba(47, 189, 107, 0.08); - border: 1px solid rgba(47, 189, 107, 0.22); - color: #2fbd6b; -} - -.token-badge-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; -} - -.token-raw-section { - margin-bottom: 20px; -} - -.token-raw-label-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.token-section-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-text-muted); -} - -.token-copy-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 5px; - background: transparent; - border: 1px solid var(--color-border); - font-size: 11px; - font-weight: 600; - color: var(--color-text-muted); - cursor: pointer; - font-family: var(--font-sans); - letter-spacing: -0.01em; - transition: border-color 0.2s, color 0.2s; -} - -.token-copy-btn:hover { - border-color: #3688FF; - color: #3688FF; -} - -.token-raw { - display: block; - font-size: 11px; - font-family: var(--font-mono, monospace); - word-break: break-all; - line-height: 1.8; - background: #0b1120; - padding: 14px 16px; - border-radius: 8px; - overflow-x: auto; -} - -.token-raw--loading { - color: rgba(255, 255, 255, 0.3); -} - -.token-part--header { color: #ff7b72; } -.token-part--payload { color: #79c0ff; } -.token-part--signature { color: #3fb950; } - -.token-dot { - color: rgba(255, 255, 255, 0.2); -} - -.token-decoded-grid { - display: grid; - grid-template-columns: 1fr 1.7fr; - gap: 12px; - margin-bottom: 20px; -} - -.token-code-box { - background: #0b1120; - border-radius: 10px; - overflow: hidden; -} - -.token-code-box-header { - padding: 9px 14px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.token-code-box-header--header { - color: #ff7b72; - border-bottom: 1px solid rgba(255, 123, 114, 0.15); -} - -.token-code-box-header--payload { - color: #79c0ff; - border-bottom: 1px solid rgba(121, 192, 255, 0.15); -} - -.token-code-pre { - margin: 0; - padding: 14px; - font-size: 11.5px; - line-height: 1.8; - font-family: var(--font-mono, monospace); - color: #c9d1d9; - overflow-x: auto; - max-height: 220px; -} - -.token-meta-row { - display: flex; - gap: 32px; - padding-top: 20px; - border-top: 1px solid var(--color-border); - flex-wrap: wrap; -} - -.token-meta-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-text-muted); - margin-bottom: 5px; -} - -.token-meta-value { - font-size: 12px; - font-family: var(--font-mono, monospace); - color: var(--color-text); - letter-spacing: -0.01em; -} - -/* ─── Account page (Google Account-style left-nav layout) ─────────── */ -.account-page { - display: flex; - align-items: flex-start; - min-height: calc(100vh - 52px); - margin-top: 52px; -} - -.account-sidebar { - width: 280px; - flex-shrink: 0; - padding: 40px 24px; - position: sticky; - top: 52px; -} - -.account-sidebar-title { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - margin: 0 0 24px 16px; -} - -.account-nav { - display: flex; - flex-direction: column; - gap: 2px; -} - -.account-nav-item { - display: flex; - align-items: center; - gap: 14px; - width: 100%; - padding: 11px 16px; - border: none; - border-radius: 20px; - background: transparent; - color: var(--color-text-muted); - font-size: 14px; - font-weight: 500; - font-family: inherit; - text-align: left; - cursor: pointer; - white-space: nowrap; - transition: background 0.2s, color 0.2s; -} - -.account-nav-item:hover { - background: var(--color-border); - color: var(--color-text); -} - -.account-nav-item--active, -.account-nav-item--active:hover { - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); -} - -.account-content { - flex: 1; - min-width: 0; - display: flex; - justify-content: center; - padding: 40px 32px 80px; - border-left: 1px solid var(--color-border); -} - -.account-content-inner { - width: 100%; - max-width: 720px; -} - -/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting - cramped in other contexts) fights this page's own responsive layout, refusing to shrink - below 600px even when the viewport is narrower. The Security/Home boxes have no such floor - and already shrink fluidly with their container, so match that here. */ -.account-content .thunderid-user-profile { - min-width: 0; - background: var(--color-card) !important; - border: 1px solid var(--color-border) !important; - border-radius: var(--radius-card) !important; - box-shadow: var(--shadow-card); - color: var(--color-text); - box-sizing: border-box; -} - -.account-content .thunderid-user-profile .thunderid-user-profile__hero { - border-bottom: 1px solid var(--color-border); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__hero-name { - color: var(--color-text); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__hero-subtitle { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field { - border-bottom: 1px solid var(--color-border); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-label { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-value { - color: var(--color-text); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-placeholder { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn { - color: var(--color-text-muted); -} - -.account-content .thunderid-user-profile .thunderid-user-profile__field-edit-btn:hover { - color: var(--color-primary); -} - -.account-content .thunderid-user-profile input, -.account-content .thunderid-user-profile select, -.account-content .thunderid-user-profile textarea { - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - color: var(--color-text); - padding: 6px 10px; -} - -.account-content-title { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - margin: 0 0 6px; -} - -.account-content-subtitle { - font-size: 14px; - color: var(--color-text-muted); - margin: 0 0 32px; -} - -.account-tiles { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 8px; -} - -.account-tile { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 22px; - cursor: pointer; - font-family: inherit; - display: flex; - flex-direction: column; - transition: border-color 0.2s; -} - -.account-tile:hover { - border-color: rgba(54, 136, 255, 0.35); -} - -.account-tile-icon { - width: 36px; - height: 36px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; -} - -.account-tile-icon--blue { - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); -} - -.account-tile-icon--green { - background: rgba(16, 185, 129, 0.12); - color: #10b981; -} - -.account-tile-title { - font-size: 15px; - font-weight: 600; - color: var(--color-text); - margin-bottom: 4px; -} - -.account-tile-desc { - font-size: 12.5px; - color: var(--color-text-muted); - line-height: 1.5; -} - -.account-security-list { - display: flex; - flex-direction: column; - gap: 16px; -} - -.account-security-card { - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 20px 22px; -} - -.account-security-card-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.account-security-card-title { - font-size: 14px; - font-weight: 600; - color: var(--color-text); - margin-bottom: 2px; -} - -.account-security-card-desc { - font-size: 12.5px; - color: var(--color-text-muted); -} - -.account-security-card-cta { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - padding: 0; - background: none; - border: none; - color: var(--color-primary); - font-size: 13px; - font-weight: 500; - font-family: inherit; - cursor: pointer; - transition: opacity 0.2s; -} - -.account-security-card-cta:hover { - opacity: 0.8; -} - -.account-security-card-chevron { - display: inline-flex; - transition: transform 0.2s; -} - -.account-security-card-chevron--open { - transform: rotate(180deg); -} - -.account-security-card-form { - margin-top: 18px; - padding-top: 18px; - border-top: 1px solid var(--color-border); -} - -@media (max-width: 760px) { - .account-page { - flex-direction: column; - } - .account-sidebar { - width: 100%; - position: static; - padding: 20px 16px 8px; - } - .account-nav { - flex-direction: row; - overflow-x: auto; - } - .account-content { - max-width: 100%; - border-left: none; - border-top: 1px solid var(--color-border); - padding: 24px 20px 60px; - } -} - -/* ── ThunderID inputs & forms dark mode ── */ -.dark .thunderid-password-field__wrapper, -html.dark .thunderid-password-field__wrapper, -.app.dark .thunderid-password-field__wrapper, -.dark .thunderid-text-field__wrapper, -html.dark .thunderid-text-field__wrapper, -.app.dark .thunderid-text-field__wrapper, -.dark .thunderid-text-field__input, -html.dark .thunderid-text-field__input, -.app.dark .thunderid-text-field__input { - background-color: rgba(255, 255, 255, 0.05) !important; - border-color: rgba(255, 255, 255, 0.16) !important; - color: var(--color-text) !important; -} - -.dark .thunderid-password-field__label, -html.dark .thunderid-password-field__label, -.app.dark .thunderid-password-field__label, -.dark .thunderid-text-field__label, -html.dark .thunderid-text-field__label, -.app.dark .thunderid-text-field__label, -.dark .thunderid-input-label, -html.dark .thunderid-input-label, -.app.dark .thunderid-input-label { - color: var(--color-text) !important; -} - -.dark .thunderid-password-field__input, -html.dark .thunderid-password-field__input, -.app.dark .thunderid-password-field__input { - background: transparent !important; - color: var(--color-text) !important; -} - -.dark .thunderid-password-field__input::placeholder, -html.dark .thunderid-password-field__input::placeholder, -.app.dark .thunderid-password-field__input::placeholder, -.dark .thunderid-text-field__input::placeholder, -html.dark .thunderid-text-field__input::placeholder, -.app.dark .thunderid-text-field__input::placeholder { - color: var(--color-text-muted) !important; -} - -.dark .thunderid-password-field__toggle, -html.dark .thunderid-password-field__toggle, -.app.dark .thunderid-password-field__toggle { - color: var(--color-text-muted) !important; - background: transparent !important; -} - -.dark .thunderid-password-field__toggle:hover, -html.dark .thunderid-password-field__toggle:hover, -.app.dark .thunderid-password-field__toggle:hover { - color: var(--color-text) !important; -} - -.dark .thunderid-change-credential__requirements-heading, -html.dark .thunderid-change-credential__requirements-heading, -.app.dark .thunderid-change-credential__requirements-heading, -.dark .thunderid-change-credential__requirement, -html.dark .thunderid-change-credential__requirement, -.app.dark .thunderid-change-credential__requirement { - color: var(--color-text-muted) !important; -} - -.dark .thunderid-change-credential__requirement--passed, -html.dark .thunderid-change-credential__requirement--passed, -.app.dark .thunderid-change-credential__requirement--passed { - color: #38c172 !important; -} - -/* ── ThunderID UserDropdown dark mode ── */ -.dark .thunderid-user-dropdown__menu, -html.dark .thunderid-user-dropdown__menu, -.app.dark .thunderid-user-dropdown__menu, -.dark .thunderid-user-dropdown__content, -html.dark .thunderid-user-dropdown__content, -.app.dark .thunderid-user-dropdown__content { - background-color: var(--color-card) !important; - border: 1px solid var(--color-border) !important; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important; - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__menu-header, -html.dark .thunderid-user-dropdown__menu-header, -.app.dark .thunderid-user-dropdown__menu-header, -.dark .thunderid-user-dropdown__header, -html.dark .thunderid-user-dropdown__header, -.app.dark .thunderid-user-dropdown__header { - border-bottom: 1px solid var(--color-border) !important; -} - -.dark .thunderid-user-dropdown__menu-header-name, -html.dark .thunderid-user-dropdown__menu-header-name, -.app.dark .thunderid-user-dropdown__menu-header-name, -.dark .thunderid-user-dropdown__header-name, -html.dark .thunderid-user-dropdown__header-name, -.app.dark .thunderid-user-dropdown__header-name { - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__menu-header-subtitle, -html.dark .thunderid-user-dropdown__menu-header-subtitle, -.app.dark .thunderid-user-dropdown__menu-header-subtitle, -.dark .thunderid-user-dropdown__header-subtitle, -html.dark .thunderid-user-dropdown__header-subtitle, -.app.dark .thunderid-user-dropdown__header-subtitle { - color: var(--color-text-muted) !important; -} - -.dark .thunderid-user-dropdown__menu-divider, -html.dark .thunderid-user-dropdown__menu-divider, -.app.dark .thunderid-user-dropdown__menu-divider, -.dark .thunderid-divider, -html.dark .thunderid-divider, -.app.dark .thunderid-divider { - background-color: var(--color-border) !important; - border-color: var(--color-border) !important; -} - -.dark .thunderid-user-dropdown__item, -html.dark .thunderid-user-dropdown__item, -.app.dark .thunderid-user-dropdown__item { - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__item:hover, -html.dark .thunderid-user-dropdown__item:hover, -.app.dark .thunderid-user-dropdown__item:hover { - background-color: rgba(255, 255, 255, 0.06) !important; - color: var(--color-text) !important; -} - -.dark .thunderid-user-dropdown__item--danger, -html.dark .thunderid-user-dropdown__item--danger, -.app.dark .thunderid-user-dropdown__item--danger { - color: #f87171 !important; -} - -.dark .thunderid-user-dropdown__item--danger:hover, -html.dark .thunderid-user-dropdown__item--danger:hover, -.app.dark .thunderid-user-dropdown__item--danger:hover { - background-color: rgba(239, 68, 68, 0.12) !important; -} - -.dark .thunderid-card, -html.dark .thunderid-card, -.app.dark .thunderid-card { - background-color: var(--color-card) !important; - border-color: var(--color-border) !important; - color: var(--color-text) !important; -} - diff --git a/samples/nuxt/quickstart/app/components/AppNav.vue b/samples/nuxt/quickstart/app/components/AppNav.vue deleted file mode 100644 index 91ac758c..00000000 --- a/samples/nuxt/quickstart/app/components/AppNav.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/components/ConfigNotice.vue b/samples/nuxt/quickstart/app/components/ConfigNotice.vue deleted file mode 100644 index 52d595fa..00000000 --- a/samples/nuxt/quickstart/app/components/ConfigNotice.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/pages/account.vue b/samples/nuxt/quickstart/app/pages/account.vue deleted file mode 100644 index addd18bd..00000000 --- a/samples/nuxt/quickstart/app/pages/account.vue +++ /dev/null @@ -1,162 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/pages/index.vue b/samples/nuxt/quickstart/app/pages/index.vue deleted file mode 100644 index fe0b7abb..00000000 --- a/samples/nuxt/quickstart/app/pages/index.vue +++ /dev/null @@ -1,234 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/pages/signin.vue b/samples/nuxt/quickstart/app/pages/signin.vue deleted file mode 100644 index adcb418c..00000000 --- a/samples/nuxt/quickstart/app/pages/signin.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/pages/signup.vue b/samples/nuxt/quickstart/app/pages/signup.vue deleted file mode 100644 index 6e8405e1..00000000 --- a/samples/nuxt/quickstart/app/pages/signup.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/app/pages/token.vue b/samples/nuxt/quickstart/app/pages/token.vue deleted file mode 100644 index b4505049..00000000 --- a/samples/nuxt/quickstart/app/pages/token.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - diff --git a/samples/nuxt/quickstart/nuxt.config.ts b/samples/nuxt/quickstart/nuxt.config.ts deleted file mode 100644 index 229cee98..00000000 --- a/samples/nuxt/quickstart/nuxt.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -// https://nuxt.com/docs/api/configuration/nuxt-config -export default defineNuxtConfig({ - compatibilityDate: '2025-07-15', - devtools: { enabled: true }, - modules: ['@thunderid/nuxt'], - thunderid: { - // Serves POST /api/auth/backchannel-logout, so signing out elsewhere ends the session here. - backchannelLogout: { enabled: true }, - }, - css: ['~/assets/styles.css'], -}) diff --git a/samples/nuxt/quickstart/package.json b/samples/nuxt/quickstart/package.json deleted file mode 100644 index c5b52b1e..00000000 --- a/samples/nuxt/quickstart/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@thunderid/nuxt-quickstart", - "private": true, - "version": "0.0.0", - "scripts": { - "prepare-dev": "node scripts/prepare-dev.cjs", - "prepare-dev:redirect": "node scripts/prepare-dev.cjs --flow=redirect", - "dev": "TMPDIR=/tmp/nuxt-tmp/ nuxt dev", - "build": "nuxt build", - "generate": "nuxt generate", - "preview": "nuxt preview" - }, - "dependencies": { - "@thunderid/nuxt": "workspace:*", - "nuxt": "^4.5.2", - "vue": "^3.5.13" - } -} diff --git a/samples/nuxt/quickstart/scripts/prepare-dev.cjs b/samples/nuxt/quickstart/scripts/prepare-dev.cjs deleted file mode 100644 index 1121a5a2..00000000 --- a/samples/nuxt/quickstart/scripts/prepare-dev.cjs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('node:fs'); -const path = require('node:path'); - -const root = path.join(__dirname, '..'); - -const PREFIX = 'NUXT_PUBLIC_THUNDERID_'; -const NATIVE_FLOW_VARS = [`${PREFIX}APPLICATION_ID`, `${PREFIX}SIGN_IN_URL`, `${PREFIX}SIGN_UP_URL`]; -const REDIRECT_FLOW_VARS = [`${PREFIX}CLIENT_ID`, 'THUNDERID_CLIENT_SECRET']; -const REDIRECT_FLOW_PLACEHOLDERS = { - [`${PREFIX}CLIENT_ID`]: 'your-client-id-here', - THUNDERID_CLIENT_SECRET: 'your-client-secret-here', -}; - -const flowArg = process.argv.find((arg) => arg.startsWith('--flow=')); -const flowExplicitlyRequested = Boolean(flowArg); -const flow = flowArg ? flowArg.slice('--flow='.length) : 'native'; - -if (flowExplicitlyRequested && flow !== 'native' && flow !== 'redirect') { - console.error(`Unknown --flow value "${flow}". Expected "native" or "redirect".`); - process.exit(1); -} - -/** - * Toggles the leading `# ` on env var lines to match the selected flow, appending - * any vars for the selected flow that aren't present in the source file yet (the - * redirect-flow vars aren't checked into `.env.example`, so switching to `redirect` - * from a fresh copy needs to add them rather than just uncomment them). - */ -function applyFlow(envContent, selectedFlow) { - const varsToEnable = selectedFlow === 'redirect' ? REDIRECT_FLOW_VARS : NATIVE_FLOW_VARS; - const varsToDisable = selectedFlow === 'redirect' ? NATIVE_FLOW_VARS : REDIRECT_FLOW_VARS; - const found = new Set(); - - const lines = envContent.split('\n').map((line) => { - const enable = varsToEnable.find((key) => line.replace(/^#\s*/, '').startsWith(`${key}=`)); - if (enable) { - found.add(enable); - return line.replace(/^#\s*/, ''); - } - - const disable = varsToDisable.find((key) => line.startsWith(`${key}=`)); - if (disable) return `# ${line}`; - - return line; - }); - - const missing = varsToEnable.filter((key) => !found.has(key)); - if (missing.length > 0) { - lines.push('', `# ── ${selectedFlow} flow ─────────────────────────────────────────────────`); - for (const key of missing) lines.push(`${key}=${REDIRECT_FLOW_PLACEHOLDERS[key] ?? ''}`); - } - - return lines.join('\n'); -} - -const envExample = path.join(root, '.env.example'); -const envTarget = path.join(root, '.env'); - -// Only explicit `--flow=` invocations are allowed to (re)write an existing -// .env β€” a plain `npm run prepare-dev` (e.g. from .stackblitzrc's -// startCommand) stays a safe, idempotent copy-if-missing. -if (fs.existsSync(envExample) && (flowExplicitlyRequested || !fs.existsSync(envTarget))) { - const envSource = fs.existsSync(envTarget) ? envTarget : envExample; - const envContent = applyFlow(fs.readFileSync(envSource, 'utf8'), flow) - // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) - // so the copied .env still trips the app's missing-env-var check until real - // values are filled in. - .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); - fs.writeFileSync(envTarget, envContent); -} - -const pkgPath = path.join(root, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -for (const field of ['dependencies', 'devDependencies']) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, version] of Object.entries(deps)) { - if (version.startsWith('workspace:')) { - deps[name] = 'latest'; - } - } -} - -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/react/quickstart/.env.example b/samples/react/quickstart/.env.example deleted file mode 100644 index e38f4022..00000000 --- a/samples/react/quickstart/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Base URL of your ThunderID deployment. ex: https://localhost:8090 -VITE_THUNDERID_BASE_URL=https://localhost:8090 - -# OAuth2 Client ID for this app. ThunderID Console -> your application -> Overview. -VITE_THUNDERID_CLIENT_ID=your-client-id-here diff --git a/samples/react/quickstart/.stackblitzrc b/samples/react/quickstart/.stackblitzrc deleted file mode 100644 index 5f0a2000..00000000 --- a/samples/react/quickstart/.stackblitzrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "installDependencies": false, - "startCommand": "npm run prepare-dev && npm install && npm run dev" -} diff --git a/samples/react/quickstart/README.md b/samples/react/quickstart/README.md deleted file mode 100644 index 47e77e45..00000000 --- a/samples/react/quickstart/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# ThunderID React Quickstart - -Open in StackBlitz - -A minimal React + Vite application demonstrating ThunderID authentication with OAuth 2.0, PKCE, and JWT support using the `@thunderid/react` SDK. - -## Prerequisites - -- Node.js 18+ -- pnpm -- A ThunderID application - -## Setup - -1. Copy the environment template: - ```sh - cp .env.example .env - ``` - -2. Fill in your ThunderID credentials in `.env`: - ``` - VITE_THUNDERID_CLIENT_ID= - VITE_THUNDERID_BASE_URL=https://your-thunderid-instance - ``` - -3. Install dependencies and start the dev server: - ```sh - pnpm install - pnpm dev - ``` - -The app will be available at `http://localhost:5173`. - -## Docs - -Full SDK reference: [thunderid.dev/docs](https://thunderid.dev/docs) diff --git a/samples/react/quickstart/index.html b/samples/react/quickstart/index.html deleted file mode 100644 index 562e0aa8..00000000 --- a/samples/react/quickstart/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - ThunderID React Quickstart - - -
    - - - diff --git a/samples/react/quickstart/package.json b/samples/react/quickstart/package.json deleted file mode 100644 index 675c73b2..00000000 --- a/samples/react/quickstart/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@thunderid/react-quickstart", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "prepare-dev": "node scripts/prepare-dev.cjs", - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "@thunderid/react": "workspace:*", - "@thunderid/react-router": "workspace:*", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "react-router": "^7.18.2" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.5.2", - "vite": "^6.3.5" - } -} diff --git a/samples/react/quickstart/scripts/prepare-dev.cjs b/samples/react/quickstart/scripts/prepare-dev.cjs deleted file mode 100644 index 8e20fa43..00000000 --- a/samples/react/quickstart/scripts/prepare-dev.cjs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('node:fs'); -const path = require('node:path'); - -const root = path.join(__dirname, '..'); - -const envExample = path.join(root, '.env.example'); -const envTarget = path.join(root, '.env'); -if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) { - // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) - // so the copied .env still trips the app's missing-env-var check until real - // values are filled in. - const envContent = fs - .readFileSync(envExample, 'utf8') - .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); - fs.writeFileSync(envTarget, envContent); -} - -const pkgPath = path.join(root, 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -for (const field of ['dependencies', 'devDependencies']) { - const deps = pkg[field]; - if (!deps) continue; - for (const [name, version] of Object.entries(deps)) { - if (version.startsWith('workspace:')) { - deps[name] = 'latest'; - } - } -} - -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/react/quickstart/src/App.css b/samples/react/quickstart/src/App.css deleted file mode 100644 index 741c085a..00000000 --- a/samples/react/quickstart/src/App.css +++ /dev/null @@ -1,1069 +0,0 @@ -/* ─── Design tokens ─────────────────────────────────────────────── */ -:root { - --color-primary: #3688FF; - --color-navy: #05213F; - --color-bg: #F7F9FC; - --color-card: #ffffff; - --color-border: #DDE3EC; - --color-muted: #5A7085; - --color-text: #05213F; - --nav-bg: rgba(247, 249, 252, 0.92); - --nav-height: 52px; - --radius-card: 12px; - --radius-btn: 8px; - --transition: 0.2s ease; -} - -[data-theme='dark'] { - --color-bg: #080f1c; - --color-card: #0f1a2d; - --color-border: rgba(255, 255, 255, 0.09); - --color-text: #E0EAFF; - --color-muted: rgba(224, 234, 255, 0.48); - --color-navy: #E0EAFF; - --nav-bg: rgba(8, 15, 28, 0.92); -} - -/* ─── App shell ─────────────────────────────────────────────────── */ -.app { - min-height: 100vh; - background: var(--color-bg); - color: var(--color-text); - transition: background var(--transition), color var(--transition); -} - -/* ─── Nav ───────────────────────────────────────────────────────── */ -.nav { - position: fixed; - top: 0; - left: 0; - right: 0; - height: var(--nav-height); - display: flex; - align-items: center; - padding: 0 28px; - background: var(--nav-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid var(--color-border); - z-index: 100; - gap: 12px; -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - text-decoration: none; - color: inherit; - flex: 1; -} - -.wordmark { - display: flex; - flex-direction: column; - gap: 0; -} -.wordmark-name { - font-size: 14px; - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.1; - color: var(--color-text); -} -.wordmark-sub { - font-size: 8.5px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #3688FF; - line-height: 1; - margin-top: 1.5px; -} - -.nav-actions { - display: flex; - align-items: center; - gap: 8px; -} - -/* ─── Dark-mode toggle ──────────────────────────────────────────── */ -.dark-toggle { - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border-radius: 8px; - border: 1px solid var(--color-border); - background: transparent; - color: var(--color-muted); - cursor: pointer; - font-size: 15px; - transition: border-color var(--transition), color var(--transition), background var(--transition); - flex-shrink: 0; -} - -.dark-toggle:hover { - background: var(--color-border); - color: var(--color-text); -} - -/* ─── Buttons ───────────────────────────────────────────────────── */ -.btn-primary, -.btn-outline, -.btn-ghost { - display: inline-flex; - align-items: center; - justify-content: center; - height: 46px; - padding: 0 24px; - border-radius: var(--radius-btn); - font-size: 15px; - font-weight: 600; - cursor: pointer; - transition: opacity var(--transition), background var(--transition), border-color var(--transition); - white-space: nowrap; - text-decoration: none; - font-family: inherit; -} - -.btn-primary { - background: var(--color-primary); - color: #fff; - border: 1.5px solid var(--color-primary); -} - -.btn-primary:hover { - opacity: 0.88; -} - -.btn-outline { - background: transparent; - color: var(--color-primary); - border: 1.5px solid rgba(54, 136, 255, 0.4); -} - -.btn-outline:hover { - background: rgba(54, 136, 255, 0.08); -} - -.btn-ghost { - background: transparent; - color: var(--color-muted); - border: none; -} -.btn-ghost:hover { - background: rgba(0, 0, 0, 0.04); -} - -/* Nav-sized buttons (slightly shorter) */ -.nav .btn-primary, -.nav .btn-outline, -.nav .btn-ghost { - height: 36px; - padding: 0 16px; - font-size: 14px; -} - -/* ─── Loading ───────────────────────────────────────────────────── */ -.loading-screen { - min-height: calc(100vh - var(--nav-height)); - margin-top: var(--nav-height); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-muted); - font-size: 15px; -} - -/* ─── Hero ──────────────────────────────────────────────────────── */ -.hero { - min-height: calc(100vh - var(--nav-height)); - margin-top: var(--nav-height); - padding: 96px 32px 80px; -} - -.home-shell { - min-height: calc(100vh - var(--nav-height)); - margin-top: var(--nav-height); -} - -.hero-inner { - width: 100%; - max-width: 560px; - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 0; -} - -.hero-mark { display: flex; justify-content: center; margin-bottom: 32px; } - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-muted); - margin-bottom: 28px; -} -.hero-badge-line { - display: block; - height: 1px; - width: 24px; - background: var(--color-border); - flex-shrink: 0; -} - -.hero-title { - font-size: 42px; - font-weight: 700; - letter-spacing: -0.05em; - line-height: 1.1; - color: var(--color-text); - margin: 0 0 18px; -} - -.hero-subtitle { - font-size: 16px; - color: var(--color-muted); - max-width: 420px; - line-height: 1.8; - letter-spacing: -0.01em; - margin: 0 auto 36px; -} - -.hero-ctas { - display: flex; - justify-content: center; - gap: 10px; - margin-bottom: 52px; -} - -.hero-divider { - width: 100%; - border: none; - border-top: 1px solid var(--color-border); - margin: 4px 0; -} - -/* ─── Stats row ─────────────────────────────────────────────────── */ -.hero-stats { - width: 100%; - display: grid; - grid-template-columns: repeat(3, 1fr); -} - -.stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 16px 8px; -} - -.stat:not(:last-child) { - border-right: 1px solid var(--color-border); -} - -.stat-value { - font-size: 15px; - font-weight: 700; - color: var(--color-text); -} - -.stat-label { - font-size: 12px; - color: var(--color-muted); -} - -/* ─── Config notice ─────────────────────────────────────────────────── */ -.config-badge { - color: #e88b3a; -} - -.config-list { - width: 100%; - max-width: 420px; - text-align: left; - list-style: none; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-btn); - padding: 0 16px; - margin: 4px 0 28px; -} - -.config-list-item { - padding: 10px 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; - color: var(--color-text); -} - -.config-list-item:not(:last-child) { - border-bottom: 1px solid var(--color-border); -} - -.config-hint { - font-size: 14px; - color: var(--color-muted); - line-height: 1.7; - max-width: 420px; - margin: 0 auto; -} - -.config-hint code { - background: rgba(54, 136, 255, 0.08); - border-radius: 4px; - padding: 2px 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; -} - -.config-step { - width: 100%; - max-width: 420px; - margin: 0 auto 28px; -} - -.config-step-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 10px; - text-align: left; -} - -.config-step .config-list, -.config-step .config-hint { - margin-left: 0; - margin-right: 0; -} - -.config-box { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 16px; -} - -.config-box-body { - font-size: 13px; - color: var(--color-muted); - line-height: 1.6; - margin-bottom: 14px; -} - -.config-value-row { - display: flex; - align-items: center; - gap: 8px; -} - -.config-value { - flex: 1; - min-width: 0; - overflow-x: auto; - white-space: nowrap; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: var(--color-text); - background: rgba(54, 136, 255, 0.08); - border-radius: 4px; - padding: 8px 10px; -} - -.config-value-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-muted); - margin-bottom: 4px; -} - -.config-value-group { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-docs-note { - font-size: 13px; - color: var(--color-muted); - max-width: 420px; - margin: 0 auto; -} - -.config-docs-note a { - color: var(--color-primary); - font-weight: 600; -} - -/* ─── Signed-in home page ───────────────────────────────────────────── */ -.home-main { - width: 100%; - max-width: 900px; - margin: 0 auto; - padding: 52px 28px 80px; -} - -/* Greeting */ -.home-greeting { - display: flex; - align-items: flex-start; - gap: 16px; - margin-bottom: 36px; -} - -.home-greeting-text { - min-width: 0; -} - -.home-greeting-name { - margin: 0 0 6px; - font-size: 26px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.04em; -} - -.home-greeting-meta { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: var(--color-muted); - letter-spacing: -0.01em; - flex-wrap: wrap; -} - -.home-dot { - display: inline-block; - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--color-border); - flex-shrink: 0; -} - -.home-session-active { - display: inline-flex; - align-items: center; - gap: 5px; - color: #2fbd6b; -} - -.home-session-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: #2fbd6b; - flex-shrink: 0; -} - -/* Session stats row */ -.home-stats { - display: flex; - gap: 0; - padding: 20px 0; - border-top: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - margin-bottom: 52px; -} - -.home-stat { - flex: 1; - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.home-stat--bordered { - padding-left: 32px; - border-left: 1px solid var(--color-border); -} - -.home-stat-icon { - width: 32px; - height: 32px; - border-radius: 8px; - background: rgba(54, 136, 255, 0.1); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: #3688FF; -} - -.home-stat-value { - font-size: 14px; - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.02em; -} - -.home-stat-value--mono { - font-family: var(--font-mono, monospace); - letter-spacing: 0.01em; -} - -.home-stat-label { - font-size: 11px; - font-weight: 600; - color: var(--color-muted); - text-transform: uppercase; - letter-spacing: 0.07em; - margin-top: 2px; -} - -/* What's next */ -.home-next-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 20px; -} - -.home-next-list { - display: flex; - flex-direction: column; -} - -.home-next-item { - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 16px; - align-items: start; - padding: 20px 0; - border-top: 1px solid var(--color-border); - text-decoration: none; - color: inherit; - cursor: pointer; - transition: background var(--transition); - border-radius: 4px; -} - -.home-next-item:hover .home-next-cta { - opacity: 1; -} - -.home-next-n { - font-size: 11px; - font-weight: 700; - color: #3688FF; - letter-spacing: 0.05em; - padding-top: 2px; - font-variant-numeric: tabular-nums; -} - -.home-next-body { - min-width: 0; -} - -.home-next-title { - font-size: 14px; - font-weight: 600; - color: var(--color-text); - letter-spacing: -0.02em; - margin-bottom: 4px; -} - -.home-next-desc { - font-size: 13px; - color: var(--color-muted); - line-height: 1.6; - letter-spacing: -0.01em; -} - -.home-next-cta { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 13px; - font-weight: 600; - color: #3688FF; - white-space: nowrap; - opacity: 0.7; - transition: opacity var(--transition); - padding-top: 2px; -} - -/* ─── Nav back button ───────────────────────────────────────────── */ -.nav-back-btn { - display: inline-flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 0 10px; - border-radius: 6px; - border: none; - background: transparent; - color: var(--color-muted); - font-size: 13px; - font-family: inherit; - letter-spacing: -0.01em; - cursor: pointer; - transition: background var(--transition), color var(--transition); -} - -.nav-back-btn:hover { - background: var(--color-border); - color: var(--color-text); -} - -/* ─── Account page (Google Account-style left-nav layout) ─────────── */ -.account-page { - display: flex; - align-items: flex-start; - min-height: calc(100vh - var(--nav-height)); - margin-top: var(--nav-height); -} - -.account-sidebar { - width: 280px; - flex-shrink: 0; - padding: 40px 24px; - position: sticky; - top: var(--nav-height); -} - -.account-sidebar-title { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - margin: 0 0 24px 16px; -} - -.account-nav { - display: flex; - flex-direction: column; - gap: 2px; -} - -.account-nav-item { - display: flex; - align-items: center; - gap: 14px; - width: 100%; - padding: 11px 16px; - border: none; - border-radius: 20px; - background: transparent; - color: var(--color-muted); - font-size: 14px; - font-weight: 500; - font-family: inherit; - text-align: left; - cursor: pointer; - white-space: nowrap; - transition: background var(--transition), color var(--transition); -} - -.account-nav-item:hover { - background: var(--color-border); - color: var(--color-text); -} - -.account-nav-item--active, -.account-nav-item--active:hover { - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); -} - -.account-content { - flex: 1; - min-width: 0; - display: flex; - justify-content: center; - padding: 40px 32px 80px; - border-left: 1px solid var(--color-border); -} - -.account-content-inner { - width: 100%; - max-width: 720px; -} - -/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting - cramped in other contexts) fights this page's own responsive layout, refusing to shrink - below 600px even when the viewport is narrower. The Security/Home boxes have no such floor - and already shrink fluidly with their container, so match that here. */ -.account-content .thunderid-user-profile { - min-width: 0; -} - -.account-content-title { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--color-text); - margin: 0 0 6px; -} - -.account-content-subtitle { - font-size: 14px; - color: var(--color-muted); - margin: 0 0 32px; -} - -.account-tiles { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 8px; -} - -.account-tile { - text-align: left; - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 22px; - cursor: pointer; - font-family: inherit; - display: flex; - flex-direction: column; - transition: border-color var(--transition); -} - -.account-tile:hover { - border-color: rgba(54, 136, 255, 0.35); -} - -.account-tile-icon { - width: 36px; - height: 36px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; -} - -.account-tile-icon--blue { - background: rgba(54, 136, 255, 0.12); - color: var(--color-primary); -} - -.account-tile-icon--green { - background: rgba(16, 185, 129, 0.12); - color: #10b981; -} - -.account-tile-title { - font-size: 15px; - font-weight: 600; - color: var(--color-text); - margin-bottom: 4px; -} - -.account-tile-desc { - font-size: 12.5px; - color: var(--color-muted); - line-height: 1.5; -} - -.account-security-list { - display: flex; - flex-direction: column; - gap: 16px; -} - -.account-security-card { - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-card); - padding: 20px 22px; -} - -.account-security-card-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.account-security-card-title { - font-size: 14px; - font-weight: 600; - color: var(--color-text); - margin-bottom: 2px; -} - -.account-security-card-desc { - font-size: 12.5px; - color: var(--color-muted); -} - -.account-security-card-cta { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - padding: 0; - background: none; - border: none; - color: var(--color-primary); - font-size: 13px; - font-weight: 500; - font-family: inherit; - cursor: pointer; - transition: opacity var(--transition); -} - -.account-security-card-cta:hover { - opacity: 0.8; -} - -.account-security-card-chevron { - display: inline-flex; - transition: transform 0.2s; -} - -.account-security-card-chevron--open { - transform: rotate(180deg); -} - -.account-security-card-form { - margin-top: 18px; - padding-top: 18px; - border-top: 1px solid var(--color-border); -} - -@media (max-width: 760px) { - .account-page { - flex-direction: column; - } - .account-sidebar { - width: 100%; - position: static; - padding: 20px 16px 8px; - } - .account-nav { - flex-direction: row; - overflow-x: auto; - } - .account-content { - max-width: 100%; - border-left: none; - border-top: 1px solid var(--color-border); - padding: 24px 20px 60px; - } -} - -/* ─── Token debug page ──────────────────────────────────────────── */ -.token-main { - width: 100%; - max-width: 780px; - margin: 0 auto; - margin-top: var(--nav-height); - padding: 52px 28px 80px; -} - -.token-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 36px; - flex-wrap: wrap; -} - -.token-title { - margin: 0 0 5px; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.04em; - color: var(--color-text); -} - -.token-subtitle { - margin: 0; - font-size: 13px; - color: var(--color-muted); - letter-spacing: -0.01em; -} - -.token-badge { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 700; - letter-spacing: -0.01em; - flex-shrink: 0; -} - -.token-badge--expired { - background: rgba(220, 38, 38, 0.08); - border: 1px solid rgba(220, 38, 38, 0.22); - color: #dc2626; -} - -.token-badge--valid { - background: rgba(47, 189, 107, 0.08); - border: 1px solid rgba(47, 189, 107, 0.22); - color: #2fbd6b; -} - -.token-badge-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - flex-shrink: 0; -} - -/* Raw token section */ -.token-raw-section { - margin-bottom: 20px; -} - -.token-raw-label-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.token-section-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); -} - -.token-copy-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 5px; - background: transparent; - border: 1px solid var(--color-border); - font-size: 11px; - font-weight: 600; - color: var(--color-muted); - cursor: pointer; - font-family: inherit; - letter-spacing: -0.01em; - transition: border-color var(--transition), color var(--transition); -} - -.token-copy-btn:hover { - border-color: #3688FF; - color: #3688FF; -} - -.token-raw { - display: block; - font-size: 11px; - font-family: var(--font-mono, monospace); - word-break: break-all; - line-height: 1.8; - background: #0b1120; - padding: 14px 16px; - border-radius: 8px; - overflow-x: auto; -} - -.token-raw--loading { - color: rgba(255, 255, 255, 0.3); -} - -.token-part--header { color: #ff7b72; } -.token-part--payload { color: #79c0ff; } -.token-part--signature { color: #3fb950; } - -.token-dot { - color: rgba(255, 255, 255, 0.2); -} - -/* Decoded grid */ -.token-decoded-grid { - display: grid; - grid-template-columns: 1fr 1.7fr; - gap: 12px; - margin-bottom: 20px; -} - -.token-code-box { - background: #0b1120; - border-radius: 10px; - overflow: hidden; -} - -.token-code-box-header { - padding: 9px 14px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; -} - -.token-code-box-header--header { - color: #ff7b72; - border-bottom: 1px solid rgba(255, 123, 114, 0.15); -} - -.token-code-box-header--payload { - color: #79c0ff; - border-bottom: 1px solid rgba(121, 192, 255, 0.15); -} - -.token-code-pre { - margin: 0; - padding: 14px; - font-size: 11.5px; - line-height: 1.8; - font-family: var(--font-mono, monospace); - color: #c9d1d9; - overflow-x: auto; - max-height: 220px; -} - -/* Meta row */ -.token-meta-row { - display: flex; - gap: 32px; - padding-top: 20px; - border-top: 1px solid var(--color-border); - flex-wrap: wrap; -} - -.token-meta-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--color-muted); - margin-bottom: 5px; -} - -.token-meta-value { - font-size: 12px; - font-family: var(--font-mono, monospace); - color: var(--color-text); - letter-spacing: -0.01em; -} diff --git a/samples/react/quickstart/src/App.jsx b/samples/react/quickstart/src/App.jsx deleted file mode 100644 index e9b27ab8..00000000 --- a/samples/react/quickstart/src/App.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createBrowserRouter, RouterProvider } from 'react-router' -import { ProtectedRoute } from '@thunderid/react-router' -import Nav from './components/Nav' -import HomePage from './pages/HomePage' -import TokenDebugPage from './pages/TokenDebugPage' -import AccountPage from './pages/AccountPage' -import './App.css' - -const router = createBrowserRouter([ - { - element: