From 64ccc777b481ff419a79c98eed9bb5ae872a259c Mon Sep 17 00:00:00 2001 From: Adam <65679285+adamkoot@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:00:18 +0200 Subject: [PATCH 01/28] feat(lore-0193): build the portal UI from the Figma design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MUI 7 and Emotion arrive — the half of the stack task 0185 shipped without — along with the landing page, the login screen and a dashboard that no longer looks like the debug harness it was deliberately left as. Design system Every colour, face and radius is transcribed from the Figma variables into theme/tokens.ts under Figma's own names, so a designer's "the tertiary text is too dim" maps to one line. theme.ts is the interpretation and keeps the two apart. Three self-hosted webfonts, ~130 KB: a link to fonts.googleapis or cdn.fontshare would be a third-party request from a page that renders a credential, and the CSP stays default-src 'self' only while nothing external is loaded. JetBrains Mono is one variable file, not two static weights. Routes / is the landing page and the junction the OAuth callback lands on — portal/auth/mod.rs redirects there in every outcome and says why: "when the portal grows a second page, the page it lands on decides where to go next". It forwards a signed-in visitor to /dashboard and a ?signin= landing to /login, carrying location.search so the one-shot refusals tasks 0186 and 0189 own are not swallowed. /dashboard sends a visitor with no session back to /api-tokens/, but only once /auth/me has answered — redirecting while it is in flight would bounce every arrival from the callback. Sign-in The Discord round-trip now opens in a second window and the page waits on it, as the mock shows. The control is still an with a real href and preventDefault is called only after a window actually opened, so a blocked popup falls through to the navigation that has always worked. Three signals end the wait — the popup's message, a poll of /auth/me, and the window closing — because each covers a case the others cannot. Message origin is checked: without it any page could end the wait and make this card claim an outcome that never happened. Copy This slice re-decides none. The prerequisites, the eligibility refusals, the cancelled and failed banners, the usage figures and the lag line are tasks 0186 to 0189's and are rendered verbatim; the dashboard is styled through descendant rules rather than rewritten, so no wording moves and no testid is touched. What is new is the card chrome, the four "What you get" lines and the marketing sections, which belong to no other slice. The FAQ answers are written here and each restates a decision made elsewhere — two of them want a product read before release, and say so in the file. Task 0185's "Reached /api-tokens/api/config successfully — same-origin, no API key, no CORS" is gone. It was that slice's evidence that the bundle could reach its own backend, written when the page had nothing else to show for it; the page now has plenty, and a diagnostic sentence was the last thing on it that read as scaffolding. The test that guarded it now guards the control that acts on the answer instead. Deployment: DirectoryIndexFn rewrites the two client routes to the portal's index.html. Without it a hard refresh on either resolves against S3 and returns 403 AccessDenied, because the bucket grants s3:GetObject and not s3:ListBucket. An allow-list of literals, not a catch-all — a catch-all would answer 200-with-index.html for genuinely missing objects and turn a broken deploy into an app that renders the wrong thing. Task 0195 replaces it with the per-prefix SPA fallback; until then a route added to landing/links.ts must be added there. Known gaps, all flagged in the files: the two logos are rasters recovered from the Figma export because the seat had no MCP calls left for download_assets and should be replaced with SVGs; the Endpoints paths are the design's, not this repo's; and the login card's legal line names two documents that do not exist, so it is plain text rather than links to a 404. --- infra/src/lib/stacks/portal-hosting-stack.ts | 34 + package-lock.json | 545 +++++++- package.json | 4 + web/portal/src/app/app.spec.tsx | 443 ++++++- web/portal/src/app/app.tsx | 1150 +++++++++++++++-- .../src/assets/fonts/clash-display-500.woff2 | Bin 0 -> 15272 bytes .../src/assets/fonts/clash-display-600.woff2 | Bin 0 -> 15284 bytes .../src/assets/fonts/clash-display-700.woff2 | Bin 0 -> 14544 bytes .../fonts/jetbrains-mono-variable.woff2 | Bin 0 -> 31432 bytes web/portal/src/assets/fonts/satoshi-500.woff2 | Bin 0 -> 25596 bytes web/portal/src/assets/fonts/satoshi-700.woff2 | Bin 0 -> 25328 bytes web/portal/src/assets/rumblefish-logo.png | Bin 0 -> 5914 bytes web/portal/src/assets/sorobanscan-logo.png | Bin 0 -> 5115 bytes web/portal/src/landing/Chrome.tsx | 231 ++++ web/portal/src/landing/DashboardPanel.tsx | 254 ++++ web/portal/src/landing/DeveloperDashboard.tsx | 141 ++ web/portal/src/landing/DiscordIcon.tsx | 25 + web/portal/src/landing/Documentation.tsx | 147 +++ web/portal/src/landing/Endpoints.tsx | 177 +++ web/portal/src/landing/FairAccess.tsx | 183 +++ web/portal/src/landing/Faq.tsx | 146 +++ web/portal/src/landing/Features.tsx | 163 +++ web/portal/src/landing/FinalCta.tsx | 69 + web/portal/src/landing/Hero.tsx | 308 +++++ web/portal/src/landing/LoginCard.tsx | 346 +++++ web/portal/src/landing/LoginSection.tsx | 148 +++ web/portal/src/landing/SelfService.tsx | 112 ++ web/portal/src/landing/Terminal.tsx | 115 ++ web/portal/src/landing/UseCases.tsx | 111 ++ web/portal/src/landing/links.ts | 56 + web/portal/src/landing/oauthPopup.ts | 158 +++ web/portal/src/landing/primitives.tsx | 298 +++++ web/portal/src/main.tsx | 43 +- web/portal/src/theme/fonts.css | 74 ++ web/portal/src/theme/theme.ts | 210 +++ web/portal/src/theme/tokens.ts | 113 ++ 36 files changed, 5591 insertions(+), 213 deletions(-) create mode 100644 web/portal/src/assets/fonts/clash-display-500.woff2 create mode 100644 web/portal/src/assets/fonts/clash-display-600.woff2 create mode 100644 web/portal/src/assets/fonts/clash-display-700.woff2 create mode 100644 web/portal/src/assets/fonts/jetbrains-mono-variable.woff2 create mode 100644 web/portal/src/assets/fonts/satoshi-500.woff2 create mode 100644 web/portal/src/assets/fonts/satoshi-700.woff2 create mode 100644 web/portal/src/assets/rumblefish-logo.png create mode 100644 web/portal/src/assets/sorobanscan-logo.png create mode 100644 web/portal/src/landing/Chrome.tsx create mode 100644 web/portal/src/landing/DashboardPanel.tsx create mode 100644 web/portal/src/landing/DeveloperDashboard.tsx create mode 100644 web/portal/src/landing/DiscordIcon.tsx create mode 100644 web/portal/src/landing/Documentation.tsx create mode 100644 web/portal/src/landing/Endpoints.tsx create mode 100644 web/portal/src/landing/FairAccess.tsx create mode 100644 web/portal/src/landing/Faq.tsx create mode 100644 web/portal/src/landing/Features.tsx create mode 100644 web/portal/src/landing/FinalCta.tsx create mode 100644 web/portal/src/landing/Hero.tsx create mode 100644 web/portal/src/landing/LoginCard.tsx create mode 100644 web/portal/src/landing/LoginSection.tsx create mode 100644 web/portal/src/landing/SelfService.tsx create mode 100644 web/portal/src/landing/Terminal.tsx create mode 100644 web/portal/src/landing/UseCases.tsx create mode 100644 web/portal/src/landing/links.ts create mode 100644 web/portal/src/landing/oauthPopup.ts create mode 100644 web/portal/src/landing/primitives.tsx create mode 100644 web/portal/src/theme/fonts.css create mode 100644 web/portal/src/theme/theme.ts create mode 100644 web/portal/src/theme/tokens.ts diff --git a/infra/src/lib/stacks/portal-hosting-stack.ts b/infra/src/lib/stacks/portal-hosting-stack.ts index 7c1a670a..c9c49cd2 100644 --- a/infra/src/lib/stacks/portal-hosting-stack.ts +++ b/infra/src/lib/stacks/portal-hosting-stack.ts @@ -238,6 +238,34 @@ var REDIRECTS = { '/api-tokens/api': '/api-tokens/api/' }; +// The portal's client-side routes, served by the portal's own index.html. +// +// Without this, a hard refresh or a pasted link to one of them resolves +// against S3, which grants s3:GetObject and NOT s3:ListBucket — so the missing +// key comes back as 403 AccessDenied XML rather than a 404, and the visitor +// gets a bare AWS error page instead of the app. The router cannot help, +// because the bundle never loads. +// +// An ALLOW-LIST of literals, not a catch-all rewrite of every extension-less +// path. A catch-all would answer 200-with-index.html for genuinely missing +// objects too, which turns a broken deploy — a hashed chunk that did not +// upload — into an app that silently renders the wrong thing. It also keeps +// the open-redirect property the stack note above insists on: nothing from the +// request is interpolated into a URI. +// +// Both slash forms, because the trailing-slash branch below would otherwise +// rewrite '/api-tokens/login/' to '/api-tokens/login/index.html' and 403. +// +// WARNING: add a route to web/portal/src/landing/links.ts and you must add +// it here too. Task 0195 is where this stops being a hand-maintained list and +// becomes the per-prefix SPA fallback. +var APP_ROUTES = { + '/api-tokens/login': '/api-tokens/index.html', + '/api-tokens/login/': '/api-tokens/index.html', + '/api-tokens/dashboard': '/api-tokens/index.html', + '/api-tokens/dashboard/': '/api-tokens/index.html' +}; + function handler(event) { var request = event.request; var uri = request.uri; @@ -246,6 +274,12 @@ function handler(event) { if (typeof REDIRECTS[uri] === 'string') { return redirect(REDIRECTS[uri]); } + // Before the trailing-slash branch, which would otherwise append index.html + // to the directory form and miss. + if (typeof APP_ROUTES[uri] === 'string') { + request.uri = APP_ROUTES[uri]; + return request; + } if (uri.slice(-1) === '/') { request.uri = uri + 'index.html'; return request; diff --git a/package-lock.json b/package-lock.json index c0c71464..96539714 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ "web/*" ], "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.11", + "@mui/material": "^7.3.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.18.2" @@ -125,7 +129,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -191,7 +194,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -325,7 +327,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -349,7 +350,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -454,7 +454,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -464,7 +463,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -513,7 +511,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -1916,7 +1913,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1926,7 +1922,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -1941,7 +1936,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -1960,7 +1954,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -2022,6 +2015,167 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2332,7 +2486,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2354,7 +2507,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2375,14 +2527,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2653,6 +2803,251 @@ "@module-federation/sdk": "2.8.2" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", + "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.11.tgz", + "integrity": "sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.11.tgz", + "integrity": "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.11", + "@mui/system": "^7.3.11", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -3831,6 +4226,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -5987,14 +6392,18 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6010,6 +6419,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -7760,7 +8178,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", @@ -8374,7 +8791,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8562,6 +8978,15 @@ "node": ">=0.8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8751,7 +9176,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", @@ -8768,7 +9192,6 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, "license": "ISC", "engines": { "node": ">= 6" @@ -8899,7 +9322,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -9019,7 +9441,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9221,6 +9642,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9494,7 +9925,6 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9615,7 +10045,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9740,7 +10169,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10856,6 +11284,12 @@ "semver": "bin/semver.js" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -11067,7 +11501,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11515,6 +11948,21 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -11761,7 +12209,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -11873,7 +12320,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -11946,7 +12392,6 @@ "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.3" @@ -11962,7 +12407,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -12537,7 +12981,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -12647,7 +13090,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -12667,7 +13109,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -13300,7 +13741,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -13672,7 +14112,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -14116,7 +14555,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14569,7 +15007,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -14582,7 +15019,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -14601,7 +15037,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/parse-ms": { @@ -14677,7 +15112,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-to-regexp": { @@ -14691,7 +15125,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14715,7 +15148,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -15061,7 +15493,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -15073,7 +15504,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proxy-addr": { @@ -15278,6 +15708,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -15426,7 +15872,6 @@ "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15455,7 +15900,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -16659,6 +17103,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/super-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", @@ -16694,7 +17144,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 2fb40a96..18907e89 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,10 @@ "web/*" ], "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.11", + "@mui/material": "^7.3.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.18.2" diff --git a/web/portal/src/app/app.spec.tsx b/web/portal/src/app/app.spec.tsx index fa3eef1f..f7a0d8d3 100644 --- a/web/portal/src/app/app.spec.tsx +++ b/web/portal/src/app/app.spec.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,8 +17,11 @@ import App from './app'; * the banner stayed — `MemoryRouter` has no `window.location` to inspect. */ let lastSearch = ''; +let lastPath = ''; function LocationSpy() { - lastSearch = useLocation().search; + const location = useLocation(); + lastSearch = location.search; + lastPath = location.pathname; return null; } @@ -141,6 +150,190 @@ const renderApp = () => , ); +/** + * The login card alone — the one place on the page a sign-in control can be. + * + * Scoping exists because of task 0193: this app used to BE the panel, so + * "offers nothing to click" could be asserted against the whole document. + * Since the landing page arrived, the document also carries a navbar, a hero, + * a footer and a "Back to landing" link whose targets are `#features`, + * `#use-cases`, `#top` and the OpenAPI document — navigation that is correct + * whether the portal is open or shut, and that a closed-portal assertion has + * no business counting. + * + * The assertion itself is NOT relaxed. Inside this panel the rule is still + * zero controls while the flag is off, and the two controls that could promise + * a key from outside it — the hero's and the navbar's "Get API Key" — are + * rendered only on a confirmed-open probe and are covered by their own test + * below. Widening the scope back out would fail on the footer, not on a + * regression. + */ +function portalPanel() { + return within(screen.getByTestId('login-card')); +} + +/** + * The three routes, and the two redirects between them (task 0193). + * + * These exist because the routes are a CONTRACT with the backend, not a + * cosmetic split. `portal/auth/mod.rs` sends every OAuth outcome to + * `/api-tokens/` and says so deliberately — "when the portal grows a second + * page, the page it lands on decides where to go next; this handler still will + * not". `/` is that page. If these forwards break, a completed sign-in ends on + * the marketing page and the visitor never reaches the key they just proved + * they are entitled to, with nothing on screen to say why. + * + * They also pin the guard the brief asks for in the other direction: a visitor + * with no session who arrives at `/dashboard` goes to `/api-tokens/`. + */ +describe('routes', () => { + beforeEach(() => { + vi.restoreAllMocks(); + lastPath = ''; + lastSearch = ''; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderAt = (entry: string) => + render( + + + + , + ); + + it('sends a signed-in visitor from the landing to the dashboard', async () => { + openAndSignedIn(); + renderAt('/'); + + // The OAuth callback lands here; the dashboard is where it must end up. + await waitFor(() => expect(lastPath).toBe('/dashboard')); + expect( + await screen.findByRole('heading', { name: /^your api key$/i }), + ).toBeTruthy(); + }); + + it('carries the issue outcome through to the dashboard', async () => { + openAndSignedIn(); + renderAt('/?issue=not_member'); + + // The forward must not eat the query. `?issue=…` is a one-shot landing + // state owned by task 0189 and the dashboard is what renders it — dropping + // it here would swallow an eligibility refusal the visitor is owed. + await waitFor(() => expect(lastPath).toBe('/dashboard')); + expect(await screen.findByTestId('issue-not-member')).toBeTruthy(); + }); + + it('sends a returning visitor with a signin outcome to the login screen', async () => { + openAndSignedOut(); + renderAt('/?signin=cancelled'); + + await waitFor(() => expect(lastPath).toBe('/login')); + expect(await screen.findByText(/sign-in cancelled/i)).toBeTruthy(); + }); + + it('leaves a signed-out visitor on the landing page', async () => { + openAndSignedOut(); + renderAt('/'); + + expect( + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); + expect(lastPath).toBe('/'); + // And the login card is not on the landing page — it is a route of its own. + expect(screen.queryByTestId('login-card')).toBeNull(); + }); + + it('shows the login screen on its own, with none of the landing page', async () => { + openAndSignedOut(); + renderAt('/login'); + + expect(await screen.findByTestId('login-card')).toBeTruthy(); + expect(lastPath).toBe('/login'); + // The marketing sections belong to `/`. "Only this one view" is the brief. + expect(document.getElementById('features')).toBeNull(); + expect(document.getElementById('use-cases')).toBeNull(); + }); + + it('sends a signed-in visitor away from the login screen', async () => { + openAndSignedIn(); + renderAt('/login'); + + await waitFor(() => expect(lastPath).toBe('/dashboard')); + }); + + it('sends a visitor with no session away from the dashboard', async () => { + openAndSignedOut(); + renderAt('/dashboard'); + + await waitFor(() => expect(lastPath).toBe('/')); + // By heading, not by text: the landing page's Self-Service section says + // "…your API key is ready immediately", which a loose text match hits. + expect( + screen.queryByRole('heading', { name: /^your api key$/i }), + ).toBeNull(); + }); + + it('waits for the session before deciding about the dashboard', async () => { + // The redirect must not fire while `/auth/me` is still in flight: that is + // exactly the moment an arrival from the OAuth callback passes through, + // and bouncing it would break the one journey these routes exist for. + let answer: (value: unknown) => void = () => undefined; + const pending = new Promise((resolve) => { + answer = resolve; + }); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url === CONFIG_URL) + return { + ok: true, + status: 200, + json: async () => ({ enabled: true }), + }; + if (url === ME_URL) { + await pending; + return { + ok: true, + status: 200, + json: async () => ({ + authenticated: true, + user_id: '1', + username: 'adam', + }), + }; + } + return { + ok: false, + status: 404, + json: async () => ({ code: 'no_key' }), + }; + }), + ); + + renderAt('/dashboard'); + expect( + await screen.findByText(/checking whether you are signed in/i), + ).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + + answer(undefined); + expect( + await screen.findByRole('heading', { name: /^your api key$/i }), + ).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + }); + + it('sends an unknown path back to the landing page', async () => { + openAndSignedOut(); + renderAt('/nonsense'); + + await waitFor(() => expect(lastPath).toBe('/')); + }); +}); + describe('portal home', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -157,8 +350,10 @@ describe('portal home', () => { // The acceptance criterion is "no sign-in button", not "no button that // happens to say sign in" — assert on the role, so any control added here // fails this rather than sneaking past a string match. - expect(screen.queryAllByRole('button')).toHaveLength(0); - expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(portalPanel().queryAllByRole('button')).toHaveLength(0); + expect(portalPanel().queryAllByRole('link')).toHaveLength(0); + // And nothing above the fold offers the key either (task 0193). + expect(screen.queryByRole('link', { name: /get api key/i })).toBeNull(); }); it('renders the open state when the flag is on', async () => { @@ -167,9 +362,17 @@ describe('portal home', () => { // Task 0185's "sign-in arrives with the next slice" placeholder is gone — // this slice IS that sign-in, so the open state is now the real control. + // + // Since task 0193 gave the portal routes, the control the LANDING offers is + // the one that leads to sign-in rather than the Discord button itself; the + // button is asserted on `/login`, where it now lives. What this test still + // pins is the gate: flag on, a way in appears and the closed sentence does + // not. + // `findAll`: the landing offers the same control in the navbar, the hero + // and the footer, and a `findBy` throws on more than one match. expect( - await screen.findByRole('link', { name: /sign in with discord/i }), - ).toBeTruthy(); + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); expect(screen.queryByText(/not yet available/i)).toBeNull(); }); @@ -260,17 +463,26 @@ describe('portal home', () => { }); it('says nothing about the outcome while the probe is still in flight', async () => { - stubFetch({}); + stubFetch({ json: async () => ({ enabled: true }) }); renderApp(); - // The evidence paragraph must not claim failure before there is an answer. + // Task 0185's `Reached /api-tokens/api/config successfully — same-origin…` + // line is gone (task 0193: the page must not read as a debug harness), so + // what this test guards has moved to the control that ACTS on the answer. + // The property is the same one and it is the one that matters: the page + // must not commit to an outcome it does not have yet. expect( screen.getByText(/Checking whether the portal is open/i), ).toBeTruthy(); - expect(screen.queryByText(/unsuccessfully/i)).toBeNull(); + // No offer of a key while nobody knows whether the portal is open… + expect(screen.queryByRole('link', { name: /get api key/i })).toBeNull(); + // …and no claim that it is shut, either. + expect(screen.queryByText(/not yet available/i)).toBeNull(); - // …and it must still report the outcome once one arrives. - expect(await screen.findByText(/successfully/i)).toBeTruthy(); + // …and the answer, once it arrives, is acted on. + expect( + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); }); // `renderApp` above mounts at `/` with no basename, so it cannot notice @@ -325,7 +537,7 @@ describe('sign in with Discord', () => { */ it('offers sign-in as a same-origin link, not a fetch', async () => { openAndSignedOut(); - renderAt('/'); + renderAt('/login'); const link = await screen.findByRole('link', { name: /sign in with discord/i, @@ -366,11 +578,11 @@ describe('sign in with Discord', () => { it('renders signed-out as plain text with the button still there', async () => { openAndSignedOut(); - renderAt('/'); + renderAt('/login'); expect(await screen.findByText(/you are not signed in/i)).toBeTruthy(); - // "Plain text, not a screen" — the heading and the same-origin evidence - // paragraph are still on the page. + // "Plain text, not a screen" — the card's heading is still the page's `h1` + // and the sign-in control is still beside the words, not behind them. expect(screen.getByRole('heading', { level: 1 })).toBeTruthy(); }); @@ -384,7 +596,7 @@ describe('sign in with Discord', () => { */ it('states both prerequisites before the visitor authenticates', async () => { openAndSignedOut(); - renderAt('/'); + renderAt('/login'); await screen.findByRole('link', { name: /sign in with discord/i }); expect( @@ -465,7 +677,7 @@ describe('sign in with Discord', () => { it('does not claim a cancellation that did not happen', async () => { openAndSignedOut(); - renderAt('/'); + renderAt('/login'); await screen.findByText(/you are not signed in/i); expect(screen.queryByText(/cancelled/i)).toBeNull(); }); @@ -496,7 +708,16 @@ describe('sign in with Discord', () => { // assertions rather than warned about after them. fireEvent.click(button); - expect(await screen.findByText(/you are not signed in/i)).toBeTruthy(); + // Signing out empties the session, and `/dashboard` sends a visitor with + // no session to `/api-tokens/` — so the observable result is the landing + // page with its way back in, not the "you are not signed in" line, which + // belongs to `/login`. The property this test exists for is unchanged and + // asserted below: the request was a POST, and the session was re-read + // rather than assumed. + expect( + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: /sign out/i })).toBeNull(); const logout = fetchMock.mock.calls.find(([url]) => url === LOGOUT_URL); expect(logout).toBeTruthy(); // A GET sign-out is triggerable by any third-party page; the backend only @@ -514,7 +735,7 @@ describe('sign in with Discord', () => { [CONFIG_URL]: openConfig, [ME_URL]: () => ({ ok: false, status: 502 }), }); - renderAt('/'); + renderAt('/login'); expect( await screen.findByText(/could not check your sign-in status/i), @@ -535,7 +756,7 @@ describe('sign in with Discord', () => { [CONFIG_URL]: openConfig, [ME_URL]: () => ({ ok: false, status: 502 }), }); - renderAt('/'); + renderAt('/login'); await screen.findByText(/could not check your sign-in status/i); const link = screen.getByRole('link', { name: /sign in with discord/i }); @@ -560,6 +781,178 @@ describe('sign in with Discord', () => { }); }); +/** + * The sign-in popup (task 0193). + * + * The control stays an `` with a real `href` and the popup is layered on + * top of it, so these tests pin BOTH halves: that a click opens the + * round-trip in a second window, and that a browser which refuses to open one + * falls through to the navigation that has always worked. + */ +describe('the sign-in popup', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderAt = (entry: string) => + render( + + + , + ); + + /** A stand-in for the second window, and the `window.open` that returns it. */ + const stubPopup = (opened: Partial | null) => { + // The parameters are declared even though the stub ignores them: the + // assertion below reads `calls[0][0]`, and without them the recorded call + // tuple has length 0 and will not typecheck. + const open = vi.fn( + (_url: string, _name?: string, _features?: string) => + opened as Window | null, + ); + vi.stubGlobal('open', open); + return open; + }; + + const clickSignIn = async () => { + const link = await screen.findByRole('link', { + name: /sign in with discord/i, + }); + fireEvent.click(link); + return link; + }; + + it('opens the round-trip in a second window and waits', async () => { + openAndSignedOut(); + const open = stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + + // The URL is the backend's own login route, relative — the popup goes + // through `/auth/login` so the PKCE `pending` cookie is set same-origin, + // exactly as the full-page flow does. + expect(open.mock.calls[0][0]).toBe('/api-tokens/api/auth/login'); + expect(await screen.findByText(/redirecting to discord/i)).toBeTruthy(); + expect(screen.getByText(/waiting for discord/i)).toBeTruthy(); + // And the escape hatch the copy promises actually points somewhere. + expect( + screen.getByRole('link', { name: /click here/i }).getAttribute('href'), + ).toBe('/api-tokens/api/auth/login'); + }); + + it('falls back to navigating this tab when the popup is blocked', async () => { + openAndSignedOut(); + stubPopup(null); + renderAt('/login'); + + const link = await clickSignIn(); + + // No waiting screen: the click was not intercepted, so the browser is + // following the `href` and this document is already on its way out. A + // "Waiting for Discord…" spinner here would be a lie about a window that + // never opened. + expect(screen.queryByText(/waiting for discord/i)).toBeNull(); + expect(link.getAttribute('href')).toBe('/api-tokens/api/auth/login'); + expect(screen.getByText(/you are not signed in/i)).toBeTruthy(); + }); + + it('leaves a modified click alone so it can open a tab', async () => { + openAndSignedOut(); + const open = stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + const link = await screen.findByRole('link', { + name: /sign in with discord/i, + }); + fireEvent.click(link, { metaKey: true }); + + expect(open).not.toHaveBeenCalled(); + expect(screen.queryByText(/waiting for discord/i)).toBeNull(); + }); + + it('reports the refusal the popup brings back, in task 0186 wording', async () => { + openAndSignedOut(); + stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { source: 'stellar-portal-oauth', search: '?signin=cancelled' }, + }), + ); + + // The same sentence the full-page flow shows, from the same one place in + // the component — not a second wording for the popup. + expect(await screen.findByText(/sign-in cancelled/i)).toBeTruthy(); + expect(screen.queryByText(/waiting for discord/i)).toBeNull(); + }); + + it('ignores a message from another origin', async () => { + openAndSignedOut(); + stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + fireEvent( + window, + new MessageEvent('message', { + origin: 'https://not-us.example', + data: { source: 'stellar-portal-oauth', search: '?signin=failed' }, + }), + ); + + // Still waiting. A `message` event arrives from any window that cares to + // send one, and without the origin check a third-party page could end the + // wait and make this card claim an outcome that never happened. + expect(screen.getByText(/waiting for discord/i)).toBeTruthy(); + expect(screen.queryByText(/could not be completed/i)).toBeNull(); + }); + + it('goes to the dashboard when the popup completes the sign-in', async () => { + // The popup reports no refusal; the session it created is what the app + // then finds. This is the whole journey the routes exist for. + let authenticated = false; + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => + authenticated + ? { authenticated: true, user_id: '1', username: 'adam' } + : { authenticated: false }, + }), + [KEY_URL]: keyNoKey, + [USAGE_URL]: usageNoKey, + }); + stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + authenticated = true; + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { source: 'stellar-portal-oauth', search: '' }, + }), + ); + + expect(await screen.findByText(/your api key/i)).toBeTruthy(); + }); +}); + /** * The API key (task 0187; issuance re-shaped by task 0189). * @@ -787,7 +1180,10 @@ describe('the API key', () => { /** The key belongs to the session, so signing out must take it off screen. */ it('is not rendered while signed out', async () => { openAndSignedOut(); - renderApp(); + // `/login`, not `/`: the key lives on the dashboard since task 0193 gave + // the portal routes, and a signed-out visitor never reaches that route — + // this asserts the key is absent from the screen they DO reach. + renderApp('/login'); await screen.findByRole('link', { name: /sign in with discord/i }); expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); @@ -803,8 +1199,9 @@ describe('the API key', () => { renderApp(); await screen.findByText(/not yet available/i); - expect(screen.queryAllByRole('button')).toHaveLength(0); - expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(portalPanel().queryAllByRole('button')).toHaveLength(0); + expect(portalPanel().queryAllByRole('link')).toHaveLength(0); + expect(screen.queryByRole('link', { name: /get api key/i })).toBeNull(); }); // ------------------------------------------------------------------------- diff --git a/web/portal/src/app/app.tsx b/web/portal/src/app/app.tsx index 11af71dc..271692b3 100644 --- a/web/portal/src/app/app.tsx +++ b/web/portal/src/app/app.tsx @@ -1,5 +1,57 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Route, Routes, useSearchParams } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent, + type ReactNode, +} from 'react'; +import { + Navigate, + Route, + Routes, + useLocation, + useSearchParams, +} from 'react-router-dom'; + +import { Navbar, Footer } from '../landing/Chrome'; +import { DiscordIcon } from '../landing/DiscordIcon'; +import { + Benefits, + Callout, + DISCORD, + LabelledRule, + LoginCard, +} from '../landing/LoginCard'; +import { KeyField, UsageMeter } from '../landing/DashboardPanel'; +import { QUICKSTART, SWAGGER_UI } from '../landing/links'; +import { LoginSection, visuallyHidden } from '../landing/LoginSection'; +import { + onOAuthPopupMessage, + openOAuthPopup, + readSigninOutcome, +} from '../landing/oauthPopup'; +import { WindowCard, cardBorder } from '../landing/primitives'; +import { Documentation } from '../landing/Documentation'; +import { DeveloperDashboard } from '../landing/DeveloperDashboard'; +import { Endpoints } from '../landing/Endpoints'; +import { FairAccess } from '../landing/FairAccess'; +import { Faq } from '../landing/Faq'; +import { Features } from '../landing/Features'; +import { FinalCta } from '../landing/FinalCta'; +import { Hero, TrustBand } from '../landing/Hero'; +import { SelfService } from '../landing/SelfService'; +import { UseCases } from '../landing/UseCases'; +import { LOGIN_ANCHOR } from '../landing/links'; +import { alpha } from '@mui/material/styles'; + +import { theme } from '../theme/theme'; +import { color, font, radius } from '../theme/tokens'; import { fetchKey, @@ -46,6 +98,39 @@ type SessionState = | { state: 'ok'; session: PortalSession } | { state: 'failed'; reason: string }; +/** + * The login card's headline and standfirst — **new copy, owned by task 0193**, + * from the Figma login frame (`778:2499`). + * + * Task 0193's rule is that it re-decides no copy another slice owns, and this + * is the other side of that rule: the card's own chrome is not owned by anyone + * else, so the design's wording is taken as written. Everything INSIDE the card + * body — the prerequisites, the refusals, the cancelled and failed banners — + * still belongs to tasks 0186 and 0189 and is rendered verbatim. + * + * Shared across all four probe states so the card does not appear to change + * identity while it is deciding what to say. + */ +const LOGIN_TITLE = 'Get your API key'; +const LOGIN_SUBTITLE = + 'Sign in with Discord to receive your key instantly. No forms, no waiting, no manual approval.'; + +/** + * The "What you get" list from the design. + * + * Also 0193's copy — and deliberately NOT a restatement of the eligibility + * prerequisites, which are task 0189's and appear above the button where the + * acceptance criterion puts them. These four say what the key is; those two say + * who may have one. Collapsing them into one list is how the requirement stops + * being stated before the visitor authorises. + */ +const BENEFITS = [ + { text: 'Instant API key — no waiting', kind: 'check' }, + { text: '100,000 requests/month — free', kind: 'check' }, + { text: 'Usage dashboard and key management', kind: 'check' }, + { text: 'Discord account is your identity', kind: 'discord' }, +] as const; + /** The landing params sign-in's callback appends. */ const SIGNIN_PARAMS = ['signin'] as const; /** The landing params the issue callback appends (task 0189). */ @@ -140,18 +225,12 @@ function Prerequisites() { * put in a context — one prop across two hops is less machinery than either, * and it keeps the value's single source visible in the call chain. */ -function SignIn({ rateLimit }: { rateLimit?: number }) { +function useSession(enabled: boolean): { + session: SessionState; + onSignOut: () => void; + reload: () => void; +} { const [session, setSession] = useState({ state: 'loading' }); - // Two landing states, from two literals the backend appends. `cancelled` is - // the visitor's own choice at Discord's consent screen; `failed` is any other - // OAuth error — a drifted scope registration, a Discord outage — which the - // backend also logs. Telling them apart on the page is the visible half of - // that split: calling a misconfiguration "cancelled" is what made it look - // like every visitor was changing their mind. One-shot (task 0189, closing - // 0186's O10): shown for this landing, stripped from the URL. - const { signin } = useOneShotParams(SIGNIN_PARAMS); - const cancelled = signin === 'cancelled'; - const failed = signin === 'failed'; // Cancels whichever `/auth/me` is currently in flight, whoever started it. // @@ -190,10 +269,18 @@ function SignIn({ rateLimit }: { rateLimit?: number }) { // first and overwrite it. The cleanup reads the ref rather than closing over // this call's canceller, so it also cancels a request the sign-out handler // started. + // + // `enabled` gates the fetch, and that gate is not an optimisation: while + // `PORTAL_ENABLED` is off, task 0183's route gate answers `/auth/me` with an + // empty 404, so asking would put a guaranteed failure in the console of + // every visitor to a closed portal and leave this hook reporting `failed` + // for a portal that is merely shut. Not asking leaves it `loading`, and the + // routes that care never consult it while the portal is closed. useEffect(() => { + if (!enabled) return; load(); return () => cancelInFlight.current?.(); - }, [load]); + }, [enabled, load]); const onSignOut = () => { setSession({ state: 'loading' }); @@ -210,16 +297,175 @@ function SignIn({ rateLimit }: { rateLimit?: number }) { ); }; + return { session, onSignOut, reload: load }; +} + +/** + * The `/login` view — the Figma login frame (`778:2499`) and nothing else. + * + * A route of its own since the portal grew a second page. The OAuth callback + * still lands on `/api-tokens/`, exactly as `portal/auth/mod.rs` says it will + * ("when the portal grows a second page, the page it lands on decides where to + * go next; this handler still will not") — so `RootRoute` is what forwards a + * `?signin=…` landing here, carrying the query with it. That is why the + * banners below still read their params from the URL: they arrive on this + * route, just not from Discord directly. + */ +function LoginView({ + session, + onSignedIn, +}: { + session: SessionState; + onSignedIn: () => void; +}) { + // Two landing states, from two literals the backend appends. `cancelled` is + // the visitor's own choice at Discord's consent screen; `failed` is any other + // OAuth error — a drifted scope registration, a Discord outage — which the + // backend also logs. Telling them apart on the page is the visible half of + // that split: calling a misconfiguration "cancelled" is what made it look + // like every visitor was changing their mind. One-shot (task 0189, closing + // 0186's O10): shown for this landing, stripped from the URL. + const { signin } = useOneShotParams(SIGNIN_PARAMS); + + /** + * The refusal on screen, from EITHER source. + * + * The wording below is task 0186's and there is one copy of it, but there are + * now two ways to arrive at it: the full-page round-trip lands on + * `?signin=…`, and the popup hands the same literal back through + * `postMessage`. Feeding both into one piece of state is what keeps that a + * single rendering path — two branches saying the same sentence is how the + * two wordings drift apart. + */ + const [outcome, setOutcome] = useState(signin); + const cancelled = outcome === 'cancelled'; + const failed = outcome === 'failed'; + + /** Whether a sign-in window is open and being waited on. */ + const [waiting, setWaiting] = useState(false); + const popup = useRef(null); + + /** + * Watch the sign-in window: its message, its closing, and the session it is + * trying to create. + * + * Three signals rather than one, because each covers a case the others + * cannot. The message is the fast, precise path — it carries the refusal + * literal. Polling `/auth/me` covers a popup whose `postMessage` never + * arrives (an extension, a `noopener` policy, a browser that reuses a tab). + * Watching `closed` covers the visitor who simply shuts the window: nothing + * happened, so the card goes back to offering the button rather than sitting + * on a spinner for ever. + */ + useEffect(() => { + if (!waiting) return; + + let live = true; + const finish = (refusal: string | null) => { + if (!live) return; + live = false; + setWaiting(false); + setOutcome(refusal); + // Ask the server either way. A refusal means no session, and saying so + // costs one request; a success means the cookie is already set and this + // is the only thing that will notice. + onSignedIn(); + }; + + const stopListening = onOAuthPopupMessage(({ search }) => + finish(readSigninOutcome(search)), + ); + + // 1.5s: fast enough that a visitor who finishes at Discord does not sit + // looking at a spinner, slow enough that a two-minute consent screen costs + // eighty requests to a route the backend answers from its own session + // cookie. + const poll = window.setInterval(() => { + fetchSession() + .then((result) => { + if (result.authenticated) finish(null); + }) + .catch(() => { + // A failed poll says nothing about the round-trip in the other + // window. Keep waiting; the message or the close will end this. + }); + }, 1500); + + const watchClosed = window.setInterval(() => { + if (popup.current?.closed) { + // No outcome to report — the window was shut, which is not a refusal + // anyone chose at Discord. `finish(null)` re-reads the session, so a + // visitor who DID complete and then closed the window still lands on + // the dashboard. + finish(null); + } + }, 500); + + return () => { + live = false; + stopListening(); + window.clearInterval(poll); + window.clearInterval(watchClosed); + }; + }, [waiting, onSignedIn]); + + /** + * Open the round-trip in a second window — and do nothing at all if the + * browser will not allow it. + * + * `preventDefault` is called ONLY after a window is actually open. When it is + * blocked, the click falls through to the anchor's `href` and the flow + * happens in this tab exactly as it did before the popup existed. + */ + const onSignInClick = (event: MouseEvent) => { + // Let the browser handle the gestures that mean "somewhere else": a + // middle-click, ⌘/Ctrl-click or a modified click is a request for a tab, + // and hijacking it into a popup is the kind of thing that makes people + // stop trusting a page with their credentials. + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + const opened = openOAuthPopup(signInUrl()); + if (!opened) return; + event.preventDefault(); + popup.current = opened; + setOutcome(null); + setWaiting(true); + }; + if (session.state === 'loading') { - return

Checking whether you are signed in…

; + return ( + + }> +

Checking whether you are signed in…

+
+
+ ); } if (session.state === 'failed') { return ( - <> -

- Could not check your sign-in status: {session.reason} -

+ + +

+ Could not check your sign-in status: {session.reason} +

+
{/* The control stays. A failed `/auth/me` usually means the backend is unreachable, in which case signing in will fail too — but it can also be one bad response, and a page that reports an error while @@ -227,42 +473,169 @@ function SignIn({ rateLimit }: { rateLimit?: number }) { they can leave only by guessing at a reload. Signing in is a fresh top-level navigation, so it does not depend on the request that just failed. */} -
Sign in with Discord - + Sign in with Discord + ); } - if (session.session.authenticated) { + // The design's second screen. Reached only when a popup actually opened — + // when one did not, the click became a full-page navigation and this window + // is already on its way to Discord. + if (waiting) { return ( - + } + > + + Complete the sign-in there to continue. If nothing opened,{' '} + {/* The fallback the design's wording promises, wired to the thing it + promises: a plain top-level navigation in THIS tab, which is the + flow that works without a popup at all. */} + click here. + + + {/* The design's spinner is an arc travelling around a visible track, + not a bare arc. MUI draws only the arc, so the track is a second + ring underneath — a `determinate` progress pinned at 100%, which + inherits the same geometry rather than guessing at a border + radius that happens to line up. */} + + + + + + Waiting for Discord… + + + This page will update automatically. + + + ); } return ( - <> - {cancelled &&

Sign-in cancelled.

} + } + > + {/* Both banners keep task 0186's wording exactly. "Cancelled" is not an + error and does not get the error skin — the visitor pressed Cancel at + Discord's consent screen, and colouring that red would tell them they + broke something. "Failed" is ours, so it does. */} + {cancelled && ( + +

Sign-in cancelled.

+
+ )} {failed && ( -

- Sign-in could not be completed. This is not something you did — try - again, and tell us if it keeps happening. -

+ +

+ Sign-in could not be completed. This is not something you did — try + again, and tell us if it keeps happening. +

+
)}

You are not signed in.

{/* Both prerequisites, BEFORE the control that starts an OAuth flow — the acceptance criterion. Signing in itself needs neither, but the visitor deciding whether to authorise an app deserves to know what - the key they came for will require. */} + the key they came for will require. + + The design's card does not have this paragraph and the design's + "What you get" list does not replace it: that list says what the key + is, this says who may have one. Dropping it to match the mock would + break the one acceptance criterion this screen exists to satisfy. */} {/* A link, not a button with an onClick. The OAuth flow is a top-level navigation to discord.com and back; `fetch` cannot perform one, and the session cookie is `SameSite=Lax` precisely so that this navigation - carries it. `href` is relative, so it stays same-origin. */} - Sign in with Discord - + carries it. `href` is relative, so it stays same-origin. + + `DiscordButton` renders an `` — `component="a"` with an `href` — + so it is still a link to the browser, to a screen reader and to the + tests that pin it by role. Only its appearance changed. */} + + Sign in with Discord + + What you get + +
+ ); +} + +/** + * The card's primary control, in Discord's blurple. + * + * Always an ``: every caller is starting an OAuth round-trip, which is a + * top-level navigation that `fetch` cannot perform and that the `SameSite=Lax` + * session cookie depends on. Rendering it as a ` + ); +} + +/** + * The card's legal footer. + * + * **The two documents it names do not exist yet**, so the words are set as + * plain text rather than as links. A link to a placeholder would be a promise + * the page cannot keep, and "you agree to our Terms of Service" pointing at a + * 404 is worse than the same sentence pointing nowhere. Give this component two + * URLs and the `` elements go back in. + */ +function Legal() { + return ( + + By continuing you agree to our Terms of Service and Privacy Policy. + ); } @@ -293,27 +666,76 @@ function Dashboard({ const [keyOnScreen, setKeyOnScreen] = useState(false); return ( - <> - {/* The acceptance criterion, rendered: username and ID. The ID is the - account key (ADR 0010) and the username is display only — it comes - from the signed session cookie and is refreshed at each sign-in. */} + + + + + {/* The acceptance criterion, rendered: username and ID. The ID is + the account key (ADR 0010) and the username is display only — + it comes from the signed session cookie and is refreshed at + each sign-in. */} +

+ Signed in as {session.username} (ID{' '} + {session.user_id}) +

+ +
+ + {/* Tasks 0187 + 0189. Inside the authenticated branch, so signing + out removes it along with the key it was showing — the component + unmounts and its state goes with it, rather than leaving a stale + credential on screen for the next person at the keyboard. */} + setKeyOnScreen(true)} /> + {/* Task 0188. Keyed refetch: a key appearing on screen (revealed on + mount, or fresh off 0189's issue round-trip) re-asks for usage, so + the section leaves "no key yet" without a manual refresh. */} + + +
+
+
+ ); +} + +/** + * The two links out of the dashboard — task 0193's acceptance criterion, and + * the one this slice had left undone: "link out to the quickstart and Swagger + * UI from the dashboard. A key is only useful next to the thing that shows + * what to call." + * + * Both point at the OpenAPI document today, because that is the only + * documentation artefact actually served; task 0163 writes the quickstart and + * task 0195 mounts Swagger UI, and `landing/links.ts` is the single place where + * those two constants diverge when they land. + */ +function DashboardDocs() { + return ( + +

Next steps

- Signed in as {session.username} (ID{' '} - {session.user_id}) + Quickstart — your first request, end to end. + {' · '} + API reference — every endpoint and response.

- - {/* Tasks 0187 + 0189. Inside the authenticated branch, so signing out - removes it along with the key it was showing — the component unmounts - and its state goes with it, rather than leaving a stale credential on - screen for the next person at the keyboard. */} - setKeyOnScreen(true)} /> - {/* Task 0188. Keyed refetch: a key appearing on screen (revealed on - mount, or fresh off 0189's issue round-trip) re-asks for usage, so - the section leaves "no key yet" without a manual refresh. */} - - + ); } @@ -621,22 +1043,37 @@ function ApiKey({ onKey }: { onKey?: () => void }) { {view.state === 'ok' && ( <> -

- {/* Masked by default. The mask is a fixed run of dots, not a - prefix-and-suffix of the real value: showing the first and last - few characters of a credential is a habit borrowed from card - numbers, where the rest is high-entropy. Here it would leak part - of the secret for no benefit anyone asked for. */} - - {revealed ? view.key.value : '••••••••••••••••••••••••••••••••'} - -

- {' '} - + {/* The design's key row (`landing/DashboardPanel.tsx`) — the same + component the landing page's preview draws, so the thing a + visitor was shown before signing in is the thing they land on. + Only the presentation moved: the mask, the toggle, the copy + button and every word around them are tasks 0187's and 0189's. + + Masked by default. The mask is a fixed run of dots, not a + prefix-and-suffix of the real value: showing the first and last + few characters of a credential is a habit borrowed from card + numbers, where the rest is high-entropy. Here it would leak part + of the secret for no benefit anyone asked for. */} + + + + + } + /> {copied &&

Copied.

}

Send it as the X-API-Key header on /v1/{' '} @@ -851,6 +1288,20 @@ function Usage({ ) : ( <> + {/* The bar is task 0193's; the figures under it are task 0188's and + are untouched — same labels, same `data-testid`s, same raw + values. The meter adds the one thing three separate numbers do + not give: the ratio, at a glance. The design puts it on the + landing page's dashboard preview, and this is the same + component, so the preview cannot promise a bar the real screen + does not draw. */} + {/* NO `resetLabel` here, unlike the landing page's preview. Task + 0188's `limits()` below already states the reset rule and the + next date, and states it more precisely than a meter caption + can ("the 1st of each month, 00:00 UTC" — our rule, not an AWS + guarantee). Repeating the date two lines apart is the panel + saying the same thing twice and inviting the two to drift. */} +

Used: {view.usage.used}

@@ -899,7 +1350,309 @@ function describeFailure(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } -function PortalHome() { +/** + * The portal panel — everything that talks to the backend, on one anchor. + * + * The `/config` probe used to live HERE and is now a prop. It moved up to + * `LandingPage` (task 0193) because the answer decides something outside this + * panel: whether the hero and the navbar render a "Get API Key" control at all. + * While the portal is closed those controls would promise a key the backend + * will not issue — the same objection task 0186 raised against putting a + * sign-in button on a closed page — and the alternative to lifting it is a + * second `/config` fetch per page load to answer a question one already + * answered. + */ +/** + * The wrapper that styles the plain markup tasks 0186 to 0192 own. + * + * Shared by the landing's status panel and the dashboard route, because both + * render that markup and neither may rewrite it — see the long note inside. + */ +function PortalStatusChrome({ children }: { children: ReactNode }) { + return ( + `, ``, ` + )} + + + + + ); +} + +export function Footer({ canOfferKey }: { canOfferKey: boolean }) { + const links: { label: string; href?: string }[] = [ + { label: 'Documentation', href: SWAGGER_UI }, + // Only where there is a dashboard to reach. While the portal is shut this + // link would land on `/dashboard`, which sends a visitor with no session + // straight back to the page they clicked from. + ...(canOfferKey ? [{ label: 'Dashboard', href: DASHBOARD_ROUTE }] : []), + { label: 'Status' }, + { label: 'Contact' }, + { label: 'rumblefish.dev', href: 'https://rumblefish.dev' }, + { label: 'Privacy policy' }, + ]; + + return ( + + + + + + {links.map(({ label, href }) => + href ? ( + + {label} + + ) : ( + + {label} + + ), + )} + + + © 2026 Rumble Fish. All rights reserved. + + + + + ); +} diff --git a/web/portal/src/landing/DashboardPanel.tsx b/web/portal/src/landing/DashboardPanel.tsx new file mode 100644 index 00000000..dd03db59 --- /dev/null +++ b/web/portal/src/landing/DashboardPanel.tsx @@ -0,0 +1,254 @@ +import Box from '@mui/material/Box'; +import LinearProgress from '@mui/material/LinearProgress'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { alpha } from '@mui/material/styles'; +import type { ReactNode } from 'react'; + +import { color, font, radius } from '../theme/tokens'; +import { cardBorder } from './primitives'; + +/** + * The dashboard's building blocks, shared by the landing page's preview and by + * the real `/dashboard` route. + * + * **Shared on purpose.** The landing page shows a picture of the dashboard and + * then the visitor signs in and lands on the real one; if those two are built + * from different code they drift, and the promise the marketing section makes + * stops matching the thing it was promising. One set of components means the + * preview cannot go stale without the dashboard going stale with it. + */ + +/** An inset panel inside the dashboard card — the darkest surface on the page. */ +export function InsetPanel({ + children, + sx, +}: { + children: ReactNode; + sx?: object; +}) { + return ( + + {children} + + ); +} + +/** + * The card's header line: a title and a status pill. + * + * The pill is a claim about the KEY, not about the service — "Active" means + * this key works. It is passed in rather than assumed, because the real + * dashboard has states the preview does not (no key yet, revoked) and a pill + * hard-coded to "Active" would be the card lying about a key that is not. + */ +export function PanelHeader({ + title, + status, +}: { + title: string; + status?: { label: string; tone: 'ok' | 'muted' }; +}) { + return ( + + + {title} + + {status && ( + + + + {status.label} + + + )} + + ); +} + +/** + * The key, in the design's monospace yellow. + * + * `value` is whatever the caller decided to show — the masked run of dots or + * the credential itself. This component never decides that: masking is task + * 0187's rule and the reveal toggle lives with the state that owns it. + */ +export function KeyField({ + label, + value, + testId, + actions, +}: { + label: string; + value: ReactNode; + testId?: string; + actions?: ReactNode; +}) { + return ( + + + {label} + + + + {value} + + {actions && ( + + {actions} + + )} + + + ); +} + +/** + * Used-of-quota as a bar, a fraction and a reset date. + * + * **The bar is task 0193's addition; the numbers are task 0188's.** That split + * matters: 0188 decided that this panel shows used, remaining and the limit as + * figures rather than prose, and decided the reset rule's wording. This adds a + * way to see the ratio at a glance and re-decides none of it — every number it + * renders is passed in, and the caller keeps the labels and the `data-testid`s + * those tests pin. + * + * `used` or `limit` being null is the honest "AWS has recorded nothing yet" + * state (0188 again): the bar is omitted rather than drawn at zero, because a + * zero-length bar reads as "you have used none of your quota" when the truth is + * "we do not know yet". + */ +export function UsageMeter({ + used, + limit, + resetLabel, +}: { + used: number | null; + limit: number | null; + resetLabel?: ReactNode; +}) { + const known = used !== null && limit !== null && limit > 0; + const percent = known ? Math.min(100, Math.round((used / limit) * 100)) : 0; + + return ( + + + + Monthly Usage + + {known && ( + + {used.toLocaleString('en-US')} / {limit.toLocaleString('en-US')} + + )} + + + {known && ( + <> + + + + {percent}% used + + {resetLabel && ( + + {resetLabel} + + )} + + + )} + + ); +} diff --git a/web/portal/src/landing/DeveloperDashboard.tsx b/web/portal/src/landing/DeveloperDashboard.tsx new file mode 100644 index 00000000..402de96c --- /dev/null +++ b/web/portal/src/landing/DeveloperDashboard.tsx @@ -0,0 +1,141 @@ +import AddRoundedIcon from '@mui/icons-material/AddRounded'; +import BarChartRoundedIcon from '@mui/icons-material/BarChartRounded'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import type { SvgIconComponent } from '@mui/icons-material'; + +import { color, radius } from '../theme/tokens'; +import { KeyField, PanelHeader, UsageMeter } from './DashboardPanel'; +import { GradientSection, SectionHeading, WindowCard } from './primitives'; + +/** + * "Everything in one place" — a picture of the dashboard, beside three claims. + * + * The card is built from the SAME components the real `/dashboard` renders + * (`DashboardPanel.tsx`), with sample values instead of a session. That is the + * point: the promise this section makes is checkable by signing in, and a + * preview drawn separately would be free to drift from the thing it previews. + */ + +const SAMPLE_KEY = 'sf_live_k8mN2pQxRvLzW9aTbYcUoJeHdFgIsSw4...'; +const SAMPLE_USED = 42_180; +const SAMPLE_LIMIT = 100_000; + +const CLAIMS: readonly { + icon: SvgIconComponent; + title: string; + body: string; +}[] = [ + { + icon: LockOutlinedIcon, + title: 'API Key Management', + body: 'View and copy your key at any time. Rotate once per month if needed.', + }, + { + icon: BarChartRoundedIcon, + title: 'Usage Dashboard', + body: 'Live request count against your monthly quota with reset date shown clearly.', + }, + { + icon: AddRoundedIcon, + title: 'Quick Start Guide', + body: 'Copy-ready curl examples and SDK snippets shown right next to your key.', + }, +]; + +export function DeveloperDashboard() { + return ( + + + + {/* `aria-hidden`: it is a screenshot of a screen the visitor can go + and see, and the three claims to its right already say in words + what it shows. Reading out a sample API key would be noise. */} + + + + + + + + + + + + + + + + + + {CLAIMS.map(({ icon: Icon, title, body }) => ( + + + + + + + {title} + + + {body} + + + + ))} + + + + + ); +} diff --git a/web/portal/src/landing/DiscordIcon.tsx b/web/portal/src/landing/DiscordIcon.tsx new file mode 100644 index 00000000..72f91ab3 --- /dev/null +++ b/web/portal/src/landing/DiscordIcon.tsx @@ -0,0 +1,25 @@ +import SvgIcon, { type SvgIconProps } from '@mui/material/SvgIcon'; + +/** + * The Discord mark, inline. + * + * Inline rather than an exported asset or an icon-font glyph, for the reason + * that governs every asset on this page: the portal renders a credential and + * ships with a `default-src 'self'` CSP, so nothing may be fetched from a + * third-party host — and Discord's own CDN is exactly the kind of host that + * would force the policy open. As a path in the bundle it costs one gzipped + * kilobyte and no request. + * + * `currentColor`, so one component serves the white glyph on the blurple + * button and the muted one in the checklist without a second copy. + */ +export function DiscordIcon(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/web/portal/src/landing/Documentation.tsx b/web/portal/src/landing/Documentation.tsx new file mode 100644 index 00000000..33bb5893 --- /dev/null +++ b/web/portal/src/landing/Documentation.tsx @@ -0,0 +1,147 @@ +import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; +import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded'; +import KeyOutlinedIcon from '@mui/icons-material/KeyOutlined'; +import TaskAltRoundedIcon from '@mui/icons-material/TaskAltRounded'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import type { SvgIconComponent } from '@mui/icons-material'; + +import { color, radius } from '../theme/tokens'; +import { OPENAPI_JSON, QUICKSTART, SWAGGER_UI } from './links'; +import { Section, SectionHeading, cardBorder, cardSurface } from './primitives'; + +/** + * "Everything developers need" — six doors into the documentation. + * + * Every card is a LINK, not a panel of prose, and they all point at the same + * place today: the OpenAPI document, which is the only documentation artefact + * actually served (see `links.ts`). Task 0163 writes the quickstart and task + * 0195 mounts Swagger UI; when they land, the three constants in `links.ts` + * diverge and these cards start pointing at six real destinations. + * + * Cards rather than a list because that is what the design does, and links + * rather than `
`s because a card that describes a document and cannot open + * it is the kind of thing that makes a reviewer stop trusting the page. + */ + +type Doc = { + icon: SvgIconComponent; + title: string; + body: string; + href: string; +}; + +const DOCS: readonly Doc[] = [ + { + icon: ArticleOutlinedIcon, + title: 'Quick Start', + body: 'Get your first live response in under 5 minutes. Covers authentication and the most common endpoint.', + href: QUICKSTART, + }, + { + icon: KeyOutlinedIcon, + title: 'Authentication', + body: 'How API keys work, where to pass them, and what to expect when a key is missing or rate-limited.', + href: SWAGGER_UI, + }, + { + icon: TaskAltRoundedIcon, + title: 'Example Requests', + body: 'Copy-ready curl commands for every endpoint. Test against the live API from your terminal.', + href: SWAGGER_UI, + }, + { + icon: DescriptionOutlinedIcon, + title: 'OpenAPI Specification', + body: 'Full Swagger UI included. Explore, test and generate client code directly from the spec.', + href: OPENAPI_JSON, + }, + { + icon: CodeRoundedIcon, + title: 'SDK Examples', + body: 'Working code snippets in four languages to copy into your project.', + href: SWAGGER_UI, + }, + { + icon: ErrorOutlineRoundedIcon, + title: 'Rate Limits', + body: 'How throttling works, what headers to watch, and how to handle 429 responses gracefully.', + href: SWAGGER_UI, + }, +]; + +export function Documentation() { + return ( +
+ + + + + {DOCS.map(({ icon: Icon, title, body, href }) => ( + + + + + + {title} + + + {body} + + + ))} + + +
+ ); +} diff --git a/web/portal/src/landing/Endpoints.tsx b/web/portal/src/landing/Endpoints.tsx new file mode 100644 index 00000000..d6d7d3ba --- /dev/null +++ b/web/portal/src/landing/Endpoints.tsx @@ -0,0 +1,177 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import { color, font, radius } from '../theme/tokens'; +import { + Section, + SectionHeading, + WindowCard, + cardBorder, + cardSurface, +} from './primitives'; + +/** + * "Clean REST API. Full OpenAPI spec." — the route list and one real response. + * + * The paths are the design's, not the OpenAPI document's, and that is worth + * saying out loud: this repo serves `/v1/assets/{id}/price` and friends, while + * the mock shows `/prices`, `/pools` and `/history`. Reconciling the two is a + * product decision about the public surface, not a styling one, so this slice + * renders what the design says and flags the difference rather than quietly + * inventing a third answer. `links.ts` is where the real spec is linked from. + */ + +const ENDPOINTS: readonly { path: string; summary: string }[] = [ + { path: '/prices', summary: 'All asset prices' }, + { path: '/prices/{asset}', summary: 'Single asset' }, + { path: '/pools', summary: 'Liquidity pools' }, + { path: '/pools/{id}/stats', summary: 'Pool statistics' }, + { path: '/history/{asset}', summary: 'Historical prices' }, +]; + +/** The `Get` pill. One verb, so it is a constant rather than a prop. */ +function MethodBadge() { + return ( + + Get + + ); +} + +export function Endpoints() { + return ( +
+ + + + Clean REST API. + Full OpenAPI spec. + + } + subtitle="Every endpoint documented, every response typed." + /> + + + {ENDPOINTS.map(({ path, summary }) => ( + + + + {path} + + + {summary} + + + ))} + + + + + + + +
+ ); +} + +/** The response beside the list. A still frame, like the hero's terminal. */ +function ExampleResponse() { + const KEY = color.accent.violet[400]; + const STR = color.accent.emerald[400]; + const NUM = color.primary[400]; + const tok = (c: string, text: string) => ( + + {text} + + ); + + return ( + + + + + {tok(color.text.tertiary, '// GET /prices/XLM-USDC — 200 OK')} + {'\n{\n'} + {tok(KEY, '"asset"')}: {tok(STR, '"XLM-USDC"')},{'\n'} + {tok(KEY, '"price"')}: {tok(NUM, '0.0812')},{'\n'} + {tok(KEY, '"change_24h"')} {tok(NUM, '+2.14')},{'\n'} + {tok(KEY, '"volume_24h"')}: {tok(NUM, '142891.50')},{'\n'} + {tok(KEY, '"liquidity"')}: {tok(NUM, '2400000')},{'\n'} + {tok(KEY, '"source"')}: {tok(STR, '"soroswap"')},{'\n'} + {tok(KEY, '"updated_at"')}: {tok(STR, '"2026-04-13T14:23:51Z"')} + {'\n}'} + + + + + ); +} diff --git a/web/portal/src/landing/FairAccess.tsx b/web/portal/src/landing/FairAccess.tsx new file mode 100644 index 00000000..eeb02044 --- /dev/null +++ b/web/portal/src/landing/FairAccess.tsx @@ -0,0 +1,183 @@ +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import { color, font, radius } from '../theme/tokens'; +import { + Section, + SectionHeading, + SectionLabel, + cardBorder, + cardSurface, +} from './primitives'; + +/** + * "Reliable for every developer" — why the limits exist, and what they are. + * + * The three figures on the right are the same ones the dashboard shows a + * signed-in visitor (task 0188), which is the reason they are stated here at + * all: a developer deciding whether to build on this needs the quota BEFORE + * they have a key, not after. + * + * ⚠️ **These are hard-coded and the dashboard's are not.** The dashboard reads + * the rate limit from `/config` precisely so it cannot drift from what the + * gateway enforces; a marketing section cannot, because it renders for visitors + * with no session and often before the probe answers. If the free plan's limits + * change, this file is one of the two places that must change with it. + */ + +const REASONS: readonly string[] = [ + 'Discord OAuth — no throwaway signups', + '1 req/s per key — 2x CoinGecko free tier', + '100,000 requests/month quota', + 'AWS API Gateway infrastructure', +]; + +const LIMITS: readonly { + label: string; + figure: string; + unit: string; + note: string; +}[] = [ + { + label: 'Rate limit', + figure: '1', + unit: 'req / second', + note: '60 requests per minute', + }, + { + label: 'Monthly quota', + figure: '100K', + unit: 'requests / mo', + note: 'Resets on the 1st of each month', + }, + { + label: 'Cost', + figure: '$0', + unit: 'free tier', + note: 'No credit card required', + }, +]; + +export function FairAccess() { + return ( +
+ + + + Reliable for + every developer + + } + subtitle="Rate limits exist so no single key can degrade the experience for everyone else." + /> + + + {REASONS.map((reason) => ( + + + + + + {reason} + + + ))} + + + + + Free Tier Limits + {LIMITS.map(({ label, figure, unit, note }) => ( + + + {label} + + + + {figure} + + + {unit} + + + + {note} + + + ))} + + +
+ ); +} diff --git a/web/portal/src/landing/Faq.tsx b/web/portal/src/landing/Faq.tsx new file mode 100644 index 00000000..42ba9273 --- /dev/null +++ b/web/portal/src/landing/Faq.tsx @@ -0,0 +1,146 @@ +import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import type { ReactNode } from 'react'; + +import { color, radius } from '../theme/tokens'; +import { STELLAR_DISCORD_INVITE, SWAGGER_UI } from './links'; +import { Section, SectionHeading, cardBorder, cardSurface } from './primitives'; + +/** + * "Common questions" — eight accordions. + * + * ⚠️ **The QUESTIONS are the design's; the ANSWERS are written here and need a + * product read.** The Figma frame shows every row collapsed, so it supplies no + * answer text at all — and shipping eight rows that open onto nothing would be + * worse than not shipping the section. + * + * So each answer below restates something this repo has already decided + * somewhere else, and nothing more: the eligibility rule and the invite are + * task 0189's, the 1 req/s and 100,000/month figures are task 0157's and + * 0188's, "no manual approval" is the epic's premise, and the replacement cap + * is the rule task 0191 settled. **No answer invents a policy.** Two are worth + * a second pair of eyes before this goes in front of anyone — "Can I increase + * my quota?", where no mechanism exists today, and "Can I rotate my API key?", + * whose wording belongs to task 0191 and should be taken from there once that + * slice lands rather than paraphrased here. + */ + +type Faq = { question: string; answer: ReactNode }; + +const FAQS: readonly Faq[] = [ + { + question: 'How do I get an API key?', + answer: ( + <> + Sign in with Discord and the key is issued immediately. Two things are + checked when you ask for one: membership of the{' '} + Stellar Developers Discord, + and a Discord account that is not brand new. + + ), + }, + { + question: 'Do I need approval?', + answer: + 'No. There is no form, no queue and no manual review — eligibility is checked automatically through Discord when you ask for a key.', + }, + { + question: 'What are the rate limits?', + answer: + 'One request per second per key, and 100,000 requests per month. The monthly quota resets on the 1st of each month at 00:00 UTC.', + }, + { + question: 'Can I increase my quota?', + answer: + 'Not today — the free tier is the only plan, and everyone is on the same limits. If your project needs more, get in touch and tell us what you are building.', + }, + { + question: 'Is the API free?', + answer: + 'Yes. There is no paid tier and no credit card is required. The free tier is the whole product.', + }, + { + question: 'How often are prices updated?', + answer: + 'Prices come straight from Soroswap liquidity pools and are updated on every block. Usage figures on your dashboard are reported by AWS with a short delay, so requests from the last few minutes may not be counted yet.', + }, + { + question: 'Where is the documentation?', + answer: ( + <> + The full OpenAPI specification covers + every endpoint and every response shape. It is linked from your + dashboard too, next to your key. + + ), + }, + { + question: 'Can I rotate my API key?', + answer: + 'Yes — you can replace your key once per quota period. The replacement is issued straight away and the old key stops working.', + }, +]; + +export function Faq() { + return ( +
+ + + + {/* Two columns that become one, filled COLUMN-first so the reading + order matches the design's. A CSS grid filled row-first would put + question 2 top-right, which is what the mock shows — so row-first + it is, and the DOM order is the reading order at every width. */} + + {FAQS.map(({ question, answer }) => ( + + + } + sx={{ px: 2.5, py: 1 }} + > + {/* `h3`, so the eight questions are a navigable list under the + section's `h2` rather than eight anonymous buttons. */} + + {question} + + + + + {answer} + + + + ))} + + +
+ ); +} diff --git a/web/portal/src/landing/Features.tsx b/web/portal/src/landing/Features.tsx new file mode 100644 index 00000000..2db2b0d6 --- /dev/null +++ b/web/portal/src/landing/Features.tsx @@ -0,0 +1,163 @@ +import AutorenewRoundedIcon from '@mui/icons-material/AutorenewRounded'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'; +import ShowChartRoundedIcon from '@mui/icons-material/ShowChartRounded'; +import TrendingUpRoundedIcon from '@mui/icons-material/TrendingUpRounded'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import type { SvgIconComponent } from '@mui/icons-material'; + +import { color, radius } from '../theme/tokens'; +import { Section, SectionHeading, cardBorder, cardSurface } from './primitives'; + +/** + * "Everything you need to build Stellar applications" — six claims, one grid. + * + * The icons are **Material equivalents, not the exported Figma vectors.** + * Reading the real SVGs out of the file needs `download_assets`, and the Figma + * seat this was built against was out of monthly tool calls; the glyph shapes + * were matched from the rendered frame instead. Swapping in the exported + * assets is a one-line change per row and should happen — this note is here so + * the next person knows these were chosen, not designed. + * + * The three-hue cycle (emerald, blue, violet) IS from the design, including + * the pairing: the disc takes the accent's 900 shade and the glyph its 100, + * which is what keeps six bright icons from competing with the yellow CTA + * above them. + */ + +type Feature = { + icon: SvgIconComponent; + accent: { disc: string; glyph: string }; + title: string; + body: string; +}; + +const EMERALD = { + disc: color.accent.emerald[900], + glyph: color.accent.emerald[100], +}; +const BLUE = { disc: color.accent.blue[900], glyph: color.accent.blue[100] }; +const VIOLET = { + disc: color.accent.violet[900], + glyph: color.accent.violet[100], +}; + +const FEATURES: readonly Feature[] = [ + { + icon: AutorenewRoundedIcon, + accent: EMERALD, + title: 'Live Prices', + body: 'Real-time token prices for all Stellar assets. Sourced directly from Soroswap liquidity pools, updated on every block.', + }, + { + icon: ShowChartRoundedIcon, + accent: BLUE, + title: 'Liquidity Data', + body: 'Pool reserves, trading depth and liquidity metrics. Essential for swap routing and price impact calculations.', + }, + { + icon: TrendingUpRoundedIcon, + accent: VIOLET, + title: 'Historical Data', + body: 'Price history for charts and analytics. Build portfolio trackers and trading dashboards with full time-series data.', + }, + { + icon: MonitorHeartOutlinedIcon, + accent: EMERALD, + title: 'Fast Response Times', + body: 'API Gateway caching keeps latency low for repeated lookups. Optimized for high-frequency applications like trading bots.', + }, + { + icon: LockOutlinedIcon, + accent: BLUE, + title: 'Secure Access', + body: 'Every request requires an API key. Rate limiting and monthly quotas protect the service for all users.', + }, + { + icon: CodeRoundedIcon, + accent: VIOLET, + title: 'Developer Friendly', + body: 'REST API with full OpenAPI specification. Swagger UI included. SDK examples in JavaScript, Python, Rust and Go.', + }, +]; + +export function Features() { + return ( +
+ + + Everything you need to build + {/* A hard break, matching the design's two centred lines — but + only from `md` up. At 375 px the headline wraps on its own and + a forced break lands mid-phrase. */} + {' '} + Stellar applications + + } + subtitle="Purpose-built for the Stellar ecosystem. Not a generic crypto data provider." + /> + + {/* CSS grid rather than MUI's `Grid`: three equal columns that become + one at 375 px is a single `repeat(auto-fit, …)` declaration, and the + cards must stretch to the tallest in their row — which `Grid`'s + item-level sizing does not give without a wrapper per cell. */} + + {FEATURES.map(({ icon: Icon, accent, title, body }) => ( + + + + + + {title} + + + {body} + + + ))} + + +
+ ); +} diff --git a/web/portal/src/landing/FinalCta.tsx b/web/portal/src/landing/FinalCta.tsx new file mode 100644 index 00000000..1ad85a46 --- /dev/null +++ b/web/portal/src/landing/FinalCta.tsx @@ -0,0 +1,69 @@ +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Link as RouterLink } from 'react-router-dom'; + +import { color } from '../theme/tokens'; +import { LOGIN_ROUTE, SWAGGER_UI } from './links'; +import { ArrowBadge, GradientSection } from './primitives'; + +/** + * "Start building today" — the closing call to action. + * + * The same `canOfferKey` rule the hero and the navbar follow: while the portal + * is shut there is nothing behind "Get API Key", so the page does not offer it. + * What is left is the documentation button, promoted to the filled style — + * a closing section with one greyed-out control reads as a page that failed to + * load rather than a product that is not open yet. + */ +export function FinalCta({ canOfferKey }: { canOfferKey: boolean }) { + return ( + + + + Start building today + + + Get instant access to the Prices API. Sign in with Discord, receive + your key, and make your first call in under a minute. + + + {canOfferKey && ( + + )} + {/* White, not the brand yellow: the design's second button here is + light-on-dark, which is what keeps two filled buttons side by side + from competing. */} + + + + + ); +} diff --git a/web/portal/src/landing/Hero.tsx b/web/portal/src/landing/Hero.tsx new file mode 100644 index 00000000..dfbe1f3e --- /dev/null +++ b/web/portal/src/landing/Hero.tsx @@ -0,0 +1,308 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { alpha } from '@mui/material/styles'; +import { Link as RouterLink } from 'react-router-dom'; + +import rumblefishLogo from '../assets/rumblefish-logo.png'; +import { color, font, radius } from '../theme/tokens'; +import { LOGIN_ROUTE, SWAGGER_UI } from './links'; +import { ArrowBadge, cardBorder } from './primitives'; +import { Terminal } from './Terminal'; + +/** + * The first screen: what the API is, and the two things a developer does next. + * + * The task's story is "from the landing page to a working `curl` in under a + * minute", and this section is the whole minute — the sentence that says what + * the thing is, a real request beside it, and two controls. Everything below + * this fold is elaboration. + * + * **"Get API Key" is an in-page anchor, not a link to the sign-in route.** The + * portal ships CLOSED (task 0183): while `PORTAL_ENABLED` is false the backend + * answers `/auth/*` with an empty 404, so a button pointing there would be a + * button that cannot work — the exact thing task 0186 refused to render. The + * anchor scrolls to the portal panel, which asks `/config` and then says + * truthfully either "not yet available" or "sign in with Discord". That keeps + * one control on the page in both states instead of a hero that has to know + * which one it is in. + */ + +/** + * The `h1`. The page has exactly one, and it is this. + * + * `canOfferKey` is the `/config` probe's verdict, lifted to `LandingPage` — see + * the note there. When it is false the primary call to action is not rendered + * at all and "View documentation" takes its place as the filled button: a + * closed portal still has an API worth reading about, and a hero left with a + * single outlined control looks like something failed to load. + */ +export function Hero({ canOfferKey }: { canOfferKey: boolean }) { + return ( + + {/* The Figma frame's `Grid layers` — a faint rule grid under an elliptical + glow. Painted with two CSS gradients rather than the exported vector: + it is a texture nobody looks at directly, and shipping it as markup + would put ~40 `` elements in the accessibility tree for it. + `pointer-events: none` so it never eats a click meant for the CTA. */} + + + + + {/* 675 / 525 in the Figma frame — the copy column is the wider one. + Equal halves put the terminal at ~600 px, which stretched the + snippet into a single unreadable line. */} + + + + + Real-time prices for{' '} + {/* The second line is the brand colour, and it is a `` + inside the same heading rather than a second element: it is + one sentence, and splitting it would have a screen reader + announce two headings where a sighted reader sees one. */} + {/* `display: block` from `md` up, so the headline breaks where + the design breaks it — "Real-time prices for" / "Stellar + developers". Letting it wrap naturally put "Stellar" on the + first line at 1440, which reads as a two-colour accident + rather than a two-line headline. Inline at `xs`, where the + line is too narrow for either arrangement to be a choice. */} + + Stellar developers + + + + + Token prices, liquidity data and market insights for wallets, DEX + aggregators and DeFi applications. Powered by Soroswap + infrastructure. + + + + {canOfferKey && ( + + )} + + + + + + + + + + + ); +} + +/** + * "Live on Stellar Mainnet". + * + * A static claim about the deployment, not a health indicator — there is no + * probe behind it and the dot does not pulse. A green dot that is always green + * is a decoration; a green dot that could be red would need a status endpoint + * and an answer for what it shows while it is loading, which is a feature + * nobody asked for on a marketing header. + */ +function StatusBadge() { + return ( + + + + Live on Stellar Mainnet + + + ); +} + +/** + * The strip under the hero: who built it, and four claims about what it is. + * + * Separate from `Hero` because it is a separate frame in the design with its + * own background, and because it is the first thing that would be cut if the + * page needed to be shorter — a section that can be deleted in one line is + * worth keeping in one component. + */ +export function TrustBand() { + const claims = [ + 'Production-ready', + 'API Gateway protected', + 'Stellar ecosystem', + 'OpenAPI docs', + ]; + + return ( + + + + } + > + + + Built by + + {/* The real mark, recovered from the Figma export — see the note + in `Chrome.tsx`. It carries "Rumble Fish" as its `alt`, so the + line still reads "Built by Rumble Fish" to a screen reader. */} + + + + + {claims.map((claim) => ( + + {claim} + + ))} + + + + + ); +} diff --git a/web/portal/src/landing/LoginCard.tsx b/web/portal/src/landing/LoginCard.tsx new file mode 100644 index 00000000..839ed1e7 --- /dev/null +++ b/web/portal/src/landing/LoginCard.tsx @@ -0,0 +1,346 @@ +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { alpha } from '@mui/material/styles'; +import type { ReactNode } from 'react'; + +import { color, font, radius } from '../theme/tokens'; +import { DiscordIcon } from './DiscordIcon'; +import { cardBorder } from './primitives'; + +/** + * The login card and the pieces the five sign-in screens are assembled from + * (Figma frame `778:2499`). + * + * The design is one card in three bands — a centred header, a body that + * changes per state, and a darker legal footer — with two kinds of callout + * inside the body. Building those as parameters rather than as five separate + * screens is what keeps the states honest: they differ by their message and + * their action, and a reader can see that at a glance instead of diffing five + * near-identical layouts. + * + * **Colours are measured off the exported render, not guessed.** The Figma seat + * this was built against had no MCP calls left, so the frame arrived as a PNG; + * every value below was sampled from it, and the ones that turned out to be + * design-system tokens (#272727 `Surface/Gray/Main`, #1a1a1a `…/Main-alt`, + * #535353 `Stroke/Default`, #004f3b `Accent/Emerald/900`) are referenced + * through `tokens.ts` rather than repeated as literals. The two that are NOT in + * the token set — the error callout's #460809 fill and #82181a edge — are + * named here as what they are. + */ + +/** Discord's brand blurple. Their colour, so it is not a theme token. */ +export const DISCORD = '#5865f2'; + +/** The error callout's fill and edge. Sampled; no matching Figma variable. */ +const ERROR_SURFACE = '#460809'; +const ERROR_EDGE = '#82181a'; + +/** + * The card shell. + * + * `component="section"` with the heading inside it, so each state is a landmark + * a screen reader can jump to rather than a `
` that happens to look like a + * panel. + */ +export function LoginCard({ + title, + titleComponent = 'h3', + subtitle, + children, + footer, +}: { + title: ReactNode; + /** + * The heading LEVEL, separate from the visual size. + * + * `h1` on `/login`, where this card is the whole page and its title is the + * page's subject. `h3` on the landing, where the hero owns the `h1` and the + * status panel's hidden `h2` names the section. Getting this wrong does not + * change a pixel and does change whether the document outline makes sense. + */ + titleComponent?: 'h1' | 'h2' | 'h3'; + subtitle: ReactNode; + children: ReactNode; + footer?: ReactNode; +}) { + return ( + + + + {/* Sized by `variant`, levelled by `component` — the design's 40 px + title at whatever depth the page it is on requires. */} + + {title} + + + {subtitle} + + + + + + + {children} + + + {footer && ( + <> + + + {footer} + + + )} + + ); +} + +/** + * "Built by Rumble Fish", the card's top line. + * + * The Figma frame uses the 188 × 47 wordmark image. Set as text until that + * asset is exported — a broken `` at the top of the sign-in card is a + * worse first impression than the name in the heading face, and the export + * needs a Figma call this build did not have. + */ +function BuiltBy() { + return ( + + + Built by + + + RUMBLEFISH + + + ); +} + +/** + * The boxed message inside a state's body — the design's two variants. + * + * `neutral` carries a fact the visitor can act on; `error` carries a failure. + * They differ by colour AND by icon AND by wording, which is task 0193's rule + * about "could not verify" versus "not a member": a refusal the visitor can fix + * must never look like the same event as one they cannot. + */ +export function Callout({ + variant, + icon, + title, + children, +}: { + variant: 'neutral' | 'error' | 'discord'; + icon?: ReactNode; + /** + * Optional: the states this slice inherits from tasks 0183 and 0185 are one + * sentence with no headline, and inventing a bold line above them would be + * this slice deciding copy that belongs to those tasks. + */ + title?: ReactNode; + children?: ReactNode; +}) { + const skin = { + neutral: { + surface: color.surface.background, + edge: alpha(color.stroke.default, 0.45), + tile: color.surface.gray, + glyph: color.text.secondary, + heading: color.text.primary, + fallbackIcon: , + }, + error: { + surface: ERROR_SURFACE, + edge: ERROR_EDGE, + tile: ERROR_EDGE, + glyph: color.text.error, + heading: color.text.error, + fallbackIcon: , + }, + discord: { + surface: color.surface.background, + edge: alpha(color.stroke.default, 0.45), + tile: DISCORD, + glyph: color.white, + heading: color.text.primary, + fallbackIcon: , + }, + }[variant]; + + return ( + + + {icon ?? skin.fallbackIcon} + + + {title && ( + + {title} + + )} + {children && ( + // `component="div"`, because callers pass the `

` elements those + // other tasks own — and a `

` inside a `

` is markup the browser + // silently repairs by closing the outer one, which moves the text out + // of the box that was meant to contain it. + + {children} + + )} + + + ); +} + +/** The "What you get" rule with its label sitting in the gap. */ +export function LabelledRule({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +/** + * The four-item "What you get" list. + * + * A real `

)} - {view.state === 'none' && !landedWithKey && ( + {emptyKeyCard && ( <> - -

- One key, on the free plan. Asking again later shows the same key - rather than issuing another. -

+ {/* The `Dashboard - no key` frame (Adam, 2026-08-26): a red strip and + one full-width control, and nothing else in the card. + + ⚠️ **This DROPS two sentences that were here** — [`Prerequisites`] + (task 0189's eligibility wording) and 0187's "One key, on the free + plan…". Neither is re-worded anywhere; they are simply not on this + card any more, and the prerequisites are still stated in full on + the landing page, before the visitor authenticates, which is where + the epic's criterion puts them. Flagged rather than done quietly, + because the rule this slice works under is that it re-decides no + other slice's copy: this is a frame saying what the card contains, + not this slice deciding those sentences were wrong. + + What the strip's own sentence adds is the DIAGNOSIS. "You have no + key" is a state; "issuance can fail during sign-in" is the reason a + signed-in visitor might be looking at it, and it is the half that + tells them pressing the button below is worth doing rather than a + repeat of something that already failed. */} + +

+ No API key found for your account. This can happen if key issuance + failed during sign-in. +

+
{/* A link, not a button: issuing is an OAuth round-trip (see - `issueUrl`), and only a top-level navigation can carry it. */} -
Get my API key + `issueUrl`), and only a top-level navigation can carry it. + + `data-variant="cta"` widens it to the card, which is the one place + the frame draws this control as a full-width bar. The chrome's + rule for issue links otherwise pins them to `flex-start` on + purpose — a stretched yellow row reads as a banner rather than a + control — and that reasoning still holds everywhere it is NOT the + only thing in the card. Here it is the only thing in the card, and + a banner that is also the single action is just the action. */} + + + Generate API Key + )} @@ -1557,22 +2688,40 @@ function ApiKey({ few characters of a credential is a habit borrowed from card numbers, where the rest is high-entropy. Here it would leak part of the secret for no benefit anyone asked for. */} - {/* ⚠️ **The first-login card shows the key, unmasked.** Task 0187 - masks by default and this keeps that everywhere else; the frame - Adam sent for this one screen draws the credential in the clear, - and the reasoning holds: the visitor completed the OAuth - round-trip seconds ago, the sentence above says "copy it below", - and a mask plus a Reveal press between a developer and the thing - they just asked for is friction with nobody watching that a - returning visit does not have. Every later load is masked. */} + {/* ⚠️ **The first-login card masks too, since 2026-08-26 (Adam).** + It briefly did not: this slice read the frame as drawing the + credential in the clear and argued the friction was not worth it + seconds after an OAuth round-trip. Re-reading the frame, the + first-login box holds a run of dots and a "Show key" control — + so 0187's rule has no exception after all, and the one place the + mask was lifted is now the one place it was wrong. The card that + delivers a credential is exactly the card most likely to be on a + shared screen. */} {/* No label inside the box: the card's own header says "API Key" one line above it, and the frame draws the ring bare. */} `, so the chrome's rule draws it as the dark + // pill the frame draws. + inlineAction={ + } actions={ justIssued ? ( @@ -1607,26 +2756,23 @@ function ApiKey({ /> Copy key - {/* The frame's second control, in the row the frame puts it - in — and NOT with the frame's word. It says "Regenerate" - and its dialog promises a key "again" after the 1st; - this build deactivates and issues nothing until the next - period, which is task 0191's decided model and wording. - 0193 restyles copy, it does not re-decide it. */} + in. It opens the confirmation (`ReplaceKey`) — never the + round-trip, which is reached only from the armed confirm + inside it. */} {/* ⚠️ **"Regenerate" is Adam's word, chosen on 2026-08-25 over task 0191's "Replace my key…"** — the frame's, and - the one the button now carries. The BEHAVIOUR behind it - is unchanged and is not what the word implies: pressing - it deactivates the key and issues nothing, and the - confirmation that opens says exactly that in 0191's - wording. Where the two disagree, the dialog is the one - telling the truth. */} + the one the button now carries. On 2026-08-26 the dialog + followed it: its heading, its confirm button and its + typed phrase (`regenerate-key`) all say the same verb, + because a dialog that demanded `delete-key` under a + button marked "Regenerate" asked the visitor to agree to + a sentence they had not read. The BEHAVIOUR is unchanged + and is still not what the word implies: pressing this + deactivates the key and issues nothing until the period + rolls, and the dialog says so in task 0191's wording. + Where the frame's word and the build disagree, the + dialog is the one telling the truth. */} {!replacing && (