From 1467a948d00ceefa9277dc0a5380923415a15c96 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 15:17:56 +0000 Subject: [PATCH] Initial server: Deno/Hono backend with auth, CSRF, Hydra consent, and flow proxy Hono app serving as the login UI and admin panel for Ory Kratos + Hydra. Handles OIDC consent/login flows, session management, avatar uploads, and proxies Kratos admin/public APIs. --- .dockerignore | 29 ++ .gitignore | 25 ++ Dockerfile | 26 ++ deno.json | 15 + deno.lock | 404 +++++++++++++++++++++ dev.toml | 8 + main.ts | 79 ++++ server/auth.ts | 289 +++++++++++++++ server/csrf.ts | 90 +++++ server/flow.ts | 77 ++++ server/hydra.ts | 244 +++++++++++++ server/proxy.ts | 66 ++++ server/s3.ts | 283 +++++++++++++++ ui/cunningham.ts | 10 + ui/index.html | 16 + ui/package.json | 30 ++ ui/public/favicon.svg | 13 + ui/src/App.tsx | 72 ++++ ui/src/api/avatar.ts | 41 +++ ui/src/api/client.ts | 25 ++ ui/src/api/courier.ts | 32 ++ ui/src/api/flows.ts | 58 +++ ui/src/api/hydra.ts | 75 ++++ ui/src/api/identities.ts | 100 +++++ ui/src/api/schemas.ts | 23 ++ ui/src/api/session.ts | 6 + ui/src/api/sessions.ts | 37 ++ ui/src/components/Avatar.tsx | 50 +++ ui/src/components/AvatarUpload.tsx | 108 ++++++ ui/src/components/ConfirmModal.tsx | 60 +++ ui/src/components/DashboardNav.tsx | 126 +++++++ ui/src/components/FlowNodes/FlowForm.tsx | 103 ++++++ ui/src/components/FlowNodes/NodeAnchor.tsx | 19 + ui/src/components/FlowNodes/NodeImage.tsx | 23 ++ ui/src/components/FlowNodes/NodeInput.tsx | 124 +++++++ ui/src/components/FlowNodes/NodeScript.tsx | 39 ++ ui/src/components/FlowNodes/NodeText.tsx | 27 ++ ui/src/components/SchemaForm/index.tsx | 44 +++ ui/src/components/SchemaForm/templates.tsx | 32 ++ ui/src/components/SchemaForm/widgets.tsx | 86 +++++ ui/src/components/WaffleButton.tsx | 93 +++++ ui/src/cunningham/useCunninghamTheme.tsx | 37 ++ ui/src/layouts/AuthLayout.tsx | 35 ++ ui/src/layouts/DashboardLayout.tsx | 89 +++++ ui/src/main.tsx | 16 + ui/src/pages/auth/ConsentPage.tsx | 156 ++++++++ ui/src/pages/auth/ErrorPage.tsx | 41 +++ ui/src/pages/auth/LoginPage.tsx | 88 +++++ ui/src/pages/auth/LogoutPage.tsx | 65 ++++ ui/src/pages/auth/OnboardingWizard.tsx | 311 ++++++++++++++++ ui/src/pages/auth/RecoveryPage.tsx | 33 ++ ui/src/pages/auth/RegistrationPage.tsx | 34 ++ ui/src/pages/auth/VerificationPage.tsx | 33 ++ ui/src/pages/courier/index.tsx | 141 +++++++ ui/src/pages/identities/create.tsx | 86 +++++ ui/src/pages/identities/detail.tsx | 177 +++++++++ ui/src/pages/identities/edit.tsx | 87 +++++ ui/src/pages/identities/index.tsx | 171 +++++++++ ui/src/pages/schemas/index.tsx | 106 ++++++ ui/src/pages/sessions/index.tsx | 121 ++++++ ui/src/pages/settings/profile.tsx | 200 ++++++++++ ui/src/pages/settings/security.tsx | 250 +++++++++++++ ui/src/stores/session.ts | 105 ++++++ ui/tsconfig.json | 22 ++ ui/vite.config.ts | 14 + 65 files changed, 5525 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 dev.toml create mode 100644 main.ts create mode 100644 server/auth.ts create mode 100644 server/csrf.ts create mode 100644 server/flow.ts create mode 100644 server/hydra.ts create mode 100644 server/proxy.ts create mode 100644 server/s3.ts create mode 100644 ui/cunningham.ts create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/public/favicon.svg create mode 100644 ui/src/App.tsx create mode 100644 ui/src/api/avatar.ts create mode 100644 ui/src/api/client.ts create mode 100644 ui/src/api/courier.ts create mode 100644 ui/src/api/flows.ts create mode 100644 ui/src/api/hydra.ts create mode 100644 ui/src/api/identities.ts create mode 100644 ui/src/api/schemas.ts create mode 100644 ui/src/api/session.ts create mode 100644 ui/src/api/sessions.ts create mode 100644 ui/src/components/Avatar.tsx create mode 100644 ui/src/components/AvatarUpload.tsx create mode 100644 ui/src/components/ConfirmModal.tsx create mode 100644 ui/src/components/DashboardNav.tsx create mode 100644 ui/src/components/FlowNodes/FlowForm.tsx create mode 100644 ui/src/components/FlowNodes/NodeAnchor.tsx create mode 100644 ui/src/components/FlowNodes/NodeImage.tsx create mode 100644 ui/src/components/FlowNodes/NodeInput.tsx create mode 100644 ui/src/components/FlowNodes/NodeScript.tsx create mode 100644 ui/src/components/FlowNodes/NodeText.tsx create mode 100644 ui/src/components/SchemaForm/index.tsx create mode 100644 ui/src/components/SchemaForm/templates.tsx create mode 100644 ui/src/components/SchemaForm/widgets.tsx create mode 100644 ui/src/components/WaffleButton.tsx create mode 100644 ui/src/cunningham/useCunninghamTheme.tsx create mode 100644 ui/src/layouts/AuthLayout.tsx create mode 100644 ui/src/layouts/DashboardLayout.tsx create mode 100644 ui/src/main.tsx create mode 100644 ui/src/pages/auth/ConsentPage.tsx create mode 100644 ui/src/pages/auth/ErrorPage.tsx create mode 100644 ui/src/pages/auth/LoginPage.tsx create mode 100644 ui/src/pages/auth/LogoutPage.tsx create mode 100644 ui/src/pages/auth/OnboardingWizard.tsx create mode 100644 ui/src/pages/auth/RecoveryPage.tsx create mode 100644 ui/src/pages/auth/RegistrationPage.tsx create mode 100644 ui/src/pages/auth/VerificationPage.tsx create mode 100644 ui/src/pages/courier/index.tsx create mode 100644 ui/src/pages/identities/create.tsx create mode 100644 ui/src/pages/identities/detail.tsx create mode 100644 ui/src/pages/identities/edit.tsx create mode 100644 ui/src/pages/identities/index.tsx create mode 100644 ui/src/pages/schemas/index.tsx create mode 100644 ui/src/pages/sessions/index.tsx create mode 100644 ui/src/pages/settings/profile.tsx create mode 100644 ui/src/pages/settings/security.tsx create mode 100644 ui/src/stores/session.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d6b29ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Dependencies (rebuilt in Docker) +ui/node_modules/ + +# Build artifacts (rebuilt in Docker) +ui/dist/ +kratos-admin +kratos-admin-* + +# Dev/editor +.git +.gitignore +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Deno cache +.deno/ + +# Test files +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6fa9e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Dependencies +ui/node_modules/ + +# Build artifacts +ui/dist/ +kratos-admin +kratos-admin-* + +# Dev/editor +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Deno cache +.deno/ + +# Lock files managed per-environment +ui/package-lock.json +*.timestamp-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b981c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: build UI +FROM node:22-alpine AS ui-builder +WORKDIR /app/ui +COPY ui/package.json ui/package-lock.json* ./ +RUN npm ci +COPY ui/ ./ +RUN npm run build + +# Stage 2: compile Deno binary +FROM denoland/deno:2.7.3 AS deno-builder +WORKDIR /app +COPY deno.json deno.lock* ./ +COPY server/ ./server/ +COPY main.ts ./ +COPY --from=ui-builder /app/ui/dist ./ui/dist +RUN deno cache main.ts +RUN deno task compile + +# Stage 3: distroless +FROM gcr.io/distroless/cc-debian12:nonroot +WORKDIR /app +# Copy binary and UI dist so serveStatic({ root: "./ui/dist" }) resolves from /app +COPY --from=deno-builder /app/kratos-admin ./ +COPY --from=ui-builder /app/ui/dist ./ui/dist +EXPOSE 3000 +ENTRYPOINT ["/app/kratos-admin"] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..3d20c05 --- /dev/null +++ b/deno.json @@ -0,0 +1,15 @@ +{ + "tasks": { + "dev": "deno task dev:server & deno task dev:ui", + "dev:server": "deno run -A --watch main.ts", + "dev:ui": "deno run -A npm:vite ui/", + "build:ui": "deno run -A npm:vite build ui/", + "build": "deno task build:ui && deno task compile", + "compile": "deno compile --allow-net --allow-read --allow-env --include ui/dist -o kratos-admin main.ts", + "test": "deno test -A" + }, + "imports": { + "hono": "jsr:@hono/hono@^4", + "hono/deno": "jsr:@hono/hono/deno" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..64c833b --- /dev/null +++ b/deno.lock @@ -0,0 +1,404 @@ +{ + "version": "5", + "specifiers": { + "jsr:@hono/hono@*": "4.12.3", + "jsr:@hono/hono@4": "4.12.3", + "npm:vite@*": "7.3.1" + }, + "jsr": { + "@hono/hono@4.12.3": { + "integrity": "53c2d99912626a8bc293d6f69649af8148961b05d44485f2c0ed3053657d324c" + } + }, + "npm": { + "@esbuild/aix-ppc64@0.27.3": { + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.27.3": { + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.27.3": { + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.27.3": { + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.27.3": { + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.27.3": { + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.27.3": { + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.27.3": { + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.27.3": { + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.27.3": { + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.27.3": { + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.27.3": { + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.27.3": { + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.27.3": { + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.27.3": { + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.27.3": { + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.27.3": { + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.27.3": { + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.27.3": { + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.27.3": { + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.27.3": { + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.27.3": { + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.27.3": { + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.27.3": { + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.27.3": { + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.27.3": { + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-android-arm-eabi@4.59.0": { + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.59.0": { + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.59.0": { + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.59.0": { + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.59.0": { + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.59.0": { + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.59.0": { + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.59.0": { + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.59.0": { + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.59.0": { + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loong64-gnu@4.59.0": { + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-loong64-musl@4.59.0": { + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-ppc64-gnu@4.59.0": { + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-ppc64-musl@4.59.0": { + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.59.0": { + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.59.0": { + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.59.0": { + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.59.0": { + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.59.0": { + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-openbsd-x64@4.59.0": { + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-openharmony-arm64@4.59.0": { + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.59.0": { + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.59.0": { + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-gnu@4.59.0": { + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-x64-msvc@4.59.0": { + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "esbuild@0.27.3": { + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "fdir@6.5.0_picomatch@4.0.3": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + }, + "postcss@8.5.8": { + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "rollup@4.59.0": { + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loong64-gnu", + "@rollup/rollup-linux-loong64-musl", + "@rollup/rollup-linux-ppc64-gnu", + "@rollup/rollup-linux-ppc64-musl", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-openbsd-x64", + "@rollup/rollup-openharmony-arm64", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "tinyglobby@0.2.15": { + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "vite@7.3.1": { + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dependencies": [ + "esbuild", + "fdir", + "picomatch", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + } + }, + "workspace": { + "dependencies": [ + "jsr:@hono/hono@*", + "jsr:@hono/hono@4" + ] + } +} diff --git a/dev.toml b/dev.toml new file mode 100644 index 0000000..8da4bf1 --- /dev/null +++ b/dev.toml @@ -0,0 +1,8 @@ +# Local development config -- copy to .env or set in shell +KRATOS_PUBLIC_URL = "http://localhost:4433" +KRATOS_ADMIN_URL = "http://localhost:4434" +PUBLIC_URL = "http://localhost:3000" +PORT = "3000" +ADMIN_IDENTITY_IDS = "" +CSRF_COOKIE_SECRET = "dev-secret-change-in-production" +COOKIE_SECRET = "dev-cookie-secret" diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..0e0aaee --- /dev/null +++ b/main.ts @@ -0,0 +1,79 @@ +import { Hono } from "hono"; +import { serveStatic } from "hono/deno"; +import { + authMiddleware, + revokeAllSessionsHandler, + sessionHandler, +} from "./server/auth.ts"; +import { proxyHandler } from "./server/proxy.ts"; +import { csrfMiddleware } from "./server/csrf.ts"; +import { flowHandler, flowErrorHandler } from "./server/flow.ts"; +import { + acceptConsent, + acceptLogin, + acceptLogout, + getConsent, + getLogout, + rejectConsent, +} from "./server/hydra.ts"; +import { deleteAvatar, getAvatar, uploadAvatar } from "./server/s3.ts"; + +const app = new Hono(); + +// Health check -- no auth required +app.get("/health", (c) => + c.json({ ok: true, time: new Date().toISOString() })); + +// Auth middleware on everything except /health +app.use("/*", async (c, next) => { + if (c.req.path === "/health") return await next(); + return await authMiddleware(c, next); +}); + +// CSRF protection on non-API state-mutating requests +app.use("/*", csrfMiddleware); + +// Session endpoints +app.get("/api/auth/session", sessionHandler); +app.delete("/api/auth/sessions", revokeAllSessionsHandler); + +// Flow proxy (public — cookies forwarded but no session check) +app.get("/api/flow/error", flowErrorHandler); +app.get("/api/flow/:type", flowHandler); + +// Hydra proxy (CSRF only — no session required) +app.get("/api/hydra/consent", getConsent); +app.post("/api/hydra/consent/accept", acceptConsent); +app.post("/api/hydra/consent/reject", rejectConsent); +app.get("/api/hydra/logout", getLogout); +app.post("/api/hydra/logout/accept", acceptLogout); +app.post("/api/hydra/login/accept", acceptLogin); + +// Avatar S3 proxy (auth required) +app.put("/api/avatar", uploadAvatar); +app.get("/api/avatar/:id", getAvatar); +app.delete("/api/avatar", deleteAvatar); + +// Proxy all other /api/* requests to Kratos Admin (admin required via authMiddleware) +app.all("/api/*", proxyHandler); + +// Static files from ui/dist +app.use( + "/*", + serveStatic({ + root: "./ui/dist", + }), +); + +// SPA fallback: serve index.html for unmatched routes +app.use( + "/*", + serveStatic({ + root: "./ui/dist", + path: "index.html", + }), +); + +const port = parseInt(Deno.env.get("PORT") ?? "3000", 10); +console.log(`kratos-admin listening on :${port}`); +Deno.serve({ port }, app.fetch); diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..3c3627d --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,289 @@ +import type { Context, Next } from "hono"; + +const KRATOS_PUBLIC_URL = + Deno.env.get("KRATOS_PUBLIC_URL") ?? + "http://kratos-public.ory.svc.cluster.local:80"; +const PUBLIC_URL = Deno.env.get("PUBLIC_URL") ?? "http://localhost:3000"; +const KRATOS_ADMIN_URL = + Deno.env.get("KRATOS_ADMIN_URL") ?? + "http://kratos-admin.ory.svc.cluster.local:80"; + +// Routes that require no authentication at all +const PUBLIC_ROUTES = new Set([ + "/login", + "/registration", + "/recovery", + "/verification", + "/error", + "/health", +]); + +// Routes that need CSRF but not a session (Hydra flows) +const CSRF_ONLY_ROUTES = new Set(["/consent", "/logout"]); + +function getAdminList(): string[] { + const raw = Deno.env.get("ADMIN_IDENTITY_IDS") ?? ""; + if (!raw.trim()) return []; + return raw.split(",").map((s) => s.trim()).filter(Boolean); +} + +function extractSessionCookie(cookieHeader: string): string | null { + const cookies = cookieHeader.split(";").map((c) => c.trim()); + for (const cookie of cookies) { + if ( + cookie.startsWith("ory_session_") || + cookie.startsWith("ory_kratos_session") + ) { + return cookie; + } + } + return null; +} + +export interface SessionInfo { + id: string; + email: string; + session: unknown; +} + +export interface SessionResult { + info: SessionInfo | null; + needsAal2: boolean; + redirectTo?: string; +} + +/** Fetch the Kratos session for the given cookie. */ +export async function getSession( + cookieHeader: string, +): Promise { + const sessionCookie = extractSessionCookie(cookieHeader); + if (!sessionCookie) return { info: null, needsAal2: false }; + + try { + const resp = await fetch(`${KRATOS_PUBLIC_URL}/sessions/whoami`, { + headers: { cookie: sessionCookie }, + }); + if (resp.status === 403) { + // Session exists but AAL is too low — need 2FA step-up + const body = await resp.json().catch(() => null); + const redirectTo = body?.redirect_browser_to ?? body?.error?.details?.redirect_browser_to; + return { info: null, needsAal2: true, redirectTo }; + } + if (resp.status !== 200) return { info: null, needsAal2: false }; + const session = await resp.json(); + return { + info: { + id: session?.identity?.id ?? "", + email: session?.identity?.traits?.email ?? "", + session, + }, + needsAal2: false, + }; + } catch { + return { info: null, needsAal2: false }; + } +} + +/** Check if an identity has any 2FA method (TOTP or WebAuthn) configured. */ +async function has2fa(identityId: string): Promise { + try { + const resp = await fetch( + `${KRATOS_ADMIN_URL}/admin/identities/${identityId}?include_credential=totp&include_credential=webauthn`, + ); + if (!resp.ok) return true; // fail open — don't block on admin API errors + const identity = await resp.json(); + const creds = identity?.credentials ?? {}; + const hasTOTP = creds.totp?.identifiers?.length > 0; + const hasWebAuthn = creds.webauthn?.identifiers?.length > 0; + return hasTOTP || hasWebAuthn; + } catch { + return true; // fail open + } +} + +export function isAdmin(identityId: string, email: string): boolean { + const adminList = getAdminList(); + if (adminList.length === 0) return true; // bootstrap mode + return adminList.includes(identityId) || adminList.includes(email); +} + +function isPublicRoute(path: string): boolean { + // Exact match or path starts with the public route + / + for (const route of PUBLIC_ROUTES) { + if (path === route || path.startsWith(route + "/")) return true; + } + // Flow proxy and flow error endpoints are public (need cookies but not session validation here) + if (path.startsWith("/api/flow/")) return true; + // Static assets must be served without auth so the SPA can load + if (path.startsWith("/assets/") || path === "/index.html" || path === "/favicon.ico") return true; + return false; +} + +function isCsrfOnlyRoute(path: string): boolean { + for (const route of CSRF_ONLY_ROUTES) { + if (path === route || path.startsWith(route + "/")) return true; + } + // Hydra proxy endpoints + if (path.startsWith("/api/hydra/")) return true; + return false; +} + +function isAdminRoute(path: string): boolean { + // Admin API proxy (Kratos admin) and admin-only UI routes + const adminPrefixes = [ + "/api/identities", + "/api/admin", + "/api/sessions", + "/api/courier", + "/identities", + "/sessions", + "/courier", + "/schemas", + ]; + for (const prefix of adminPrefixes) { + if (path === prefix || path.startsWith(prefix + "/") || + path.startsWith(prefix + "?")) { + return true; + } + } + return false; +} + +export async function authMiddleware(c: Context, next: Next) { + const path = c.req.path; + + // Public routes: no auth needed + if (isPublicRoute(path)) { + return await next(); + } + + // CSRF-only routes: skip session check + if (isCsrfOnlyRoute(path)) { + return await next(); + } + + // All other routes need authentication + const cookieHeader = c.req.header("cookie") ?? ""; + const { info: sessionInfo, needsAal2, redirectTo } = await getSession( + cookieHeader, + ); + + if (needsAal2) { + // Session exists but needs 2FA — redirect to step-up login + if ( + path.startsWith("/api/") || + c.req.header("accept")?.includes("application/json") + ) { + return c.json({ error: "AAL2 required", redirectTo }, 403); + } + // Use Kratos-provided redirect URL if available, otherwise construct one + if (redirectTo) { + return c.redirect(redirectTo, 302); + } + const returnTo = encodeURIComponent(PUBLIC_URL + path); + return c.redirect( + `/kratos/self-service/login/browser?aal=aal2&return_to=${returnTo}`, + 302, + ); + } + + if (!sessionInfo) { + // API requests get 401, browser requests get redirected + if ( + path.startsWith("/api/") || + c.req.header("accept")?.includes("application/json") + ) { + return c.json({ error: "Unauthorized" }, 401); + } + const loginUrl = + `${PUBLIC_URL}/login?return_to=${encodeURIComponent(PUBLIC_URL + path)}`; + return c.redirect(loginUrl, 302); + } + + c.set("identity", { + id: sessionInfo.id, + email: sessionInfo.email, + session: sessionInfo.session, + }); + c.set("isAdmin", isAdmin(sessionInfo.id, sessionInfo.email)); + + // 2FA enrollment check — force users to set up TOTP/WebAuthn before using the app. + // Allow /security, /api/auth/*, /api/flow/*, and /kratos/* through so they can + // actually complete the setup flow. + const skipMfaCheck = path === "/onboarding" || path.startsWith("/onboarding/") || + path.startsWith("/api/auth/") || path.startsWith("/api/flow/") || + path.startsWith("/api/avatar/") || path.startsWith("/api/health/") || + path === "/health" || path.startsWith("/kratos/"); + if (!skipMfaCheck) { + const userHas2fa = await has2fa(sessionInfo.id); + c.set("has2fa", userHas2fa); + if (!userHas2fa) { + if ( + path.startsWith("/api/") || + c.req.header("accept")?.includes("application/json") + ) { + return c.json({ error: "2FA setup required", needs2faSetup: true }, 403); + } + return c.redirect("/onboarding", 302); + } + } + + // Admin-only routes: check admin status + if (isAdminRoute(path)) { + if (!c.get("isAdmin")) { + if ( + path.startsWith("/api/") || + c.req.header("accept")?.includes("application/json") + ) { + return c.json({ error: "Forbidden" }, 403); + } + return c.redirect("/settings", 302); + } + } + + await next(); +} + +/** DELETE /api/auth/sessions — revokes ALL sessions for the current identity. */ +export async function revokeAllSessionsHandler(c: Context): Promise { + const identity = c.get("identity"); + if (!identity?.id) { + return c.json({ error: "Unauthorized" }, 401); + } + + try { + const resp = await fetch( + `${KRATOS_ADMIN_URL}/admin/identities/${identity.id}/sessions`, + { method: "DELETE" }, + ); + if (resp.status === 204 || resp.status === 200) { + return c.json({ ok: true }); + } + return c.json({ error: "Failed to revoke sessions" }, 500); + } catch { + return c.json({ error: "Failed to revoke sessions" }, 500); + } +} + +/** GET /api/auth/session — returns current session + admin status. */ +export async function sessionHandler(c: Context): Promise { + const cookieHeader = c.req.header("cookie") ?? ""; + const { info: sessionInfo, needsAal2, redirectTo } = await getSession( + cookieHeader, + ); + + if (needsAal2) { + return c.json({ error: "AAL2 required", needsAal2: true, redirectTo }, 403); + } + + if (!sessionInfo) { + return c.json({ error: "Unauthorized" }, 401); + } + + const userHas2fa = await has2fa(sessionInfo.id); + return c.json({ + session: sessionInfo.session, + isAdmin: isAdmin(sessionInfo.id, sessionInfo.email), + needs2faSetup: !userHas2fa, + }); +} diff --git a/server/csrf.ts b/server/csrf.ts new file mode 100644 index 0000000..9a80c38 --- /dev/null +++ b/server/csrf.ts @@ -0,0 +1,90 @@ +import type { Context, Next } from "hono"; + +const CSRF_COOKIE_SECRET = + Deno.env.get("CSRF_COOKIE_SECRET") ?? "dev-secret-change-in-production"; +const CSRF_COOKIE_NAME = "ory-csrf-token"; + +const encoder = new TextEncoder(); + +async function hmacSign(data: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function hmacVerify( + data: string, + signature: string, + secret: string, +): Promise { + const expected = await hmacSign(data, secret); + if (expected.length !== signature.length) return false; + // Constant-time compare + let result = 0; + for (let i = 0; i < expected.length; i++) { + result |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return result === 0; +} + +export async function generateCsrfToken(): Promise<{ + token: string; + cookie: string; +}> { + const raw = crypto.randomUUID(); + const sig = await hmacSign(raw, CSRF_COOKIE_SECRET); + const token = `${raw}.${sig}`; + const cookie = + `${CSRF_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict`; + return { token, cookie }; +} + +function extractCookie(cookieHeader: string, name: string): string | null { + const cookies = cookieHeader.split(";").map((c) => c.trim()); + for (const cookie of cookies) { + if (cookie.startsWith(`${name}=`)) { + return cookie.slice(name.length + 1); + } + } + return null; +} + +async function verifyCsrfToken(req: Request): Promise { + const headerToken = req.headers.get("x-csrf-token"); + if (!headerToken) return false; + + const cookieHeader = req.headers.get("cookie") ?? ""; + const cookieToken = extractCookie(cookieHeader, CSRF_COOKIE_NAME); + if (!cookieToken) return false; + + // Both must match + if (headerToken !== cookieToken) return false; + + // Verify HMAC + const parts = headerToken.split("."); + if (parts.length !== 2) return false; + const [raw, sig] = parts; + return await hmacVerify(raw, sig, CSRF_COOKIE_SECRET); +} + +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export async function csrfMiddleware(c: Context, next: Next) { + // Only protect state-mutating methods on non-API routes + // (API routes proxy to Kratos which has its own CSRF) + if (MUTATING_METHODS.has(c.req.method) && !c.req.path.startsWith("/api")) { + const valid = await verifyCsrfToken(c.req.raw); + if (!valid) { + return c.text("CSRF token invalid or missing", 403); + } + } + await next(); +} diff --git a/server/flow.ts b/server/flow.ts new file mode 100644 index 0000000..e1164a7 --- /dev/null +++ b/server/flow.ts @@ -0,0 +1,77 @@ +import type { Context } from "hono"; + +const KRATOS_PUBLIC_URL = + Deno.env.get("KRATOS_PUBLIC_URL") ?? + "http://kratos-public.ory.svc.cluster.local:80"; + +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +]); + +function stripHopByHop(headers: Headers): Headers { + const out = new Headers(); + for (const [key, value] of headers.entries()) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== "host") { + out.set(key, value); + } + } + return out; +} + +/** GET /api/flow/:type?flow= — proxy Kratos self-service flow data. */ +export async function flowHandler(c: Context): Promise { + const type = c.req.param("type"); + const flowId = c.req.query("flow"); + + if (!flowId) { + return c.json({ error: "Missing flow query parameter" }, 400); + } + + const target = + `${KRATOS_PUBLIC_URL}/self-service/${type}/flows?id=${flowId}`; + + const reqHeaders = stripHopByHop(c.req.raw.headers); + + try { + const resp = await fetch(target, { headers: reqHeaders }); + const respHeaders = stripHopByHop(resp.headers); + return new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers: respHeaders, + }); + } catch { + return c.text("Kratos unavailable", 502); + } +} + +/** GET /api/flow/error?id= — proxy Kratos self-service error. */ +export async function flowErrorHandler(c: Context): Promise { + const errorId = c.req.query("id"); + + if (!errorId) { + return c.json({ error: "Missing id query parameter" }, 400); + } + + const target = `${KRATOS_PUBLIC_URL}/self-service/errors?id=${errorId}`; + const reqHeaders = stripHopByHop(c.req.raw.headers); + + try { + const resp = await fetch(target, { headers: reqHeaders }); + const respHeaders = stripHopByHop(resp.headers); + return new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers: respHeaders, + }); + } catch { + return c.text("Kratos unavailable", 502); + } +} diff --git a/server/hydra.ts b/server/hydra.ts new file mode 100644 index 0000000..de32f2e --- /dev/null +++ b/server/hydra.ts @@ -0,0 +1,244 @@ +import type { Context } from "hono"; + +const HYDRA_ADMIN_URL = + Deno.env.get("HYDRA_ADMIN_URL") ?? + "http://hydra-admin.ory.svc.cluster.local:4445"; + +const KRATOS_ADMIN_URL = + Deno.env.get("KRATOS_ADMIN_URL") ?? + "http://kratos-admin.ory.svc.cluster.local:80"; + +const TRUSTED_CLIENT_IDS = new Set( + (Deno.env.get("TRUSTED_CLIENT_IDS") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + +async function hydraFetch( + path: string, + init?: RequestInit, +): Promise { + const resp = await fetch(`${HYDRA_ADMIN_URL}${path}`, { + headers: { "Content-Type": "application/json", Accept: "application/json" }, + ...init, + }); + return resp; +} + +/** + * Fetch a Kratos identity and map its traits to standard OIDC claims, + * filtered by the granted scopes. + */ +async function getIdentityClaims( + subject: string, + grantedScopes: string[], +): Promise> { + const scopes = new Set(grantedScopes); + const claims: Record = {}; + + let traits: Record; + let verifiableAddresses: Array<{ value: string; verified: boolean }>; + try { + const resp = await fetch( + `${KRATOS_ADMIN_URL}/admin/identities/${subject}`, + { headers: { Accept: "application/json" } }, + ); + if (!resp.ok) return claims; + const identity = await resp.json(); + traits = (identity.traits as Record) ?? {}; + verifiableAddresses = identity.verifiable_addresses ?? []; + } catch { + return claims; + } + + if (scopes.has("email") && traits.email) { + claims.email = traits.email; + const addr = verifiableAddresses.find((a) => a.value === traits.email); + claims.email_verified = addr?.verified ?? false; + } + + if (scopes.has("profile")) { + if (traits.given_name) claims.given_name = traits.given_name; + if (traits.family_name) claims.family_name = traits.family_name; + if (traits.middle_name) claims.middle_name = traits.middle_name; + if (traits.nickname) claims.nickname = traits.nickname; + if (traits.picture) claims.picture = traits.picture; + if (traits.phone_number) claims.phone_number = traits.phone_number; + + // Synthesize "name" from given + family name + const parts = [traits.given_name, traits.family_name].filter(Boolean); + if (parts.length > 0) claims.name = parts.join(" "); + + // Non-standard but useful: map to first_name/last_name aliases + // that some La Suite apps read via OIDC_USERINFO_FULLNAME_FIELDS + if (traits.given_name) claims.first_name = traits.given_name; + if (traits.family_name) claims.last_name = traits.family_name; + } + + return claims; +} + +/** GET /api/hydra/consent?challenge= */ +export async function getConsent(c: Context): Promise { + const challenge = c.req.query("challenge"); + if (!challenge) return c.json({ error: "Missing challenge" }, 400); + + try { + const resp = await hydraFetch( + `/admin/oauth2/auth/requests/consent?consent_challenge=${challenge}`, + ); + const data = await resp.json(); + + // Auto-accept all consent requests — all our clients are internal/trusted. + // Hydra's skip_consent should prevent reaching here, but belt-and-suspenders. + { + const idTokenClaims = await getIdentityClaims( + data.subject, + data.requested_scope, + ); + const acceptResp = await hydraFetch( + `/admin/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`, + { + method: "PUT", + body: JSON.stringify({ + grant_scope: data.requested_scope, + grant_access_token_audience: + data.requested_access_token_audience, + remember: true, + remember_for: 2592000, + session: { id_token: idTokenClaims }, + }), + }, + ); + const acceptData = await acceptResp.json(); + return c.json({ redirect_to: acceptData.redirect_to, auto: true }); + } + + return c.json(data); + } catch { + return c.text("Hydra unavailable", 502); + } +} + +/** POST /api/hydra/consent/accept */ +export async function acceptConsent(c: Context): Promise { + const body = await c.req.json(); + const { challenge, grantScope, remember } = body; + if (!challenge) return c.json({ error: "Missing challenge" }, 400); + + try { + // Fetch the consent request to get the subject (identity ID) + const consentResp = await hydraFetch( + `/admin/oauth2/auth/requests/consent?consent_challenge=${challenge}`, + ); + const consentData = await consentResp.json(); + + const idTokenClaims = await getIdentityClaims( + consentData.subject, + grantScope ?? consentData.requested_scope, + ); + + const resp = await hydraFetch( + `/admin/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`, + { + method: "PUT", + body: JSON.stringify({ + grant_scope: grantScope, + remember: remember ?? false, + remember_for: 0, + session: { id_token: idTokenClaims }, + }), + }, + ); + const data = await resp.json(); + return c.json(data); + } catch { + return c.text("Hydra unavailable", 502); + } +} + +/** POST /api/hydra/consent/reject */ +export async function rejectConsent(c: Context): Promise { + const body = await c.req.json(); + const { challenge } = body; + if (!challenge) return c.json({ error: "Missing challenge" }, 400); + + try { + const resp = await hydraFetch( + `/admin/oauth2/auth/requests/consent/reject?consent_challenge=${challenge}`, + { + method: "PUT", + body: JSON.stringify({ + error: "access_denied", + error_description: "The resource owner denied the request", + }), + }, + ); + const data = await resp.json(); + return c.json(data); + } catch { + return c.text("Hydra unavailable", 502); + } +} + +/** GET /api/hydra/logout?challenge= */ +export async function getLogout(c: Context): Promise { + const challenge = c.req.query("challenge"); + if (!challenge) return c.json({ error: "Missing challenge" }, 400); + + try { + const resp = await hydraFetch( + `/admin/oauth2/auth/requests/logout?logout_challenge=${challenge}`, + ); + const data = await resp.json(); + return c.json(data); + } catch { + return c.text("Hydra unavailable", 502); + } +} + +/** POST /api/hydra/logout/accept */ +export async function acceptLogout(c: Context): Promise { + const body = await c.req.json(); + const { challenge } = body; + if (!challenge) return c.json({ error: "Missing challenge" }, 400); + + try { + const resp = await hydraFetch( + `/admin/oauth2/auth/requests/logout/accept?logout_challenge=${challenge}`, + { method: "PUT" }, + ); + const data = await resp.json(); + return c.json(data); + } catch { + return c.text("Hydra unavailable", 502); + } +} + +/** POST /api/hydra/login/accept — accept Hydra login challenge after Kratos login succeeds. */ +export async function acceptLogin(c: Context): Promise { + const body = await c.req.json(); + const { challenge, subject } = body; + if (!challenge || !subject) { + return c.json({ error: "Missing challenge or subject" }, 400); + } + + try { + const resp = await hydraFetch( + `/admin/oauth2/auth/requests/login/accept?login_challenge=${challenge}`, + { + method: "PUT", + body: JSON.stringify({ + subject, + remember: true, + remember_for: 0, + }), + }, + ); + const data = await resp.json(); + return c.json(data); + } catch { + return c.text("Hydra unavailable", 502); + } +} diff --git a/server/proxy.ts b/server/proxy.ts new file mode 100644 index 0000000..f2d2603 --- /dev/null +++ b/server/proxy.ts @@ -0,0 +1,66 @@ +import type { Context } from "hono"; + +const KRATOS_ADMIN_URL = + Deno.env.get("KRATOS_ADMIN_URL") ?? + "http://kratos-admin.ory.svc.cluster.local:80"; + +const KRATOS_PUBLIC_URL = + Deno.env.get("KRATOS_PUBLIC_URL") ?? + "http://kratos-public.ory.svc.cluster.local:80"; + +// Paths that must be served from the public API (not the admin API) +const PUBLIC_API_PATHS = ["/schemas"]; + +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +]); + +export async function proxyHandler(c: Context): Promise { + const url = new URL(c.req.url); + // Strip the /api prefix + const path = url.pathname.replace(/^\/api/, "") || "/"; + const isPublic = PUBLIC_API_PATHS.some((p) => path === p || path.startsWith(p + "/")); + const upstream = isPublic ? KRATOS_PUBLIC_URL : KRATOS_ADMIN_URL; + const target = `${upstream}${path}${url.search}`; + + const reqHeaders = new Headers(); + for (const [key, value] of c.req.raw.headers.entries()) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== "host") { + reqHeaders.set(key, value); + } + } + + let resp: Response; + try { + resp = await fetch(target, { + method: c.req.method, + headers: reqHeaders, + body: c.req.method !== "GET" && c.req.method !== "HEAD" + ? c.req.raw.body + : undefined, + redirect: "manual", + }); + } catch { + return c.text("Upstream unavailable", 502); + } + + const respHeaders = new Headers(); + for (const [key, value] of resp.headers.entries()) { + if (!HOP_BY_HOP.has(key.toLowerCase())) { + respHeaders.set(key, value); + } + } + + return new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers: respHeaders, + }); +} diff --git a/server/s3.ts b/server/s3.ts new file mode 100644 index 0000000..aebba75 --- /dev/null +++ b/server/s3.ts @@ -0,0 +1,283 @@ +import type { Context } from "hono"; + +const SEAWEEDFS_S3_URL = + Deno.env.get("SEAWEEDFS_S3_URL") ?? + "http://seaweedfs-filer.storage.svc.cluster.local:8333"; +const ACCESS_KEY = Deno.env.get("SEAWEEDFS_ACCESS_KEY") ?? ""; +const SECRET_KEY = Deno.env.get("SEAWEEDFS_SECRET_KEY") ?? ""; +const BUCKET = "avatars"; +const REGION = "us-east-1"; + +const KRATOS_ADMIN_URL = + Deno.env.get("KRATOS_ADMIN_URL") ?? + "http://kratos-admin.ory.svc.cluster.local:80"; + +const ALLOWED_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); +const MAX_SIZE = 2 * 1024 * 1024; // 2MB + +const encoder = new TextEncoder(); + +// --- AWS Signature V4 (minimal, for single-bucket S3 operations) --- + +async function hmacSha256( + key: ArrayBuffer | Uint8Array, + data: string, +): Promise { + const keyBuf = key instanceof Uint8Array ? key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) : key; + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyBuf as ArrayBuffer, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(data)); +} + +async function sha256(data: Uint8Array): Promise { + const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; + const hash = await crypto.subtle.digest("SHA-256", buf); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function toHex(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function signRequest( + method: string, + url: URL, + headers: Record, + body: Uint8Array | null, +): Promise> { + const now = new Date(); + const dateStamp = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; + const shortDate = dateStamp.slice(0, 8); + const scope = `${shortDate}/${REGION}/s3/aws4_request`; + + headers["x-amz-date"] = dateStamp; + headers["x-amz-content-sha256"] = body + ? await sha256(body) + : await sha256(new Uint8Array(0)); + + const signedHeaderKeys = Object.keys(headers) + .map((k) => k.toLowerCase()) + .sort(); + const signedHeaders = signedHeaderKeys.join(";"); + + const canonicalHeaders = signedHeaderKeys + .map((k) => `${k}:${headers[k] ?? headers[Object.keys(headers).find((h) => h.toLowerCase() === k)!]}`) + .join("\n") + "\n"; + + const canonicalRequest = [ + method, + url.pathname, + url.search.replace(/^\?/, ""), + canonicalHeaders, + signedHeaders, + headers["x-amz-content-sha256"], + ].join("\n"); + + const stringToSign = [ + "AWS4-HMAC-SHA256", + dateStamp, + scope, + await sha256(encoder.encode(canonicalRequest)), + ].join("\n"); + + let signingKey: ArrayBuffer = await hmacSha256( + encoder.encode("AWS4" + SECRET_KEY), + shortDate, + ); + signingKey = await hmacSha256(signingKey, REGION); + signingKey = await hmacSha256(signingKey, "s3"); + signingKey = await hmacSha256(signingKey, "aws4_request"); + + const signature = toHex(await hmacSha256(signingKey, stringToSign)); + + headers["Authorization"] = + `AWS4-HMAC-SHA256 Credential=${ACCESS_KEY}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return headers; +} + +async function s3Request( + method: string, + key: string, + body: Uint8Array | null = null, + contentType?: string, +): Promise { + const url = new URL(`/${BUCKET}/${key}`, SEAWEEDFS_S3_URL); + const headers: Record = { + host: url.host, + }; + if (contentType) headers["content-type"] = contentType; + + await signRequest(method, url, headers, body); + + return fetch(url.toString(), { + method, + headers, + body, + }); +} + +/** PUT /api/avatar — upload avatar image. */ +export async function uploadAvatar(c: Context): Promise { + const identity = c.get("identity"); + if (!identity?.id) return c.text("Unauthorized", 401); + + const contentType = c.req.header("content-type") ?? ""; + + let imageBytes: Uint8Array; + let imageType: string; + + if (contentType.includes("multipart/form-data")) { + const formData = await c.req.formData(); + const file = formData.get("avatar"); + if (!(file instanceof File)) { + return c.json({ error: "No avatar file provided" }, 400); + } + if (!ALLOWED_TYPES.has(file.type)) { + return c.json( + { error: "Invalid file type. Use JPEG, PNG, or WebP." }, + 400, + ); + } + if (file.size > MAX_SIZE) { + return c.json({ error: "File too large. Max 2MB." }, 400); + } + imageBytes = new Uint8Array(await file.arrayBuffer()); + imageType = file.type; + } else { + // Raw body upload + if (!ALLOWED_TYPES.has(contentType)) { + return c.json( + { error: "Invalid content type. Use JPEG, PNG, or WebP." }, + 400, + ); + } + imageBytes = new Uint8Array(await c.req.arrayBuffer()); + if (imageBytes.length > MAX_SIZE) { + return c.json({ error: "File too large. Max 2MB." }, 400); + } + imageType = contentType; + } + + const key = identity.id; + + try { + const resp = await s3Request("PUT", key, imageBytes, imageType); + if (!resp.ok) { + const text = await resp.text(); + return c.json({ error: `S3 upload failed: ${text}` }, 502); + } + } catch (err) { + console.error("Avatar upload error:", err); + return c.json({ error: `Storage unavailable: ${err}` }, 502); + } + + // Update identity picture trait via Kratos Admin API + const PUBLIC_URL = Deno.env.get("PUBLIC_URL") ?? "http://localhost:3000"; + const avatarUrl = `${PUBLIC_URL}/api/avatar/${identity.id}`; + try { + const getResp = await fetch( + `${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`, + { headers: { Accept: "application/json" } }, + ); + if (getResp.ok) { + const identityData = await getResp.json(); + identityData.traits.picture = avatarUrl; + const putResp = await fetch( + `${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_id: identityData.schema_id, + state: identityData.state, + traits: identityData.traits, + }), + }, + ); + if (!putResp.ok) { + const errText = await putResp.text(); + console.error("Kratos trait update failed:", putResp.status, errText); + } + } + } catch (err) { + console.error("Kratos trait update error:", err); + } + + return c.json({ url: avatarUrl }); +} + +/** GET /api/avatar/:id — proxy avatar image from SeaweedFS. */ +export async function getAvatar(c: Context): Promise { + const id = c.req.param("id"); + if (!id) return c.text("Missing id", 400); + + try { + const resp = await s3Request("GET", id); + if (resp.status === 404 || resp.status === 403) { + return c.body(null, 404); + } + if (!resp.ok) { + return c.text("Storage error", 502); + } + + const headers = new Headers(); + const ct = resp.headers.get("content-type"); + if (ct) headers.set("Content-Type", ct); + headers.set("Cache-Control", "public, max-age=300, must-revalidate"); + + return new Response(resp.body, { status: 200, headers }); + } catch { + return c.text("Storage unavailable", 502); + } +} + +/** DELETE /api/avatar — delete current user's avatar. */ +export async function deleteAvatar(c: Context): Promise { + const identity = c.get("identity"); + if (!identity?.id) return c.text("Unauthorized", 401); + + try { + await s3Request("DELETE", identity.id); + } catch { + return c.text("Storage unavailable", 502); + } + + // Clear picture trait + try { + const getResp = await fetch( + `${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`, + { headers: { Accept: "application/json" } }, + ); + if (getResp.ok) { + const identityData = await getResp.json(); + delete identityData.traits.picture; + await fetch(`${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_id: identityData.schema_id, + state: identityData.state, + traits: identityData.traits, + }), + }); + } + } catch { + // Non-fatal + } + + return c.json({ ok: true }); +} diff --git a/ui/cunningham.ts b/ui/cunningham.ts new file mode 100644 index 0000000..f0d0df1 --- /dev/null +++ b/ui/cunningham.ts @@ -0,0 +1,10 @@ +export default { + themes: { + default: {}, + dark: {}, + "dsfr-light": {}, + "dsfr-dark": {}, + "anct-light": {}, + "anct-dark": {}, + }, +}; diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..fbfb7df --- /dev/null +++ b/ui/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Sunbeam Studios + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..430c6a1 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,30 @@ +{ + "name": "kratos-admin-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@gouvfr-lasuite/cunningham-react": "^4.2.0", + "@gouvfr-lasuite/ui-kit": "^0.19.9", + "@rjsf/core": "^6.3.1", + "@rjsf/utils": "^6.3.1", + "@rjsf/validator-ajv8": "^6.3.1", + "@tanstack/react-query": "^5.59.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "@vitejs/plugin-react": "^4.3.0" + } +} diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000..262a638 --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..ca4e9bd --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,72 @@ +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { useCunninghamTheme } from './cunningham/useCunninghamTheme' +import AuthLayout from './layouts/AuthLayout' +import DashboardLayout from './layouts/DashboardLayout' +import LoginPage from './pages/auth/LoginPage' +import RegistrationPage from './pages/auth/RegistrationPage' +import RecoveryPage from './pages/auth/RecoveryPage' +import VerificationPage from './pages/auth/VerificationPage' +import ErrorPage from './pages/auth/ErrorPage' +import ConsentPage from './pages/auth/ConsentPage' +import LogoutPage from './pages/auth/LogoutPage' +import OnboardingWizard from './pages/auth/OnboardingWizard' +import ProfilePage from './pages/settings/profile' +import SecurityPage from './pages/settings/security' +import IdentitiesPage from './pages/identities' +import IdentityCreatePage from './pages/identities/create' +import IdentityDetailPage from './pages/identities/detail' +import IdentityEditPage from './pages/identities/edit' +import SessionsPage from './pages/sessions' +import CourierPage from './pages/courier' +import SchemasPage from './pages/schemas' + +const queryClient = new QueryClient() + +export default function App() { + const { theme } = useCunninghamTheme() + + return ( + + + + + {/* Public auth flows — centered card layout */} + }> + } /> + } /> + } /> + } /> + } /> + + + {/* Hydra flows + onboarding — card layout */} + }> + } /> + } /> + } /> + + + {/* Authenticated — Dashboard with sidebar */} + }> + } /> + } /> + } /> + {/* Legacy redirect */} + } /> + {/* Admin-only routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ) +} diff --git a/ui/src/api/avatar.ts b/ui/src/api/avatar.ts new file mode 100644 index 0000000..3a8c6d8 --- /dev/null +++ b/ui/src/api/avatar.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +export function avatarUrl(identityId: string): string { + return `/api/avatar/${identityId}` +} + +export function useUploadAvatar() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData() + formData.append('avatar', file) + const resp = await fetch('/api/avatar', { + method: 'PUT', + body: formData, + }) + if (!resp.ok) { + const data = await resp.json().catch(() => ({})) + throw new Error(data.error ?? 'Upload failed') + } + return resp.json() as Promise<{ url: string }> + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['session'] }) + }, + }) +} + +export function useDeleteAvatar() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async () => { + const resp = await fetch('/api/avatar', { method: 'DELETE' }) + if (!resp.ok) throw new Error('Delete failed') + return resp.json() + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['session'] }) + }, + }) +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..50e99f2 --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,25 @@ +const BASE = '/api' + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(BASE + path, { + headers: { 'Content-Type': 'application/json', ...options?.headers }, + ...options, + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`${res.status} ${res.statusText}: ${body}`) + } + if (res.status === 204) return undefined as T + return res.json() +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + put: (path: string, body?: unknown) => + request(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), + patch: (path: string, body?: unknown) => + request(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }), + delete: (path: string) => request(path, { method: 'DELETE' }), +} diff --git a/ui/src/api/courier.ts b/ui/src/api/courier.ts new file mode 100644 index 0000000..68fbc13 --- /dev/null +++ b/ui/src/api/courier.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from './client' + +export interface CourierMessage { + id: string + recipient: string + subject: string + status: string + type: string + created_at: string + updated_at: string + body?: string +} + +export function useCourierMessages(params?: { page_size?: number; status?: string; recipient?: string }) { + const search = new URLSearchParams() + if (params?.page_size) search.set('page_size', String(params.page_size)) + if (params?.status) search.set('status', params.status) + if (params?.recipient) search.set('recipient', params.recipient) + return useQuery({ + queryKey: ['courier', params], + queryFn: () => api.get(`/admin/courier/messages?${search}`), + }) +} + +export function useCourierMessage(id: string) { + return useQuery({ + queryKey: ['courier', id], + queryFn: () => api.get(`/admin/courier/messages/${id}`), + enabled: !!id, + }) +} diff --git a/ui/src/api/flows.ts b/ui/src/api/flows.ts new file mode 100644 index 0000000..cdc0687 --- /dev/null +++ b/ui/src/api/flows.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query' + +export interface FlowUI { + action: string + method: string + nodes: FlowNode[] + messages?: FlowMessage[] +} + +export interface FlowNode { + type: 'input' | 'text' | 'img' | 'script' | 'a' + group: string + attributes: Record + messages: FlowMessage[] + meta: { + label?: { id: number; text: string; type: string } + } +} + +export interface FlowMessage { + id: number + text: string + type: 'error' | 'info' | 'success' + context?: Record +} + +export interface Flow { + id: string + type: string + ui: FlowUI + state?: string + request_url?: string + oauth2_login_challenge?: string +} + +export function useFlow(type: string, flowId: string | null) { + return useQuery({ + queryKey: ['flow', type, flowId], + queryFn: async () => { + const resp = await fetch(`/api/flow/${type}?flow=${flowId}`) + if (!resp.ok) throw new Error(`Failed to fetch flow: ${resp.status}`) + return resp.json() as Promise + }, + enabled: !!flowId, + }) +} + +export function useFlowError(errorId: string | null) { + return useQuery({ + queryKey: ['flowError', errorId], + queryFn: async () => { + const resp = await fetch(`/api/flow/error?id=${errorId}`) + if (!resp.ok) throw new Error(`Failed to fetch error: ${resp.status}`) + return resp.json() + }, + enabled: !!errorId, + }) +} diff --git a/ui/src/api/hydra.ts b/ui/src/api/hydra.ts new file mode 100644 index 0000000..ab857be --- /dev/null +++ b/ui/src/api/hydra.ts @@ -0,0 +1,75 @@ +import { useQuery } from '@tanstack/react-query' + +export interface ConsentRequest { + challenge: string + client: { client_id: string; client_name?: string; logo_uri?: string } + requested_scope: string[] + requested_access_token_audience: string[] + subject?: string + skip?: boolean + redirect_to?: string + auto?: boolean +} + +export function useConsent(challenge: string | null) { + return useQuery({ + queryKey: ['consent', challenge], + queryFn: async () => { + const resp = await fetch(`/api/hydra/consent?challenge=${challenge}`) + if (!resp.ok) throw new Error(`Failed to fetch consent: ${resp.status}`) + return resp.json() as Promise + }, + enabled: !!challenge, + }) +} + +export async function acceptConsent( + challenge: string, + grantScope: string[], + remember = false, + session?: Record, +): Promise<{ redirect_to: string }> { + const resp = await fetch('/api/hydra/consent/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challenge, grantScope, remember, session }), + }) + if (!resp.ok) throw new Error('Failed to accept consent') + return resp.json() +} + +export async function rejectConsent( + challenge: string, +): Promise<{ redirect_to: string }> { + const resp = await fetch('/api/hydra/consent/reject', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challenge }), + }) + if (!resp.ok) throw new Error('Failed to reject consent') + return resp.json() +} + +export function useLogoutRequest(challenge: string | null) { + return useQuery({ + queryKey: ['logout', challenge], + queryFn: async () => { + const resp = await fetch(`/api/hydra/logout?challenge=${challenge}`) + if (!resp.ok) throw new Error(`Failed to fetch logout: ${resp.status}`) + return resp.json() + }, + enabled: !!challenge, + }) +} + +export async function acceptLogout( + challenge: string, +): Promise<{ redirect_to: string }> { + const resp = await fetch('/api/hydra/logout/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challenge }), + }) + if (!resp.ok) throw new Error('Failed to accept logout') + return resp.json() +} diff --git a/ui/src/api/identities.ts b/ui/src/api/identities.ts new file mode 100644 index 0000000..9063bc5 --- /dev/null +++ b/ui/src/api/identities.ts @@ -0,0 +1,100 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from './client' + +export interface Identity { + id: string + schema_id: string + state: 'active' | 'inactive' + traits: Record + metadata_public?: Record + metadata_admin?: Record + credentials?: Record + created_at: string + updated_at: string +} + +export function useIdentities(params?: { page_size?: number; page_token?: string; credentials_identifier?: string }) { + const search = new URLSearchParams() + if (params?.page_size) search.set('page_size', String(params.page_size)) + if (params?.page_token) search.set('page_token', params.page_token) + if (params?.credentials_identifier) search.set('credentials_identifier', params.credentials_identifier) + return useQuery({ + queryKey: ['identities', params], + queryFn: () => api.get(`/admin/identities?${search}`), + }) +} + +export function useIdentity(id: string) { + return useQuery({ + queryKey: ['identities', id], + queryFn: () => api.get(`/admin/identities/${id}`), + enabled: !!id, + }) +} + +export function useCreateIdentity() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { schema_id: string; traits: unknown; state?: string }) => + api.post('/admin/identities', body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }), + }) +} + +export function useUpdateIdentity(id: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial) => + api.put(`/admin/identities/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }), + }) +} + +export function useDeleteIdentity() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/admin/identities/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }), + }) +} + +export function useGenerateRecoveryLink() { + return useMutation({ + mutationFn: (body: { identity_id: string; expires_in?: string }) => + api.post<{ recovery_link: string; expires_at: string }>('/admin/recovery/link', body), + }) +} + +export function useGenerateRecoveryCode() { + return useMutation({ + mutationFn: (body: { identity_id: string; expires_in?: string }) => + api.post<{ recovery_code: string; recovery_link: string; expires_at: string }>('/admin/recovery/code', body), + }) +} + +export interface Session { + id: string + identity_id?: string + active: boolean + expires_at: string + authenticated_at: string + authenticator_assurance_level: string +} + +export function useIdentitySessions(identityId: string) { + return useQuery({ + queryKey: ['identities', identityId, 'sessions'], + queryFn: () => api.get(`/admin/identities/${identityId}/sessions`), + enabled: !!identityId, + }) +} + +export function useDeleteAllIdentitySessions() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (identityId: string) => + api.delete(`/admin/identities/${identityId}/sessions`), + onSuccess: (_, identityId) => + qc.invalidateQueries({ queryKey: ['identities', identityId, 'sessions'] }), + }) +} diff --git a/ui/src/api/schemas.ts b/ui/src/api/schemas.ts new file mode 100644 index 0000000..39bba51 --- /dev/null +++ b/ui/src/api/schemas.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from './client' + +export interface SchemaListItem { + id: string + url?: string +} + +export function useSchemas() { + return useQuery({ + queryKey: ['schemas'], + queryFn: () => api.get('/schemas'), + }) +} + +// Kratos GET /schemas/{id} returns the raw JSON schema directly +export function useSchema(id: string) { + return useQuery({ + queryKey: ['schemas', id], + queryFn: () => api.get>(`/schemas/${encodeURIComponent(id)}`), + enabled: !!id, + }) +} diff --git a/ui/src/api/session.ts b/ui/src/api/session.ts new file mode 100644 index 0000000..256bebe --- /dev/null +++ b/ui/src/api/session.ts @@ -0,0 +1,6 @@ +import { useSessionStore } from '../stores/session' + +export function useSession() { + const { session, isAdmin, isLoading, error } = useSessionStore() + return { session, isAdmin, isLoading, error } +} diff --git a/ui/src/api/sessions.ts b/ui/src/api/sessions.ts new file mode 100644 index 0000000..5d9651e --- /dev/null +++ b/ui/src/api/sessions.ts @@ -0,0 +1,37 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from './client' +import type { Session } from './identities' + +export function useSessions(params?: { page_size?: number; active?: boolean }) { + const search = new URLSearchParams() + if (params?.page_size) search.set('page_size', String(params.page_size)) + if (params?.active !== undefined) search.set('active', String(params.active)) + return useQuery({ + queryKey: ['sessions', params], + queryFn: () => api.get(`/admin/sessions?${search}`), + }) +} + +export function useSession(id: string) { + return useQuery({ + queryKey: ['sessions', id], + queryFn: () => api.get(`/admin/sessions/${id}`), + enabled: !!id, + }) +} + +export function useRevokeSession() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/admin/sessions/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }), + }) +} + +export function useExtendSession() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.patch(`/admin/sessions/${id}/extend`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }), + }) +} diff --git a/ui/src/components/Avatar.tsx b/ui/src/components/Avatar.tsx new file mode 100644 index 0000000..3052cc9 --- /dev/null +++ b/ui/src/components/Avatar.tsx @@ -0,0 +1,50 @@ +interface AvatarProps { + identityId?: string + name?: string + picture?: string + size?: 'xsmall' | 'small' | 'medium' | 'large' +} + +const sizes = { + xsmall: 24, + small: 32, + medium: 48, + large: 80, +} + +export default function Avatar({ identityId, name, picture, size = 'medium' }: AvatarProps) { + const px = sizes[size] + const initial = (name?.[0] ?? '?').toUpperCase() + + if (picture && identityId) { + return ( + {name + ) + } + + return ( +
+ {initial} +
+ ) +} diff --git a/ui/src/components/AvatarUpload.tsx b/ui/src/components/AvatarUpload.tsx new file mode 100644 index 0000000..6e78e82 --- /dev/null +++ b/ui/src/components/AvatarUpload.tsx @@ -0,0 +1,108 @@ +import { useRef, useState } from 'react' +import { Button } from '@gouvfr-lasuite/cunningham-react' +import { useUploadAvatar, useDeleteAvatar } from '../api/avatar' + +interface AvatarUploadProps { + identityId: string + picture?: string + name?: string + onUploaded?: () => void +} + +export default function AvatarUpload({ identityId, picture, name, onUploaded }: AvatarUploadProps) { + const fileRef = useRef(null) + const [preview, setPreview] = useState(null) + const upload = useUploadAvatar() + const remove = useDeleteAvatar() + + const [uploadedUrl, setUploadedUrl] = useState(null) + const currentSrc = preview ?? uploadedUrl ?? (picture ? `/api/avatar/${identityId}?t=${Date.now()}` : null) + const initial = (name?.[0] ?? '?').toUpperCase() + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = () => setPreview(reader.result as string) + reader.readAsDataURL(file) + + try { + await upload.mutateAsync(file) + setUploadedUrl(`/api/avatar/${identityId}?t=${Date.now()}`) + onUploaded?.() + } catch { + setPreview(null) + setUploadedUrl(null) + } + } + + const handleDelete = async () => { + await remove.mutateAsync() + setPreview(null) + onUploaded?.() + } + + return ( +
+
fileRef.current?.click()} + style={{ + width: 80, + height: 80, + borderRadius: '50%', + overflow: 'hidden', + cursor: 'pointer', + position: 'relative', + backgroundColor: 'var(--sunbeam--avatar-bg)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + {currentSrc ? ( + + ) : ( + {initial} + )} +
{ (e.currentTarget as HTMLDivElement).style.opacity = '1' }} + onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.opacity = '0' }} + > + Change +
+
+ + + +
+ + {currentSrc && !preview && ( + + )} +
+
+ ) +} diff --git a/ui/src/components/ConfirmModal.tsx b/ui/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..c6c84b4 --- /dev/null +++ b/ui/src/components/ConfirmModal.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef } from 'react' +import { Button } from '@gouvfr-lasuite/cunningham-react' + +interface ConfirmModalProps { + isOpen: boolean + title: string + message: string + confirmLabel?: string + confirmColor?: 'brand' | 'error' + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmModal({ + isOpen, + title, + message, + confirmLabel = 'Confirm', + confirmColor = 'brand', + onConfirm, + onCancel, +}: ConfirmModalProps) { + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + if (isOpen && !dialog.open) { + dialog.showModal() + } else if (!isOpen && dialog.open) { + dialog.close() + } + }, [isOpen]) + + return ( + +

{title}

+

{message}

+
+ + +
+
+ ) +} diff --git a/ui/src/components/DashboardNav.tsx b/ui/src/components/DashboardNav.tsx new file mode 100644 index 0000000..f6dbfa8 --- /dev/null +++ b/ui/src/components/DashboardNav.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import { NavLink } from 'react-router-dom' +import { useSessionStore } from '../stores/session' +import { Button } from '@gouvfr-lasuite/cunningham-react' + +interface HealthStatus { + alive: boolean + ready: boolean +} + +const linkStyle: React.CSSProperties = { + display: 'block', + padding: '0.5rem 1rem', + textDecoration: 'none', + color: 'inherit', + borderRadius: 4, +} + +const activeLinkStyle: React.CSSProperties = { + ...linkStyle, + backgroundColor: 'var(--c--theme--colors--primary-100)', + fontWeight: 600, + color: 'var(--c--theme--colors--primary-700)', +} + +export default function DashboardNav() { + const { isAdmin, needs2faSetup, logout } = useSessionStore() + const [health, setHealth] = useState({ alive: false, ready: false }) + + useEffect(() => { + const check = async () => { + const [alive, ready] = await Promise.all([ + fetch('/api/health/alive').then((r) => r.ok).catch(() => false), + fetch('/api/health/ready').then((r) => r.ok).catch(() => false), + ]) + setHealth({ alive, ready }) + } + + check() + const interval = setInterval(check, 30_000) + return () => clearInterval(interval) + }, []) + + return ( + + ) +} diff --git a/ui/src/components/FlowNodes/FlowForm.tsx b/ui/src/components/FlowNodes/FlowForm.tsx new file mode 100644 index 0000000..7b65ae8 --- /dev/null +++ b/ui/src/components/FlowNodes/FlowForm.tsx @@ -0,0 +1,103 @@ +import type { FlowUI, FlowNode } from '../../api/flows' +import NodeInput from './NodeInput' +import NodeText from './NodeText' +import NodeImage from './NodeImage' +import NodeScript from './NodeScript' +import NodeAnchor from './NodeAnchor' + +interface FlowFormProps { + ui: FlowUI + only?: string // render only this group + exclude?: string[] // exclude these groups + onSubmit?: (action: string, data: FormData) => void // intercept submission with fetch() +} + +function renderNode(node: FlowNode, index: number) { + switch (node.type) { + case 'input': + return + case 'text': + return + case 'img': + return + case 'script': + return + case 'a': + return + default: + return null + } +} + +const GROUP_ORDER = [ + 'default', + 'password', + 'oidc', + 'code', + 'webauthn', + 'totp', + 'lookup_secret', +] + +export default function FlowForm({ ui, only, exclude, onSubmit }: FlowFormProps) { + const groups = new Map() + for (const node of ui.nodes) { + const group = node.group + if (exclude?.includes(group)) continue + if (only && group !== only && group !== 'default') continue + if (!groups.has(group)) groups.set(group, []) + groups.get(group)!.push(node) + } + + const sortedGroups = [...groups.entries()].sort( + ([a], [b]) => GROUP_ORDER.indexOf(a) - GROUP_ORDER.indexOf(b), + ) + + const handleSubmit = onSubmit + ? (e: React.FormEvent) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + onSubmit(ui.action, formData) + } + : undefined + + return ( +
+ {ui.messages?.map((msg) => { + const isError = msg.type === 'error' + const isSuccess = msg.type === 'success' + return ( +
+ {msg.text} +
+ ) + })} + + {sortedGroups.map(([group, nodes]) => ( +
+ {nodes.map((node, i) => renderNode(node, i))} +
+ ))} +
+ ) +} diff --git a/ui/src/components/FlowNodes/NodeAnchor.tsx b/ui/src/components/FlowNodes/NodeAnchor.tsx new file mode 100644 index 0000000..e0fe93b --- /dev/null +++ b/ui/src/components/FlowNodes/NodeAnchor.tsx @@ -0,0 +1,19 @@ +import { Button } from '@gouvfr-lasuite/cunningham-react' +import type { FlowNode } from '../../api/flows' + +interface Props { + node: FlowNode +} + +export default function NodeAnchor({ node }: Props) { + const attrs = node.attributes as { href: string; title?: string; id?: string } + const label = node.meta?.label?.text ?? attrs.title ?? 'Link' + + return ( + + + + ) +} diff --git a/ui/src/components/FlowNodes/NodeImage.tsx b/ui/src/components/FlowNodes/NodeImage.tsx new file mode 100644 index 0000000..c936725 --- /dev/null +++ b/ui/src/components/FlowNodes/NodeImage.tsx @@ -0,0 +1,23 @@ +import type { FlowNode } from '../../api/flows' + +interface Props { + node: FlowNode +} + +export default function NodeImage({ node }: Props) { + const attrs = node.attributes as { src: string; width?: number; height?: number } + const label = node.meta?.label?.text + + return ( +
+ {label &&
{label}
} + {label +
+ ) +} diff --git a/ui/src/components/FlowNodes/NodeInput.tsx b/ui/src/components/FlowNodes/NodeInput.tsx new file mode 100644 index 0000000..f5516b2 --- /dev/null +++ b/ui/src/components/FlowNodes/NodeInput.tsx @@ -0,0 +1,124 @@ +import { useRef, useEffect } from 'react' +import { Input, Button, Checkbox } from '@gouvfr-lasuite/cunningham-react' +import type { FlowNode } from '../../api/flows' + +interface Props { + node: FlowNode +} + +export default function NodeInput({ node }: Props) { + const attrs = node.attributes as { + name: string + type: string + value?: string + required?: boolean + disabled?: boolean + label?: string + onclick?: string + } + + const label = node.meta?.label?.text ?? attrs.label ?? attrs.name + const errorMsg = node.messages.find((m) => m.type === 'error') + const infoMsg = node.messages.find((m) => m.type === 'info') + + if (attrs.type === 'hidden') { + return + } + + // WebAuthn and other interactive buttons with onclick handlers + // Use a native button so Kratos-injected scripts can attach via onclick attribute + if ((attrs.type === 'submit' || attrs.type === 'button') && attrs.onclick) { + return + } + + if (attrs.type === 'submit' || attrs.type === 'button') { + const isPrimary = node.group === 'password' || node.group === 'default' + return ( + <> + {errorMsg && ( +
+ {errorMsg.text} +
+ )} + + + ) + } + + if (attrs.type === 'checkbox') { + return ( + + ) + } + + // Text, email, password, number, tel, etc. + return ( + + ) +} + +// Renders a native + ) +} diff --git a/ui/src/components/FlowNodes/NodeScript.tsx b/ui/src/components/FlowNodes/NodeScript.tsx new file mode 100644 index 0000000..a1a81db --- /dev/null +++ b/ui/src/components/FlowNodes/NodeScript.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef } from 'react' +import type { FlowNode } from '../../api/flows' + +interface Props { + node: FlowNode +} + +export default function NodeScript({ node }: Props) { + const ref = useRef(null) + + useEffect(() => { + const attrs = node.attributes as { + src?: string + async?: boolean + type?: string + integrity?: string + crossorigin?: string + nonce?: string + } + + if (!attrs.src || !ref.current) return + + const script = document.createElement('script') + script.src = attrs.src + if (attrs.async) script.async = true + if (attrs.type) script.type = attrs.type + if (attrs.integrity) script.integrity = attrs.integrity + if (attrs.crossorigin) script.crossOrigin = attrs.crossorigin + if (attrs.nonce) script.nonce = attrs.nonce + + ref.current.appendChild(script) + + return () => { + script.remove() + } + }, [node]) + + return
+} diff --git a/ui/src/components/FlowNodes/NodeText.tsx b/ui/src/components/FlowNodes/NodeText.tsx new file mode 100644 index 0000000..e245a03 --- /dev/null +++ b/ui/src/components/FlowNodes/NodeText.tsx @@ -0,0 +1,27 @@ +import type { FlowNode } from '../../api/flows' + +interface Props { + node: FlowNode +} + +export default function NodeText({ node }: Props) { + const attrs = node.attributes as { text?: { text: string; id: number } } + const text = attrs.text?.text ?? '' + const label = node.meta?.label?.text + + return ( +
+ {label &&
{label}
} + {text} +
+ ) +} diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx new file mode 100644 index 0000000..eb3bd85 --- /dev/null +++ b/ui/src/components/SchemaForm/index.tsx @@ -0,0 +1,44 @@ +import Form from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' +import type { RJSFSchema, UiSchema, ErrorSchema } from '@rjsf/utils' +import { CunninghamWidgets } from './widgets' +import { ObjectFieldTemplate, ArrayFieldTemplate } from './templates' + +interface SchemaFormProps { + schema: RJSFSchema + uiSchema?: UiSchema + formData?: unknown + onSubmit: (data: unknown) => void + onError?: (errors: unknown) => void + extraErrors?: ErrorSchema + disabled?: boolean + children?: React.ReactNode +} + +export default function SchemaForm({ + schema, + uiSchema, + formData, + onSubmit, + onError, + extraErrors, + disabled, + children, +}: SchemaFormProps) { + return ( +
onSubmit(formData)} + onError={onError} + > + {children} +
+ ) +} diff --git a/ui/src/components/SchemaForm/templates.tsx b/ui/src/components/SchemaForm/templates.tsx new file mode 100644 index 0000000..35bbdee --- /dev/null +++ b/ui/src/components/SchemaForm/templates.tsx @@ -0,0 +1,32 @@ +import type { ObjectFieldTemplateProps, ArrayFieldTemplateProps } from '@rjsf/utils' +import { Button } from '@gouvfr-lasuite/cunningham-react' + +export function ObjectFieldTemplate({ title, properties, description }: ObjectFieldTemplateProps) { + return ( +
+ {title && {title}} + {description &&

{description}

} +
+ {properties.map((prop) => prop.content)} +
+
+ ) +} + +// In rjsf v6, items are pre-rendered ReactElements (each rendered by ArrayFieldItemTemplate, +// which handles its own remove/reorder buttons). ArrayFieldTemplate just provides the layout. +export function ArrayFieldTemplate({ title, items, canAdd, onAddClick }: ArrayFieldTemplateProps) { + return ( +
+ {title &&

{title}

} +
+ {items} +
+ {canAdd && ( +
+ +
+ )} +
+ ) +} diff --git a/ui/src/components/SchemaForm/widgets.tsx b/ui/src/components/SchemaForm/widgets.tsx new file mode 100644 index 0000000..b2c2a38 --- /dev/null +++ b/ui/src/components/SchemaForm/widgets.tsx @@ -0,0 +1,86 @@ +import type { WidgetProps } from '@rjsf/utils' +import { Input, Select, Checkbox } from '@gouvfr-lasuite/cunningham-react' + +function TextWidget({ id, value, onChange, label, required, disabled, rawErrors }: WidgetProps) { + return ( + onChange(e.target.value || undefined)} + required={required} + disabled={disabled} + state={rawErrors?.length ? 'error' : 'default'} + text={rawErrors?.[0]} + /> + ) +} + +function EmailWidget({ id, value, onChange, label, required, disabled, rawErrors }: WidgetProps) { + return ( + onChange(e.target.value || undefined)} + required={required} + disabled={disabled} + state={rawErrors?.length ? 'error' : 'default'} + text={rawErrors?.[0]} + /> + ) +} + +function SelectWidget({ value, onChange, label, disabled, options, rawErrors }: WidgetProps) { + const selectOptions = (options.enumOptions ?? []).map((o) => ({ + label: String(o.label), + value: String(o.value), + })) + return ( + onChange(e.target.value === '' ? undefined : Number(e.target.value))} + required={required} + disabled={disabled} + state={rawErrors?.length ? 'error' : 'default'} + text={rawErrors?.[0]} + /> + ) +} + +export const CunninghamWidgets = { + TextWidget, + EmailWidget, + SelectWidget, + CheckboxWidget, + NumberWidget, +} diff --git a/ui/src/components/WaffleButton.tsx b/ui/src/components/WaffleButton.tsx new file mode 100644 index 0000000..b05f8a8 --- /dev/null +++ b/ui/src/components/WaffleButton.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, useCallback } from 'react' + +const INTEGRATION_ORIGIN = window.location.origin.replace(/^https?:\/\/auth\./, 'https://integration.') + +/** + * Waffle menu button that loads the La Gaufre v2 widget from the integration service. + * The widget is a Shadow DOM popup that shows links to all studio services. + */ +export default function WaffleButton() { + const btnRef = useRef(null) + const initialized = useRef(false) + + const toggle = useCallback(() => { + window._lasuite_widget = window._lasuite_widget || [] + window._lasuite_widget.push(['lagaufre', 'toggle']) + }, []) + + useEffect(() => { + if (initialized.current) return + initialized.current = true + + // Load the lagaufre v2 widget script + const script = document.createElement('script') + script.src = `${INTEGRATION_ORIGIN}/api/v2/lagaufre.js` + script.onload = () => { + window._lasuite_widget = window._lasuite_widget || [] + window._lasuite_widget.push(['lagaufre', 'init', { + api: `${INTEGRATION_ORIGIN}/api/v2/services.json`, + buttonElement: btnRef.current!, + label: 'Sunbeam Studios', + closeLabel: 'Close', + newWindowLabelSuffix: ' · new window', + }]) + } + document.head.appendChild(script) + + return () => { + window._lasuite_widget = window._lasuite_widget || [] + window._lasuite_widget.push(['lagaufre', 'destroy']) + } + }, []) + + return ( + + ) +} + +// Global type augmentation for the widget API +declare global { + interface Window { + _lasuite_widget: unknown[] + } +} diff --git a/ui/src/cunningham/useCunninghamTheme.tsx b/ui/src/cunningham/useCunninghamTheme.tsx new file mode 100644 index 0000000..e952c90 --- /dev/null +++ b/ui/src/cunningham/useCunninghamTheme.tsx @@ -0,0 +1,37 @@ +import { create } from 'zustand' + +const defaultTheme = import.meta.env.VITE_CUNNINGHAM_THEME ?? 'default' + +interface ThemeState { + theme: string + setTheme: (theme: string) => void + toggle: () => void +} + +const getStoredTheme = (): string => { + try { + return localStorage.getItem('cunningham-theme') ?? defaultTheme + } catch { + return defaultTheme + } +} + +export const useCunninghamTheme = create((set, get) => ({ + theme: getStoredTheme(), + setTheme: (theme: string) => { + localStorage.setItem('cunningham-theme', theme) + set({ theme }) + }, + toggle: () => { + const current = get().theme + const next = current.endsWith('-dark') + ? current.replace('-dark', '-light') + : current === 'dark' + ? 'default' + : current === 'default' + ? 'dark' + : current.replace('-light', '-dark') + localStorage.setItem('cunningham-theme', next) + set({ theme: next }) + }, +})) diff --git a/ui/src/layouts/AuthLayout.tsx b/ui/src/layouts/AuthLayout.tsx new file mode 100644 index 0000000..b88d03b --- /dev/null +++ b/ui/src/layouts/AuthLayout.tsx @@ -0,0 +1,35 @@ +import { Outlet } from 'react-router-dom' + +export default function AuthLayout() { + return ( +
+
+

Sunbeam Studios

+
+
+ +
+
+ ) +} diff --git a/ui/src/layouts/DashboardLayout.tsx b/ui/src/layouts/DashboardLayout.tsx new file mode 100644 index 0000000..62a6c7f --- /dev/null +++ b/ui/src/layouts/DashboardLayout.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react' +import { Outlet, useNavigate } from 'react-router-dom' +import { useSessionStore } from '../stores/session' +import DashboardNav from '../components/DashboardNav' +import WaffleButton from '../components/WaffleButton' + +export default function DashboardLayout() { + const { session, isLoading, fetchSession } = useSessionStore() + const navigate = useNavigate() + + useEffect(() => { + fetchSession() + }, [fetchSession]) + + useEffect(() => { + if (!isLoading && !session) { + navigate('/login', { replace: true }) + } + }, [isLoading, session, navigate]) + + if (isLoading) { + return ( +
+ Loading... +
+ ) + } + + if (!session) return null + + const identity = session.identity + const traits = (identity?.traits ?? {}) as Record + const fullName = [traits.given_name, traits.family_name].filter(Boolean).join(' ') || traits.email || '' + + return ( +
+ +
+
+
+ +
+
+
{fullName}
+
{traits.email}
+
+ {identity?.traits?.picture ? ( + + ) : ( +
+ {(fullName[0] ?? '?').toUpperCase()} +
+ )} +
+
+
+ +
+
+
+ ) +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..acb4164 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import '@gouvfr-lasuite/cunningham-react/style' +import App from './App' + +// Load theme AFTER Cunningham styles so our :root overrides win by source order +const themeLink = document.createElement('link') +themeLink.rel = 'stylesheet' +themeLink.href = window.location.origin.replace(/^https?:\/\/auth\./, 'https://integration.') + '/api/v2/theme.css' +document.head.appendChild(themeLink) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/ui/src/pages/auth/ConsentPage.tsx b/ui/src/pages/auth/ConsentPage.tsx new file mode 100644 index 0000000..7d29345 --- /dev/null +++ b/ui/src/pages/auth/ConsentPage.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Button, Checkbox } from '@gouvfr-lasuite/cunningham-react' +import { useConsent, acceptConsent, rejectConsent } from '../../api/hydra' + +const SCOPE_INFO: Record = { + openid: { label: 'OpenID', description: 'Verify your identity' }, + email: { label: 'Email', description: 'View your email address' }, + profile: { label: 'Profile', description: 'View your name and profile info' }, + offline_access: { label: 'Offline access', description: 'Stay signed in' }, +} + +export default function ConsentPage() { + const [params] = useSearchParams() + const challenge = params.get('consent_challenge') + const { data: consent, isLoading, error } = useConsent(challenge) + const [selectedScopes, setSelectedScopes] = useState>(new Set()) + const [remember, setRemember] = useState(true) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (consent?.redirect_to && consent?.auto) { + window.location.href = consent.redirect_to + } + if (consent?.requested_scope) { + setSelectedScopes(new Set(consent.requested_scope)) + } + }, [consent]) + + if (!challenge) { + return

Missing consent challenge.

+ } + + if (isLoading) return

Loading...

+ if (error) return

Error: {String(error)}

+ if (!consent) return null + + if (consent.redirect_to && consent.auto) { + return

Redirecting...

+ } + + if (consent.skip) { + const doAccept = async () => { + const result = await acceptConsent(challenge, consent.requested_scope, true) + window.location.href = result.redirect_to + } + doAccept() + return

Redirecting...

+ } + + const handleAccept = async () => { + setSubmitting(true) + try { + const result = await acceptConsent(challenge, [...selectedScopes], remember) + window.location.href = result.redirect_to + } catch { + setSubmitting(false) + } + } + + const handleReject = async () => { + setSubmitting(true) + try { + const result = await rejectConsent(challenge) + window.location.href = result.redirect_to + } catch { + setSubmitting(false) + } + } + + const toggleScope = (scope: string) => { + const next = new Set(selectedScopes) + next.has(scope) ? next.delete(scope) : next.add(scope) + setSelectedScopes(next) + } + + const clientName = consent.client?.client_name ?? consent.client?.client_id ?? 'An application' + + return ( +
+
+
+ + + + +
+

+ Authorize {clientName} +

+

+ This app would like permission to access your account. +

+
+ +
+ {consent.requested_scope.map((scope, i) => { + const info = SCOPE_INFO[scope] + return ( + + ) + })} +
+ +
+ setRemember(!remember)} + /> +
+ +
+ + +
+
+ ) +} diff --git a/ui/src/pages/auth/ErrorPage.tsx b/ui/src/pages/auth/ErrorPage.tsx new file mode 100644 index 0000000..14e397e --- /dev/null +++ b/ui/src/pages/auth/ErrorPage.tsx @@ -0,0 +1,41 @@ +import { useSearchParams } from 'react-router-dom' +import { useFlowError } from '../../api/flows' + +export default function ErrorPage() { + const [params] = useSearchParams() + const errorId = params.get('id') + + if (!errorId) { + return ( +
+

Error

+

An unknown error occurred.

+ Back to sign in +
+ ) + } + + return +} + +function ErrorDetail({ errorId }: { errorId: string }) { + const { data, isLoading, error: fetchError } = useFlowError(errorId) + + if (isLoading) return
Loading...
+ if (fetchError) return
Failed to load error details.
+ + const errorData = data?.error ?? data + const message = errorData?.message ?? errorData?.reason ?? 'An error occurred' + const status = errorData?.code ?? errorData?.status + + return ( +
+

Error{status ? ` ${status}` : ''}

+

{message}

+ {errorData?.debug && ( +
{errorData.debug}
+ )} + Back to sign in +
+ ) +} diff --git a/ui/src/pages/auth/LoginPage.tsx b/ui/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..52223d7 --- /dev/null +++ b/ui/src/pages/auth/LoginPage.tsx @@ -0,0 +1,88 @@ +import { useSearchParams } from 'react-router-dom' +import { useFlow } from '../../api/flows' +import FlowForm from '../../components/FlowNodes/FlowForm' + +export default function LoginPage() { + const [params] = useSearchParams() + const flowId = params.get('flow') + const loginChallenge = params.get('login_challenge') + + // If no flow ID, redirect to Kratos to create one + if (!flowId) { + const returnTo = params.get('return_to') ?? '/' + // Save return_to for the onboarding wizard to redirect back after setup + if (returnTo && returnTo !== '/') { + sessionStorage.setItem('onboarding_return_to', returnTo) + } + let url = `/kratos/self-service/login/browser?return_to=${encodeURIComponent(returnTo)}` + if (loginChallenge) url += `&login_challenge=${loginChallenge}` + window.location.href = url + return
Redirecting...
+ } + + return +} + +function LoginFlow({ flowId }: { flowId: string }) { + const { data: flow, isLoading, error } = useFlow('login', flowId) + + if (isLoading) return
Loading...
+ if (error) return
Error loading login flow: {String(error)}
+ if (!flow) return null + + // Detect dead-end: flow has messages but no actionable input nodes. + // This happens when Kratos wants aal2 but the user has no 2FA method set up. + const actionableNodes = flow.ui.nodes.filter( + n => n.type === 'input' && + (n.attributes as Record).type !== 'hidden' && + n.group !== 'default' + ) + + if (actionableNodes.length === 0 && flow.ui.messages?.length) { + return ( +
+

+ Additional setup required +

+ +

+ You need to set up two-factor authentication before you can sign in to services. +

+ +
+ ) + } + + // Inject remember=true so Kratos tells Hydra to persist the login session. + // Without this, every OAuth2 flow triggers a fresh login. + const uiWithRemember = { + ...flow.ui, + nodes: [ + ...flow.ui.nodes, + { + type: 'input' as const, + group: 'default', + attributes: { name: 'remember', type: 'hidden', value: 'true', disabled: false, node_type: 'input' }, + messages: [], + meta: {}, + }, + ], + } + + return ( +
+

Sign in

+ + +
+ ) +} diff --git a/ui/src/pages/auth/LogoutPage.tsx b/ui/src/pages/auth/LogoutPage.tsx new file mode 100644 index 0000000..8acf309 --- /dev/null +++ b/ui/src/pages/auth/LogoutPage.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Button } from '@gouvfr-lasuite/cunningham-react' +import { useLogoutRequest, acceptLogout } from '../../api/hydra' + +export default function LogoutPage() { + const [params] = useSearchParams() + const challenge = params.get('logout_challenge') + const { data: logoutReq, isLoading, error } = useLogoutRequest(challenge) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!challenge) { + fetch('/kratos/self-service/logout/browser', { + credentials: 'include', + redirect: 'manual', + }).then(async (resp) => { + if (resp.ok) { + const data = await resp.json() + if (data.logout_url) { + window.location.href = data.logout_url + return + } + } + window.location.href = '/login' + }).catch(() => { + window.location.href = '/login' + }) + } + }, [challenge]) + + if (!challenge) return
Signing out...
+ if (isLoading) return
Loading...
+ if (error) return
Error: {String(error)}
+ + const handleAccept = async () => { + setSubmitting(true) + try { + const result = await acceptLogout(challenge) + window.location.href = result.redirect_to + } catch { + setSubmitting(false) + } + } + + return ( +
+

Sign out

+

Do you want to sign out?

+ {logoutReq?.subject && ( +

+ Signed in as: {logoutReq.subject} +

+ )} +
+ + +
+
+ ) +} diff --git a/ui/src/pages/auth/OnboardingWizard.tsx b/ui/src/pages/auth/OnboardingWizard.tsx new file mode 100644 index 0000000..756eb3b --- /dev/null +++ b/ui/src/pages/auth/OnboardingWizard.tsx @@ -0,0 +1,311 @@ +import { useState } from 'react' +import { Button, Input } from '@gouvfr-lasuite/cunningham-react' +import FlowForm from '../../components/FlowNodes/FlowForm' +import { useFlow } from '../../api/flows' + +type Step = 'password' | 'totp' | 'done' + +const STEP_LABELS: Record = { + password: 'Set password', + totp: 'Authenticator app', + done: 'Ready', +} + +const STEPS: Step[] = ['password', 'totp', 'done'] + +export default function OnboardingWizard() { + const [step, setStep] = useState('password') + const stepIndex = STEPS.indexOf(step) + + return ( +
+

+ Account setup +

+

+ Step {stepIndex + 1} of {STEPS.length}: {STEP_LABELS[step]} +

+ + + + {step === 'password' && ( + setStep('totp')} /> + )} + {step === 'totp' && ( + setStep('done')} /> + )} + {step === 'done' && } +
+ ) +} + +function StepIndicator({ current, total }: { current: number; total: number }) { + return ( +
+ {Array.from({ length: total }, (_, i) => ( +
+ ))} +
+ ) +} + +function PasswordStep({ onComplete }: { onComplete: () => void }) { + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + const mismatch = confirm.length > 0 && password !== confirm + + const handleSubmit = async () => { + if (!password || password !== confirm) return + setSaving(true) + setMessage(null) + try { + const flowResp = await fetch('/kratos/self-service/settings/browser', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }) + if (!flowResp.ok) throw new Error('Failed to create settings flow') + const flow = await flowResp.json() + + const csrfToken = flow.ui.nodes.find( + (n: { attributes: { name: string } }) => n.attributes.name === 'csrf_token' + )?.attributes?.value ?? '' + + const submitResp = await fetch(flow.ui.action, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + credentials: 'include', + body: new URLSearchParams({ + csrf_token: csrfToken, + method: 'password', + password, + }), + }) + + if (submitResp.ok || submitResp.status === 422) { + const result = await submitResp.json() + const errorMsg = result.ui?.messages?.find((m: { type: string }) => m.type === 'error') + if (errorMsg) { + setMessage({ type: 'error', text: errorMsg.text }) + } else { + onComplete() + } + } else { + throw new Error('Failed to set password') + } + } catch (err) { + setMessage({ type: 'error', text: String(err) }) + } finally { + setSaving(false) + } + } + + return ( +
+

+ Choose a strong password for your account. +

+ + {message && ( +
+ {message.text} +
+ )} + + ) => setPassword(e.target.value)} + fullWidth + /> + ) => setConfirm(e.target.value)} + state={mismatch ? 'error' : undefined} + text={mismatch ? 'Passwords do not match' : undefined} + fullWidth + /> + +
+ ) +} + +function TotpStep({ onComplete }: { onComplete: () => void }) { + const [flowId, setFlowId] = useState(null) + const [loading, setLoading] = useState(false) + const [started, setStarted] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const startFlow = async () => { + setLoading(true) + try { + const resp = await fetch('/kratos/self-service/settings/browser', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }) + if (resp.ok) { + const flow = await resp.json() + setFlowId(flow.id) + setStarted(true) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + + const handleTotpSubmit = async (action: string, formData: FormData) => { + setSubmitting(true) + setError(null) + try { + // Submit button name/value isn't captured by FormData — add it explicitly + formData.set('method', 'totp') + const resp = await fetch(action, { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'include', + body: new URLSearchParams(formData as unknown as Record), + }) + const result = await resp.json() + const errMsg = result.ui?.messages?.find((m: { type: string }) => m.type === 'error') + if (errMsg) { + setError(errMsg.text) + } else { + // Verify TOTP was actually set up + const sessionResp = await fetch('/api/auth/session') + if (sessionResp.ok) { + const data = await sessionResp.json() + if (!data.needs2faSetup) { + onComplete() + return + } + } + // If still needs setup, refresh the flow to show updated state + startFlow() + } + } catch (err) { + setError(String(err)) + } finally { + setSubmitting(false) + } + } + + if (!started) { + return ( +
+

+ Two-factor authentication is required. You'll need an authenticator + app like Google Authenticator, 1Password, or Authy. +

+ +
+ ) + } + + return ( +
+

+ Scan the QR code with your authenticator app, then enter the code below. +

+ {error && ( +
{error}
+ )} + {flowId && } +
+ ) +} + +function TotpFlow({ flowId, onSubmit, disabled }: { + flowId: string + onSubmit: (action: string, data: FormData) => void + disabled?: boolean +}) { + const { data: flow, isLoading, error } = useFlow('settings', flowId) + + if (isLoading) return
Loading...
+ if (error) return
Error: {String(error)}
+ if (!flow) return null + + return ( +
+ +
+ ) +} + +function DoneStep() { + const returnTo = sessionStorage.getItem('onboarding_return_to') || '/profile' + + const handleContinue = () => { + sessionStorage.removeItem('onboarding_return_to') + window.location.replace(returnTo) + } + + return ( +
+
+

+ You're all set! +

+

+ Your account is secured with a password and two-factor authentication. +

+ +
+ ) +} diff --git a/ui/src/pages/auth/RecoveryPage.tsx b/ui/src/pages/auth/RecoveryPage.tsx new file mode 100644 index 0000000..664d6c5 --- /dev/null +++ b/ui/src/pages/auth/RecoveryPage.tsx @@ -0,0 +1,33 @@ +import { useSearchParams } from 'react-router-dom' +import { useFlow } from '../../api/flows' +import FlowForm from '../../components/FlowNodes/FlowForm' + +export default function RecoveryPage() { + const [params] = useSearchParams() + const flowId = params.get('flow') + + if (!flowId) { + window.location.href = '/kratos/self-service/recovery/browser' + return
Redirecting...
+ } + + return +} + +function RecoveryFlow({ flowId }: { flowId: string }) { + const { data: flow, isLoading, error } = useFlow('recovery', flowId) + + if (isLoading) return
Loading...
+ if (error) return
Error loading recovery flow: {String(error)}
+ if (!flow) return null + + return ( +
+

Account recovery

+ + +
+ ) +} diff --git a/ui/src/pages/auth/RegistrationPage.tsx b/ui/src/pages/auth/RegistrationPage.tsx new file mode 100644 index 0000000..332d749 --- /dev/null +++ b/ui/src/pages/auth/RegistrationPage.tsx @@ -0,0 +1,34 @@ +import { useSearchParams } from 'react-router-dom' +import { useFlow } from '../../api/flows' +import FlowForm from '../../components/FlowNodes/FlowForm' + +export default function RegistrationPage() { + const [params] = useSearchParams() + const flowId = params.get('flow') + + if (!flowId) { + const returnTo = params.get('return_to') ?? '/' + window.location.href = `/kratos/self-service/registration/browser?return_to=${encodeURIComponent(returnTo)}` + return
Redirecting...
+ } + + return +} + +function RegistrationFlow({ flowId }: { flowId: string }) { + const { data: flow, isLoading, error } = useFlow('registration', flowId) + + if (isLoading) return
Loading...
+ if (error) return
Error loading registration flow: {String(error)}
+ if (!flow) return null + + return ( +
+

Create account

+ + +
+ ) +} diff --git a/ui/src/pages/auth/VerificationPage.tsx b/ui/src/pages/auth/VerificationPage.tsx new file mode 100644 index 0000000..caf645c --- /dev/null +++ b/ui/src/pages/auth/VerificationPage.tsx @@ -0,0 +1,33 @@ +import { useSearchParams } from 'react-router-dom' +import { useFlow } from '../../api/flows' +import FlowForm from '../../components/FlowNodes/FlowForm' + +export default function VerificationPage() { + const [params] = useSearchParams() + const flowId = params.get('flow') + + if (!flowId) { + window.location.href = '/kratos/self-service/verification/browser' + return
Redirecting...
+ } + + return +} + +function VerificationFlow({ flowId }: { flowId: string }) { + const { data: flow, isLoading, error } = useFlow('verification', flowId) + + if (isLoading) return
Loading...
+ if (error) return
Error loading verification flow: {String(error)}
+ if (!flow) return null + + return ( +
+

Email verification

+ + +
+ ) +} diff --git a/ui/src/pages/courier/index.tsx b/ui/src/pages/courier/index.tsx new file mode 100644 index 0000000..3f0cc88 --- /dev/null +++ b/ui/src/pages/courier/index.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { Button } from '@gouvfr-lasuite/cunningham-react' +import { useCourierMessages, useCourierMessage } from '../../api/courier' + +const STATUS_COLORS: Record = { + queued: 'var(--c--theme--colors--info-600)', + sent: 'var(--c--theme--colors--success-600)', + processing: 'var(--c--theme--colors--warning-600)', + abandoned: 'var(--c--theme--colors--danger-600)', +} + +export default function CourierPage() { + const [statusFilter, setStatusFilter] = useState('') + const [selectedId, setSelectedId] = useState(null) + + const { data: messages, isLoading, error } = useCourierMessages({ + page_size: 50, + status: statusFilter || undefined, + }) + const { data: detail } = useCourierMessage(selectedId ?? '') + + if (isLoading) return
Loading courier messages...
+ if (error) return
Error: {String(error)}
+ + return ( +
+

Courier Messages

+ +
+ {['', 'queued', 'sent', 'processing', 'abandoned'].map((s) => ( + + ))} +
+ +
+
+ + + + + + + + + + + + {(messages ?? []).map((msg) => ( + setSelectedId(msg.id)} + > + + + + + + + ))} + +
RecipientSubjectTypeStatusCreated
{msg.recipient}{msg.subject}{msg.type} + + {msg.status} + + {new Date(msg.created_at).toLocaleString()}
+ + {(messages ?? []).length === 0 && ( +

No messages found.

+ )} +
+ + {selectedId && detail && ( +
+

Message Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{detail.id}
Recipient{detail.recipient}
Subject{detail.subject}
Type{detail.type}
Status + + {detail.status} + +
Created{new Date(detail.created_at).toLocaleString()}
Updated{new Date(detail.updated_at).toLocaleString()}
+ {detail.body && ( + <> +

Body

+
{detail.body}
+ + )} +
+ +
+
+ )} +
+
+ ) +} diff --git a/ui/src/pages/identities/create.tsx b/ui/src/pages/identities/create.tsx new file mode 100644 index 0000000..362f562 --- /dev/null +++ b/ui/src/pages/identities/create.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button, Select } from '@gouvfr-lasuite/cunningham-react' +import { useSchemas, useSchema } from '../../api/schemas' +import { useCreateIdentity } from '../../api/identities' +import SchemaForm from '../../components/SchemaForm' +import type { RJSFSchema } from '@rjsf/utils' + +export default function IdentityCreatePage() { + const navigate = useNavigate() + const { data: schemas, isLoading: schemasLoading } = useSchemas() + const createIdentity = useCreateIdentity() + const [selectedSchema, setSelectedSchema] = useState('') + const [state, setState] = useState<'active' | 'inactive'>('active') + const [error, setError] = useState(null) + + const { data: fetchedSchema } = useSchema(selectedSchema) + const traitsSchema = fetchedSchema as RJSFSchema | undefined + + const schemaOptions = (schemas ?? []).map((s) => ({ label: s.id, value: s.id })) + const stateOptions = [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ] + + const handleSubmit = async (traits: unknown) => { + setError(null) + try { + const result = await createIdentity.mutateAsync({ + schema_id: selectedSchema, + traits, + state, + }) + navigate(`/identities/${result.id}`) + } catch (e) { + setError(String(e)) + } + } + + if (schemasLoading) return
Loading schemas...
+ + return ( +
+

Create Identity

+ +
+ setState(e.target.value as 'active' | 'inactive')} + /> +
+ + {error && ( +
{error}
+ )} + + {traitsSchema ? ( + +
+ + +
+
+ ) : ( + selectedSchema &&
Schema has no traits definition.
+ )} +
+ ) +} diff --git a/ui/src/pages/identities/detail.tsx b/ui/src/pages/identities/detail.tsx new file mode 100644 index 0000000..6090291 --- /dev/null +++ b/ui/src/pages/identities/detail.tsx @@ -0,0 +1,177 @@ +import { useParams, Link, useNavigate } from 'react-router-dom' +import { Button } from '@gouvfr-lasuite/cunningham-react' +import { useIdentity, useIdentitySessions, useDeleteIdentity, useDeleteAllIdentitySessions, useGenerateRecoveryLink, useGenerateRecoveryCode } from '../../api/identities' +import { useState } from 'react' +import ConfirmModal from '../../components/ConfirmModal' + +export default function IdentityDetailPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { data: identity, isLoading, error } = useIdentity(id!) + const { data: sessions } = useIdentitySessions(id!) + const deleteIdentity = useDeleteIdentity() + const deleteAllSessions = useDeleteAllIdentitySessions() + const generateLink = useGenerateRecoveryLink() + const generateCode = useGenerateRecoveryCode() + const [confirmDelete, setConfirmDelete] = useState(false) + const [recoveryResult, setRecoveryResult] = useState<{ link?: string; code?: string } | null>(null) + + if (isLoading) return
Loading identity...
+ if (error) return
Error: {String(error)}
+ if (!identity) return
Identity not found.
+ + const handleDelete = async () => { + await deleteIdentity.mutateAsync(identity.id) + navigate('/identities') + } + + const handleRecoveryLink = async () => { + const r = await generateLink.mutateAsync({ identity_id: identity.id }) + setRecoveryResult({ link: r.recovery_link }) + } + + const handleRecoveryCode = async () => { + const r = await generateCode.mutateAsync({ identity_id: identity.id }) + setRecoveryResult({ link: r.recovery_link, code: r.recovery_code }) + } + + return ( +
+
+

Identity Detail

+
+ + + + + + + +
+
+ + {recoveryResult && ( +
+ Recovery: + {recoveryResult.code &&
Code: {recoveryResult.code}
} + {recoveryResult.link && } +
+ +
+
+ )} + + + + + + + + + + + + + + + + + + + + + + + + +
ID{identity.id}
Schema{identity.schema_id}
State + + {identity.state} + +
Created{new Date(identity.created_at).toLocaleString()}
Updated{new Date(identity.updated_at).toLocaleString()}
+ +

Traits

+
{JSON.stringify(identity.traits, null, 2)}
+ + {identity.metadata_public && ( + <> +

Public Metadata

+
{JSON.stringify(identity.metadata_public, null, 2)}
+ + )} + + {identity.metadata_admin && ( + <> +

Admin Metadata

+
{JSON.stringify(identity.metadata_admin, null, 2)}
+ + )} + + {identity.credentials && ( + <> +

Credentials

+ + + + + + + + + {Object.entries(identity.credentials).map(([key, cred]) => ( + + + + + ))} + +
TypeCreated
{cred.type}{new Date(cred.created_at).toLocaleString()}
+ + )} + +

Sessions

+ {sessions && sessions.length > 0 ? ( + + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + ))} + +
IDActiveAALAuthenticatedExpires
{s.id.slice(0, 8)}...{s.active ? 'Yes' : 'No'}{s.authenticator_assurance_level}{new Date(s.authenticated_at).toLocaleString()}{new Date(s.expires_at).toLocaleString()}
+ ) : ( +

No active sessions.

+ )} + + setConfirmDelete(false)} + /> +
+ ) +} diff --git a/ui/src/pages/identities/edit.tsx b/ui/src/pages/identities/edit.tsx new file mode 100644 index 0000000..ebbd706 --- /dev/null +++ b/ui/src/pages/identities/edit.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { Button, Select } from '@gouvfr-lasuite/cunningham-react' +import { useIdentity, useUpdateIdentity } from '../../api/identities' +import { useSchema } from '../../api/schemas' +import SchemaForm from '../../components/SchemaForm' +import type { RJSFSchema } from '@rjsf/utils' + +export default function IdentityEditPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { data: identity, isLoading } = useIdentity(id!) + const updateIdentity = useUpdateIdentity(id!) + const { data: fetchedSchema } = useSchema(identity?.schema_id ?? '') + const [error, setError] = useState(null) + const [state, setState] = useState(null) + + if (isLoading) return
Loading identity...
+ if (!identity) return
Identity not found.
+ + const traitsSchema = fetchedSchema as RJSFSchema | undefined + const currentState = state ?? identity.state + + const stateOptions = [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ] + + const handleSubmit = async (traits: unknown) => { + setError(null) + try { + await updateIdentity.mutateAsync({ + schema_id: identity.schema_id, + traits: traits as Record, + state: currentState as 'active' | 'inactive', + }) + navigate(`/identities/${id}`) + } catch (e) { + setError(String(e)) + } + } + + return ( +
+

Edit Identity

+

+ Schema: {identity.schema_id} | ID: {identity.id} +

+ +
+ +
+ {selected.size > 0 && ( + + )} +
+ + {recoveryResult && ( +
+ Recovery: + {recoveryResult.code &&
Code: {recoveryResult.code}
} + {recoveryResult.link && } +
+ +
+
+ )} + + + + + + + + + + + + + {(identities ?? []).map((identity) => { + const email = (identity.traits as { email?: string })?.email ?? identity.id.slice(0, 8) + return ( + + + + + + + + ) + })} + +
EmailSchemaStateActions
+ toggleSelect(identity.id)} + /> + + {email} + {identity.schema_id} + + {identity.state} + + + + + + + + + + + + +
+ + { + if (confirmDelete) await deleteIdentity.mutateAsync(confirmDelete) + setConfirmDelete(null) + }} + onCancel={() => setConfirmDelete(null)} + /> + + setConfirmBulkDelete(false)} + /> +
+ ) +} diff --git a/ui/src/pages/schemas/index.tsx b/ui/src/pages/schemas/index.tsx new file mode 100644 index 0000000..e307430 --- /dev/null +++ b/ui/src/pages/schemas/index.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { Button } from '@gouvfr-lasuite/cunningham-react' +import { useSchemas, useSchema } from '../../api/schemas' +import SchemaForm from '../../components/SchemaForm' +import type { RJSFSchema } from '@rjsf/utils' + +export default function SchemasPage() { + const { data: schemas, isLoading, error } = useSchemas() + const [selectedId, setSelectedId] = useState('') + const [viewMode, setViewMode] = useState<'json' | 'preview'>('json') + const { data: schema } = useSchema(selectedId) + + if (isLoading) return
Loading schemas...
+ if (error) return
Error: {String(error)}
+ + const schemaTitle = (schema as Record)?.title as string | undefined + + return ( +
+

Identity Schemas

+ +
+
+
+ {(schemas ?? []).map((s) => ( + + ))} +
+ {(schemas ?? []).length === 0 && ( +

No schemas found.

+ )} +
+ +
+ {schema ? ( + <> +
+
+

{selectedId}

+ {schemaTitle && ( +
+ {schemaTitle} +
+ )} +
+
+ + +
+
+ + {viewMode === 'json' ? ( +
+                  {JSON.stringify(schema, null, 2)}
+                
+ ) : ( +
+

+ Preview of the form generated from this schema. Submit is disabled. +

+ {}} + disabled + > + + +
+ )} + + ) : ( +

+ Select a schema to view its details. +

+ )} +
+
+
+ ) +} diff --git a/ui/src/pages/sessions/index.tsx b/ui/src/pages/sessions/index.tsx new file mode 100644 index 0000000..b04acb0 --- /dev/null +++ b/ui/src/pages/sessions/index.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { Button } from '@gouvfr-lasuite/cunningham-react' +import { useSessions, useRevokeSession, useExtendSession } from '../../api/sessions' +import ConfirmModal from '../../components/ConfirmModal' + +export default function SessionsPage() { + const [activeFilter, setActiveFilter] = useState(undefined) + const { data: sessions, isLoading, error } = useSessions({ + page_size: 50, + active: activeFilter, + }) + const revokeSession = useRevokeSession() + const extendSession = useExtendSession() + const [confirmRevoke, setConfirmRevoke] = useState(null) + + if (isLoading) return
Loading sessions...
+ if (error) return
Error: {String(error)}
+ + return ( +
+

Sessions

+ +
+ + + +
+ + + + + + + + + + + + + + + {(sessions ?? []).map((session) => ( + + + + + + + + + + ))} + +
Session IDIdentityActiveAALAuthenticatedExpiresActions
{session.id.slice(0, 8)}... + {session.identity_id ? ( + + {session.identity_id.slice(0, 8)}... + + ) : ( + '-' + )} + + + {session.active ? 'Yes' : 'No'} + + {session.authenticator_assurance_level}{new Date(session.authenticated_at).toLocaleString()}{new Date(session.expires_at).toLocaleString()} + + +
+ + {(sessions ?? []).length === 0 && ( +

No sessions found.

+ )} + + { + if (confirmRevoke) await revokeSession.mutateAsync(confirmRevoke) + setConfirmRevoke(null) + }} + onCancel={() => setConfirmRevoke(null)} + /> +
+ ) +} diff --git a/ui/src/pages/settings/profile.tsx b/ui/src/pages/settings/profile.tsx new file mode 100644 index 0000000..ab9a304 --- /dev/null +++ b/ui/src/pages/settings/profile.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react' +import { Input, Button } from '@gouvfr-lasuite/cunningham-react' +import { useSessionStore } from '../../stores/session' +import AvatarUpload from '../../components/AvatarUpload' + +export default function ProfilePage() { + const { session, fetchSession } = useSessionStore() + + if (!session) return
Loading...
+ + const identity = session.identity + const traits = (identity?.traits ?? {}) as Record + const isEmployee = identity?.schema_id === 'employee' + + return ( +
+

Profile

+ + + + +
+ ) +} + +function ProfileForm({ + traits, + isEmployee, +}: { + traits: Record + isEmployee: boolean +}) { + const [values, setValues] = useState(traits) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + useEffect(() => { + setValues(traits) + }, [traits]) + + const handleSave = async () => { + setSaving(true) + setMessage(null) + try { + const flowResp = await fetch('/kratos/self-service/settings/browser', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }) + if (!flowResp.ok) throw new Error('Failed to create settings flow') + const flow = await flowResp.json() + + const submitResp = await fetch(flow.ui.action, { + method: flow.ui.method, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'include', + body: new URLSearchParams({ + 'csrf_token': flow.ui.nodes.find( + (n: { attributes: { name: string } }) => n.attributes.name === 'csrf_token' + )?.attributes?.value ?? '', + method: 'profile', + 'traits.email': traits.email ?? '', + 'traits.given_name': values.given_name ?? '', + 'traits.family_name': values.family_name ?? '', + 'traits.nickname': values.nickname ?? '', + // Preserve picture trait so avatar isn't cleared on save + ...(traits.picture ? { 'traits.picture': traits.picture } : {}), + ...(isEmployee ? { + 'traits.middle_name': values.middle_name ?? '', + 'traits.phone_number': values.phone_number ?? '', + // Admin-managed fields: pass original values so Kratos doesn't clear them + 'traits.job_title': traits.job_title ?? '', + 'traits.department': traits.department ?? '', + 'traits.office_location': traits.office_location ?? '', + 'traits.employee_id': traits.employee_id ?? '', + 'traits.hire_date': traits.hire_date ?? '', + 'traits.manager': traits.manager ?? '', + } : {}), + }), + }) + + if (submitResp.ok || submitResp.status === 422) { + setMessage({ type: 'success', text: 'Profile updated successfully.' }) + useSessionStore.getState().fetchSession() + } else { + throw new Error('Failed to update profile') + } + } catch (err) { + setMessage({ type: 'error', text: String(err) }) + } finally { + setSaving(false) + } + } + + const update = (key: string, value: string) => { + setValues((v) => ({ ...v, [key]: value })) + } + + return ( +
+ {message && ( +
+ {message.text} +
+ )} + + + +
+ ) => update('given_name', e.target.value)} + fullWidth + /> + ) => update('family_name', e.target.value)} + fullWidth + /> +
+ + {isEmployee && ( + ) => update('middle_name', e.target.value)} + fullWidth + /> + )} + + ) => update('nickname', e.target.value)} + fullWidth + /> + + {isEmployee && ( + ) => update('phone_number', e.target.value)} + fullWidth + /> + )} + +
+ +
+ + {isEmployee && ( + <> +
+

Organization

+

+ These fields are managed by an administrator. +

+
+
+ + +
+ +
+ + +
+ +
+
+ + )} +
+ ) +} diff --git a/ui/src/pages/settings/security.tsx b/ui/src/pages/settings/security.tsx new file mode 100644 index 0000000..fb1fb41 --- /dev/null +++ b/ui/src/pages/settings/security.tsx @@ -0,0 +1,250 @@ +import { useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Button, Input } from '@gouvfr-lasuite/cunningham-react' +import { useFlow } from '../../api/flows' +import { useSessionStore } from '../../stores/session' +import FlowForm from '../../components/FlowNodes/FlowForm' + +export default function SecurityPage() { + const [params] = useSearchParams() + const flowId = params.get('flow') + const { needs2faSetup } = useSessionStore() + + // New users arriving from recovery flow: redirect to onboarding wizard + if (needs2faSetup) { + window.location.href = '/onboarding' + return
Redirecting to account setup...
+ } + + // If redirected back from Kratos with a flow param, show the result + if (flowId) { + return ( +
+

Security

+ +
+ ) + } + + return ( +
+

Security

+ + {needs2faSetup && ( +
+ You must set up two-factor authentication before you can use this app. + Configure an authenticator app or security key below. +
+ )} + +
+

Password

+ +
+ +
+

Two-Factor Authentication

+

+ Add a second layer of security to your account using an authenticator app, security key, or backup codes. +

+ + + +
+
+ ) +} + +function PasswordSection() { + const [expanded, setExpanded] = useState(false) + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + const mismatch = confirm.length > 0 && password !== confirm + + const handleSubmit = async () => { + if (!password || password !== confirm) return + setSaving(true) + setMessage(null) + try { + const flowResp = await fetch('/kratos/self-service/settings/browser', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }) + if (!flowResp.ok) throw new Error('Failed to create settings flow') + const flow = await flowResp.json() + + const csrfToken = flow.ui.nodes.find( + (n: { attributes: { name: string } }) => n.attributes.name === 'csrf_token' + )?.attributes?.value ?? '' + + const submitResp = await fetch(flow.ui.action, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'include', + body: new URLSearchParams({ + csrf_token: csrfToken, + method: 'password', + password, + }), + }) + + if (submitResp.ok || submitResp.status === 422) { + const result = await submitResp.json() + const errorMsg = result.ui?.messages?.find((m: { type: string }) => m.type === 'error') + if (errorMsg) { + setMessage({ type: 'error', text: errorMsg.text }) + } else { + setMessage({ type: 'success', text: 'Password changed successfully.' }) + setPassword('') + setConfirm('') + setExpanded(false) + } + } else { + throw new Error('Failed to change password') + } + } catch (err) { + setMessage({ type: 'error', text: String(err) }) + } finally { + setSaving(false) + } + } + + if (!expanded) { + return ( + <> + {message && ( +
+ {message.text} +
+ )} + + + ) + } + + return ( +
+ {message && ( +
+ {message.text} +
+ )} + ) => setPassword(e.target.value)} + fullWidth + /> + ) => setConfirm(e.target.value)} + state={mismatch ? 'error' : undefined} + text={mismatch ? 'Passwords do not match' : undefined} + fullWidth + /> +
+ + +
+
+ ) +} + +function MfaSection({ method, title, description }: { method: string; title: string; description: string }) { + const [flowId, setFlowId] = useState(null) + const [loading, setLoading] = useState(false) + const [expanded, setExpanded] = useState(false) + + const startFlow = async () => { + setLoading(true) + try { + const resp = await fetch('/kratos/self-service/settings/browser', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }) + if (resp.ok) { + const flow = await resp.json() + setFlowId(flow.id) + setExpanded(true) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
{title}
+
+ {description} +
+
+ {!expanded && ( + + )} +
+ {expanded && flowId && ( +
+ +
+ )} +
+ ) +} + +function SettingsFlow({ flowId, only, exclude }: { flowId: string; only?: string; exclude?: string[] }) { + const { data: flow, isLoading, error } = useFlow('settings', flowId) + + if (isLoading) return
Loading...
+ if (error) return
Error: {String(error)}
+ if (!flow) return null + + return +} diff --git a/ui/src/stores/session.ts b/ui/src/stores/session.ts new file mode 100644 index 0000000..9c8859d --- /dev/null +++ b/ui/src/stores/session.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand' + +interface Identity { + id: string + schema_id: string + traits: Record + state: string + metadata_public?: Record +} + +interface Session { + id: string + active: boolean + identity: Identity + authenticated_at: string + expires_at: string +} + +interface SessionState { + session: Session | null + isAdmin: boolean + needs2faSetup: boolean + isLoading: boolean + error: string | null + fetchSession: () => Promise + logout: () => Promise +} + +export const useSessionStore = create((set) => ({ + session: null, + isAdmin: false, + needs2faSetup: false, + isLoading: true, + error: null, + + fetchSession: async () => { + set({ isLoading: true, error: null }) + try { + const resp = await fetch('/api/auth/session') + if (resp.status === 403) { + const data = await resp.json().catch(() => null) + // AAL2 required — redirect to TOTP/WebAuthn step-up + if (data?.needsAal2) { + const returnTo = encodeURIComponent(window.location.href) + window.location.href = `/kratos/self-service/login/browser?aal=aal2&return_to=${returnTo}` + return + } + // 2FA not set up — redirect to onboarding wizard + if (data?.needs2faSetup) { + set({ session: null, isAdmin: false, needs2faSetup: true, isLoading: false }) + if (window.location.pathname !== '/onboarding' && window.location.pathname !== '/security') { + window.location.href = '/onboarding' + } + return + } + } + if (!resp.ok) { + set({ session: null, isAdmin: false, needs2faSetup: false, isLoading: false }) + return + } + const data = await resp.json() + if (data.needs2faSetup && window.location.pathname !== '/onboarding' && window.location.pathname !== '/security') { + window.location.href = '/onboarding' + return + } + set({ + session: data.session, + isAdmin: data.isAdmin, + needs2faSetup: data.needs2faSetup ?? false, + isLoading: false, + }) + } catch (err) { + set({ + session: null, + isAdmin: false, + needs2faSetup: false, + isLoading: false, + error: String(err), + }) + } + }, + + logout: async () => { + try { + // Revoke ALL sessions for this identity (force logout everywhere) + await fetch('/api/auth/sessions', { method: 'DELETE', credentials: 'include' }) + + // Then perform the browser logout flow to clear the current cookie + const resp = await fetch('/kratos/self-service/logout/browser', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }) + if (resp.ok) { + const data = await resp.json() + if (data.logout_url) { + window.location.href = data.logout_url + return + } + } + } catch { + // Fall through + } + window.location.href = '/login' + }, +})) diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..055b65e --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts", "cunningham.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..ead9adb --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:3000', + }, + }, + build: { + outDir: 'dist', + }, +})