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.
This commit is contained in:
2026-03-21 15:17:56 +00:00
commit 1467a948d0
65 changed files with 5525 additions and 0 deletions

29
.dockerignore Normal file
View File

@@ -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

25
.gitignore vendored Normal file
View File

@@ -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-*

26
Dockerfile Normal file
View File

@@ -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"]

15
deno.json Normal file
View File

@@ -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"
}
}

404
deno.lock generated Normal file
View File

@@ -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"
]
}
}

8
dev.toml Normal file
View File

@@ -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"

79
main.ts Normal file
View File

@@ -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);

289
server/auth.ts Normal file
View File

@@ -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<SessionResult> {
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<boolean> {
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<Response> {
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<Response> {
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,
});
}

90
server/csrf.ts Normal file
View File

@@ -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<string> {
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<boolean> {
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<boolean> {
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();
}

77
server/flow.ts Normal file
View File

@@ -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=<id> — proxy Kratos self-service flow data. */
export async function flowHandler(c: Context): Promise<Response> {
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=<id> — proxy Kratos self-service error. */
export async function flowErrorHandler(c: Context): Promise<Response> {
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);
}
}

244
server/hydra.ts Normal file
View File

@@ -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<Response> {
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<Record<string, unknown>> {
const scopes = new Set(grantedScopes);
const claims: Record<string, unknown> = {};
let traits: Record<string, unknown>;
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<string, unknown>) ?? {};
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=<ch> */
export async function getConsent(c: Context): Promise<Response> {
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<Response> {
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<Response> {
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=<ch> */
export async function getLogout(c: Context): Promise<Response> {
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<Response> {
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<Response> {
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);
}
}

66
server/proxy.ts Normal file
View File

@@ -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<Response> {
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,
});
}

283
server/s3.ts Normal file
View File

@@ -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<ArrayBuffer> {
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<string> {
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<string, string>,
body: Uint8Array | null,
): Promise<Record<string, string>> {
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<Response> {
const url = new URL(`/${BUCKET}/${key}`, SEAWEEDFS_S3_URL);
const headers: Record<string, string> = {
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<Response> {
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<Response> {
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<Response> {
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 });
}

10
ui/cunningham.ts Normal file
View File

@@ -0,0 +1,10 @@
export default {
themes: {
default: {},
dark: {},
"dsfr-light": {},
"dsfr-dark": {},
"anct-light": {},
"anct-dark": {},
},
};

16
ui/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ysabeau:ital,wght@0,1..1000;1,1..1000&display=swap" />
<title>Sunbeam Studios</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
ui/package.json Normal file
View File

@@ -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"
}
}

13
ui/public/favicon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="8" fill="#4F46E5"/>
<g stroke="#4F46E5" stroke-width="2.5" stroke-linecap="round">
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="16" y1="26" x2="16" y2="30"/>
<line x1="2" y1="16" x2="6" y2="16"/>
<line x1="26" y1="16" x2="30" y2="16"/>
<line x1="6.1" y1="6.1" x2="8.9" y2="8.9"/>
<line x1="23.1" y1="23.1" x2="25.9" y2="25.9"/>
<line x1="6.1" y1="25.9" x2="8.9" y2="23.1"/>
<line x1="23.1" y1="8.9" x2="25.9" y2="6.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 561 B

72
ui/src/App.tsx Normal file
View File

@@ -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 (
<CunninghamProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{/* Public auth flows — centered card layout */}
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/registration" element={<RegistrationPage />} />
<Route path="/recovery" element={<RecoveryPage />} />
<Route path="/verification" element={<VerificationPage />} />
<Route path="/error" element={<ErrorPage />} />
</Route>
{/* Hydra flows + onboarding — card layout */}
<Route element={<AuthLayout />}>
<Route path="/consent" element={<ConsentPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/onboarding" element={<OnboardingWizard />} />
</Route>
{/* Authenticated — Dashboard with sidebar */}
<Route element={<DashboardLayout />}>
<Route path="/" element={<Navigate to="/profile" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/security" element={<SecurityPage />} />
{/* Legacy redirect */}
<Route path="/settings" element={<Navigate to="/profile" replace />} />
{/* Admin-only routes */}
<Route path="/identities" element={<IdentitiesPage />} />
<Route path="/identities/create" element={<IdentityCreatePage />} />
<Route path="/identities/:id/edit" element={<IdentityEditPage />} />
<Route path="/identities/:id" element={<IdentityDetailPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/courier" element={<CourierPage />} />
<Route path="/schemas" element={<SchemasPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</CunninghamProvider>
)
}

41
ui/src/api/avatar.ts Normal file
View File

@@ -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'] })
},
})
}

25
ui/src/api/client.ts Normal file
View File

@@ -0,0 +1,25 @@
const BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
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: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}

32
ui/src/api/courier.ts Normal file
View File

@@ -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<CourierMessage[]>(`/admin/courier/messages?${search}`),
})
}
export function useCourierMessage(id: string) {
return useQuery({
queryKey: ['courier', id],
queryFn: () => api.get<CourierMessage>(`/admin/courier/messages/${id}`),
enabled: !!id,
})
}

58
ui/src/api/flows.ts Normal file
View File

@@ -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<string, unknown>
messages: FlowMessage[]
meta: {
label?: { id: number; text: string; type: string }
}
}
export interface FlowMessage {
id: number
text: string
type: 'error' | 'info' | 'success'
context?: Record<string, unknown>
}
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<Flow>
},
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,
})
}

75
ui/src/api/hydra.ts Normal file
View File

@@ -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<ConsentRequest>
},
enabled: !!challenge,
})
}
export async function acceptConsent(
challenge: string,
grantScope: string[],
remember = false,
session?: Record<string, unknown>,
): 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()
}

100
ui/src/api/identities.ts Normal file
View File

@@ -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<string, unknown>
metadata_public?: Record<string, unknown>
metadata_admin?: Record<string, unknown>
credentials?: Record<string, { type: string; created_at: string }>
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<Identity[]>(`/admin/identities?${search}`),
})
}
export function useIdentity(id: string) {
return useQuery({
queryKey: ['identities', id],
queryFn: () => api.get<Identity>(`/admin/identities/${id}`),
enabled: !!id,
})
}
export function useCreateIdentity() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { schema_id: string; traits: unknown; state?: string }) =>
api.post<Identity>('/admin/identities', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }),
})
}
export function useUpdateIdentity(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Identity>) =>
api.put<Identity>(`/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<Session[]>(`/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'] }),
})
}

23
ui/src/api/schemas.ts Normal file
View File

@@ -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<SchemaListItem[]>('/schemas'),
})
}
// Kratos GET /schemas/{id} returns the raw JSON schema directly
export function useSchema(id: string) {
return useQuery({
queryKey: ['schemas', id],
queryFn: () => api.get<Record<string, unknown>>(`/schemas/${encodeURIComponent(id)}`),
enabled: !!id,
})
}

6
ui/src/api/session.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useSessionStore } from '../stores/session'
export function useSession() {
const { session, isAdmin, isLoading, error } = useSessionStore()
return { session, isAdmin, isLoading, error }
}

37
ui/src/api/sessions.ts Normal file
View File

@@ -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<Session[]>(`/admin/sessions?${search}`),
})
}
export function useSession(id: string) {
return useQuery({
queryKey: ['sessions', id],
queryFn: () => api.get<Session>(`/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'] }),
})
}

View File

@@ -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 (
<img
src={`/api/avatar/${identityId}`}
alt={name ?? ''}
style={{
width: px,
height: px,
borderRadius: '50%',
objectFit: 'cover',
}}
/>
)
}
return (
<div style={{
width: px,
height: px,
borderRadius: '50%',
backgroundColor: 'var(--sunbeam--avatar-bg)',
color: 'var(--sunbeam--avatar-fg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: px * 0.4,
fontWeight: 600,
}}>
{initial}
</div>
)
}

View File

@@ -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<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null)
const upload = useUploadAvatar()
const remove = useDeleteAvatar()
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
const currentSrc = preview ?? uploadedUrl ?? (picture ? `/api/avatar/${identityId}?t=${Date.now()}` : null)
const initial = (name?.[0] ?? '?').toUpperCase()
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div
onClick={() => 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 ? (
<img src={currentSrc} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<span style={{ color: 'var(--sunbeam--avatar-fg)', fontSize: '2rem', fontWeight: 600 }}>{initial}</span>
)}
<div style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: 0,
transition: 'opacity 0.2s',
color: '#fff',
fontSize: '0.7rem',
fontWeight: 500,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.opacity = '1' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.opacity = '0' }}
>
Change
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<Button color="neutral" size="small" onClick={() => fileRef.current?.click()}>
{upload.isPending ? 'Uploading...' : 'Upload photo'}
</Button>
{currentSrc && !preview && (
<Button color="error" size="small" onClick={handleDelete}>
{remove.isPending ? 'Removing...' : 'Remove'}
</Button>
)}
</div>
</div>
)
}

View File

@@ -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<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen && !dialog.open) {
dialog.showModal()
} else if (!isOpen && dialog.open) {
dialog.close()
}
}, [isOpen])
return (
<dialog
ref={dialogRef}
onClose={onCancel}
style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 12,
padding: '1.5rem',
maxWidth: 420,
width: '100%',
}}
>
<h3 style={{ margin: '0 0 0.75rem' }}>{title}</h3>
<p style={{ margin: '0 0 1.5rem', color: 'var(--sunbeam--text-secondary)' }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<Button color="neutral" onClick={onCancel}>
Cancel
</Button>
<Button color={confirmColor} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</dialog>
)
}

View File

@@ -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<HealthStatus>({ 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 (
<nav style={{
width: 220,
borderRight: '1px solid var(--sunbeam--border)',
display: 'flex',
flexDirection: 'column',
padding: '1rem 0',
}}>
<div style={{ padding: '0 1rem', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0, fontSize: '1.125rem' }}>Sunbeam Studios</h2>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem', padding: '0 0.5rem' }}>
{!needs2faSetup && (
<NavLink
to="/profile"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Profile
</NavLink>
)}
<NavLink
to="/security"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Security
</NavLink>
{isAdmin && !needs2faSetup && (
<>
<div style={{
padding: '0.75rem 0.5rem 0.25rem',
fontSize: '0.7rem',
textTransform: 'uppercase',
color: 'var(--sunbeam--text-muted)',
fontWeight: 600,
letterSpacing: '0.05em',
}}>
Administration
</div>
<NavLink to="/identities" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Identities
</NavLink>
<NavLink to="/sessions" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Sessions
</NavLink>
<NavLink to="/courier" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Courier
</NavLink>
<NavLink to="/schemas" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Schemas
</NavLink>
</>
)}
</div>
<div style={{ padding: '0.5rem 1rem' }}>
<Button color="brand" size="small" fullWidth onClick={logout}>
Sign out
</Button>
</div>
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--sunbeam--border)', fontSize: '0.8rem', color: 'var(--sunbeam--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: health.alive ? 'var(--c--theme--colors--success-500)' : 'var(--c--theme--colors--danger-500)',
display: 'inline-block',
}} />
Alive
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: health.ready ? 'var(--c--theme--colors--success-500)' : 'var(--c--theme--colors--danger-500)',
display: 'inline-block',
}} />
Ready
</div>
</div>
</nav>
)
}

View File

@@ -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 <NodeInput key={index} node={node} />
case 'text':
return <NodeText key={index} node={node} />
case 'img':
return <NodeImage key={index} node={node} />
case 'script':
return <NodeScript key={index} node={node} />
case 'a':
return <NodeAnchor key={index} node={node} />
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<string, FlowNode[]>()
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<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
onSubmit(ui.action, formData)
}
: undefined
return (
<form action={ui.action} method={ui.method} onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{ui.messages?.map((msg) => {
const isError = msg.type === 'error'
const isSuccess = msg.type === 'success'
return (
<div key={msg.id} style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: isError
? 'var(--c--theme--colors--danger-50)'
: isSuccess
? 'var(--c--theme--colors--success-50)'
: 'var(--c--theme--colors--info-50)',
border: `1px solid ${isError
? 'var(--c--theme--colors--danger-200)'
: isSuccess
? 'var(--c--theme--colors--success-200)'
: 'var(--c--theme--colors--info-200)'}`,
color: isError
? 'var(--c--theme--colors--danger-800)'
: isSuccess
? 'var(--c--theme--colors--success-800)'
: 'var(--c--theme--colors--info-800)',
fontSize: '0.875rem',
lineHeight: 1.5,
}}>
{msg.text}
</div>
)
})}
{sortedGroups.map(([group, nodes]) => (
<div key={group} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{nodes.map((node, i) => renderNode(node, i))}
</div>
))}
</form>
)
}

View File

@@ -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 (
<a href={attrs.href} style={{ textDecoration: 'none' }}>
<Button color="neutral" fullWidth>
{label}
</Button>
</a>
)
}

View File

@@ -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 (
<div style={{ textAlign: 'center' }}>
{label && <div style={{ marginBottom: '0.5rem', fontWeight: 500 }}>{label}</div>}
<img
src={attrs.src}
width={attrs.width ?? 200}
height={attrs.height ?? 200}
alt={label ?? 'QR Code'}
style={{ maxWidth: '100%' }}
/>
</div>
)
}

View File

@@ -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 <input type="hidden" name={attrs.name} value={attrs.value ?? ''} />
}
// 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 <NativeOnclickButton node={node} />
}
if (attrs.type === 'submit' || attrs.type === 'button') {
const isPrimary = node.group === 'password' || node.group === 'default'
return (
<>
{errorMsg && (
<div style={{ color: 'var(--c--theme--colors--danger-400)', fontSize: '0.8125rem', marginBottom: '0.25rem' }}>
{errorMsg.text}
</div>
)}
<Button
type="submit"
name={attrs.name}
value={attrs.value}
color={isPrimary ? 'brand' : 'neutral'}
disabled={attrs.disabled}
fullWidth
>
{label}
</Button>
</>
)
}
if (attrs.type === 'checkbox') {
return (
<Checkbox
label={label}
name={attrs.name}
defaultChecked={attrs.value === 'true'}
disabled={attrs.disabled}
/>
)
}
// Text, email, password, number, tel, etc.
return (
<Input
label={label}
name={attrs.name}
type={attrs.type === 'password' ? 'password' : attrs.type === 'email' ? 'email' : 'text'}
defaultValue={attrs.value ?? ''}
required={attrs.required}
disabled={attrs.disabled}
state={errorMsg ? 'error' : undefined}
text={errorMsg?.text ?? infoMsg?.text}
fullWidth
/>
)
}
// Renders a native <button> and sets the onclick attribute via the DOM
// so Kratos-injected scripts (WebAuthn) can trigger their ceremony handlers.
function NativeOnclickButton({ node }: Props) {
const ref = useRef<HTMLButtonElement>(null)
const attrs = node.attributes as {
name: string
type: string
value?: string
disabled?: boolean
onclick?: string
}
const label = node.meta?.label?.text ?? attrs.name
useEffect(() => {
if (ref.current && attrs.onclick) {
ref.current.setAttribute('onclick', attrs.onclick)
}
}, [attrs.onclick])
return (
<button
ref={ref}
type={attrs.type === 'button' ? 'button' : 'submit'}
name={attrs.name}
value={attrs.value}
disabled={attrs.disabled}
style={{
width: '100%',
padding: '0.625rem 1rem',
borderRadius: 4,
border: '1px solid var(--sunbeam--border)',
backgroundColor: 'var(--sunbeam--bg-muted)',
cursor: attrs.disabled ? 'not-allowed' : 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{label}
</button>
)
}

View File

@@ -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<HTMLDivElement>(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 <div ref={ref} />
}

View File

@@ -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 (
<div style={{
padding: '1rem',
backgroundColor: 'var(--sunbeam--bg-muted)',
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{label && <div style={{ fontWeight: 600, marginBottom: '0.5rem', fontFamily: 'inherit' }}>{label}</div>}
{text}
</div>
)
}

View File

@@ -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<unknown>
disabled?: boolean
children?: React.ReactNode
}
export default function SchemaForm({
schema,
uiSchema,
formData,
onSubmit,
onError,
extraErrors,
disabled,
children,
}: SchemaFormProps) {
return (
<Form
schema={schema}
uiSchema={uiSchema}
formData={formData}
validator={validator}
widgets={CunninghamWidgets}
templates={{ ObjectFieldTemplate, ArrayFieldTemplate }}
extraErrors={extraErrors}
disabled={disabled}
onSubmit={({ formData }) => onSubmit(formData)}
onError={onError}
>
{children}
</Form>
)
}

View File

@@ -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 (
<fieldset style={{ border: '1px solid var(--c--theme--colors--greyscale-200)', borderRadius: '4px', padding: '1rem', marginBottom: '1rem' }}>
{title && <legend style={{ fontWeight: 600, padding: '0 0.5rem' }}>{title}</legend>}
{description && <p style={{ color: 'var(--c--theme--colors--greyscale-600)', marginBottom: '0.5rem' }}>{description}</p>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{properties.map((prop) => prop.content)}
</div>
</fieldset>
)
}
// 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 (
<div style={{ marginBottom: '1rem' }}>
{title && <h4 style={{ marginBottom: '0.5rem' }}>{title}</h4>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{items}
</div>
{canAdd && (
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={onAddClick}>+ Add item</Button>
</div>
)}
</div>
)
}

View File

@@ -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 (
<Input
label={label}
id={id}
value={value ?? ''}
onChange={(e) => 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 (
<Input
type="email"
label={label}
id={id}
value={value ?? ''}
onChange={(e) => 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 (
<Select
label={label}
options={selectOptions}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
state={rawErrors?.length ? 'error' : 'default'}
text={rawErrors?.[0]}
/>
)
}
function CheckboxWidget({ value, onChange, label, disabled }: WidgetProps) {
return (
<Checkbox
label={label}
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
)
}
function NumberWidget({ id, value, onChange, label, required, disabled, rawErrors }: WidgetProps) {
return (
<Input
type="number"
label={label}
id={id}
value={value ?? ''}
onChange={(e) => 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,
}

View File

@@ -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<HTMLButtonElement>(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 (
<button
ref={btnRef}
onClick={toggle}
aria-label="Apps"
aria-expanded="false"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 8,
border: '1px solid var(--sunbeam--border)',
backgroundColor: 'transparent',
cursor: 'pointer',
padding: 0,
color: 'var(--sunbeam--text-secondary)',
transition: 'background-color 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--c--theme--colors--greyscale-100)'
e.currentTarget.style.borderColor = 'var(--c--theme--colors--greyscale-300)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.borderColor = 'var(--sunbeam--border)'
}}
>
{/* 3x3 grid icon (waffle) */}
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<circle cx="3" cy="3" r="1.5" />
<circle cx="9" cy="3" r="1.5" />
<circle cx="15" cy="3" r="1.5" />
<circle cx="3" cy="9" r="1.5" />
<circle cx="9" cy="9" r="1.5" />
<circle cx="15" cy="9" r="1.5" />
<circle cx="3" cy="15" r="1.5" />
<circle cx="9" cy="15" r="1.5" />
<circle cx="15" cy="15" r="1.5" />
</svg>
</button>
)
}
// Global type augmentation for the widget API
declare global {
interface Window {
_lasuite_widget: unknown[]
}
}

View File

@@ -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<ThemeState>((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 })
},
}))

View File

@@ -0,0 +1,35 @@
import { Outlet } from 'react-router-dom'
export default function AuthLayout() {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--sunbeam--bg-page)',
padding: '2rem',
}}>
<div style={{ marginBottom: '1.5rem', textAlign: 'center' }}>
<h1 style={{
margin: 0,
fontSize: '1.5rem',
fontWeight: 600,
letterSpacing: '-0.01em',
color: 'var(--sunbeam--text-primary)',
}}>Sunbeam Studios</h1>
</div>
<div style={{
width: '100%',
maxWidth: 420,
backgroundColor: 'var(--sunbeam--bg-surface)',
borderRadius: 12,
padding: '2rem',
border: '1px solid var(--sunbeam--border)',
}}>
<Outlet />
</div>
</div>
)
}

View File

@@ -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 (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--sunbeam--text-secondary)',
}}>
Loading...
</div>
)
}
if (!session) return null
const identity = session.identity
const traits = (identity?.traits ?? {}) as Record<string, string>
const fullName = [traits.given_name, traits.family_name].filter(Boolean).join(' ') || traits.email || ''
return (
<div style={{ display: 'flex', height: '100vh' }}>
<DashboardNav />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<header style={{
padding: '0.75rem 1.5rem',
borderBottom: '1px solid var(--sunbeam--border)',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<WaffleButton />
<div style={{ width: 1, height: 24, backgroundColor: 'var(--sunbeam--border)' }} />
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--sunbeam--text-secondary)' }}>{traits.email}</div>
</div>
{identity?.traits?.picture ? (
<img
src={`/api/avatar/${identity.id}`}
alt=""
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<div style={{
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: 'var(--sunbeam--avatar-bg)',
color: 'var(--sunbeam--avatar-fg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.8rem',
fontWeight: 600,
}}>
{(fullName[0] ?? '?').toUpperCase()}
</div>
)}
</div>
</header>
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
<Outlet />
</main>
</div>
</div>
)
}

16
ui/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -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<string, { label: string; description: string }> = {
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<Set<string>>(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 <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Missing consent challenge.</p>
}
if (isLoading) return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Loading...</p>
if (error) return <p style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</p>
if (!consent) return null
if (consent.redirect_to && consent.auto) {
return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Redirecting...</p>
}
if (consent.skip) {
const doAccept = async () => {
const result = await acceptConsent(challenge, consent.requested_scope, true)
window.location.href = result.redirect_to
}
doAccept()
return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Redirecting...</p>
}
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 (
<div>
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: 'var(--c--theme--colors--primary-100)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: '0.75rem',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--c--theme--colors--primary-600)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1.25rem', fontWeight: 600 }}>
Authorize {clientName}
</h2>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
This app would like permission to access your account.
</p>
</div>
<div style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
overflow: 'hidden',
marginBottom: '1rem',
}}>
{consent.requested_scope.map((scope, i) => {
const info = SCOPE_INFO[scope]
return (
<label
key={scope}
style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.75rem 1rem',
cursor: 'pointer',
borderTop: i > 0 ? '1px solid var(--sunbeam--border)' : 'none',
}}
>
<Checkbox
checked={selectedScopes.has(scope)}
onChange={() => toggleScope(scope)}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>
{info?.label ?? scope}
</div>
{info?.description && (
<div style={{ fontSize: '0.8125rem', color: 'var(--sunbeam--text-secondary)', marginTop: 1 }}>
{info.description}
</div>
)}
</div>
</label>
)
})}
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Checkbox
label="Remember this decision"
checked={remember}
onChange={() => setRemember(!remember)}
/>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Button color="brand" onClick={handleAccept} disabled={submitting} fullWidth>
Allow
</Button>
<Button color="neutral" onClick={handleReject} disabled={submitting} fullWidth>
Deny
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<div>
<h2 style={{ marginTop: 0 }}>Error</h2>
<p>An unknown error occurred.</p>
<a href="/login">Back to sign in</a>
</div>
)
}
return <ErrorDetail errorId={errorId} />
}
function ErrorDetail({ errorId }: { errorId: string }) {
const { data, isLoading, error: fetchError } = useFlowError(errorId)
if (isLoading) return <div>Loading...</div>
if (fetchError) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Failed to load error details.</div>
const errorData = data?.error ?? data
const message = errorData?.message ?? errorData?.reason ?? 'An error occurred'
const status = errorData?.code ?? errorData?.status
return (
<div>
<h2 style={{ marginTop: 0 }}>Error{status ? ` ${status}` : ''}</h2>
<p>{message}</p>
{errorData?.debug && (
<pre>{errorData.debug}</pre>
)}
<a href="/login">Back to sign in</a>
</div>
)
}

View File

@@ -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 <div>Redirecting...</div>
}
return <LoginFlow flowId={flowId} />
}
function LoginFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('login', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading login flow: {String(error)}</div>
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<string, unknown>).type !== 'hidden' &&
n.group !== 'default'
)
if (actionableNodes.length === 0 && flow.ui.messages?.length) {
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem', textAlign: 'center' }}>
Additional setup required
</h2>
<FlowForm ui={flow.ui} />
<p style={{
color: 'var(--sunbeam--text-secondary)',
fontSize: '0.875rem',
textAlign: 'center',
margin: '1rem 0',
}}>
You need to set up two-factor authentication before you can sign in to services.
</p>
<div style={{ textAlign: 'center' }}>
<a href="/onboarding" style={{ fontWeight: 500 }}>Complete account setup</a>
</div>
</div>
)
}
// 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 (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem', textAlign: 'center' }}>Sign in</h2>
<FlowForm ui={uiWithRemember} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/recovery">Forgot password?</a>
</div>
</div>
)
}

View File

@@ -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 <div>Signing out...</div>
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
const handleAccept = async () => {
setSubmitting(true)
try {
const result = await acceptLogout(challenge)
window.location.href = result.redirect_to
} catch {
setSubmitting(false)
}
}
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1rem' }}>Sign out</h2>
<p>Do you want to sign out?</p>
{logoutReq?.subject && (
<p style={{ color: 'var(--sunbeam--text-secondary)' }}>
Signed in as: <strong>{logoutReq.subject}</strong>
</p>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem' }}>
<Button color="brand" onClick={handleAccept} disabled={submitting} fullWidth>
Sign out
</Button>
<Button color="neutral" onClick={() => window.history.back()} disabled={submitting} fullWidth>
Cancel
</Button>
</div>
</div>
)
}

View File

@@ -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<Step, string> = {
password: 'Set password',
totp: 'Authenticator app',
done: 'Ready',
}
const STEPS: Step[] = ['password', 'totp', 'done']
export default function OnboardingWizard() {
const [step, setStep] = useState<Step>('password')
const stepIndex = STEPS.indexOf(step)
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '0.25rem', textAlign: 'center' }}>
Account setup
</h2>
<p style={{
textAlign: 'center',
color: 'var(--sunbeam--text-secondary)',
fontSize: '0.875rem',
marginBottom: '1.5rem',
}}>
Step {stepIndex + 1} of {STEPS.length}: {STEP_LABELS[step]}
</p>
<StepIndicator current={stepIndex} total={STEPS.length} />
{step === 'password' && (
<PasswordStep onComplete={() => setStep('totp')} />
)}
{step === 'totp' && (
<TotpStep onComplete={() => setStep('done')} />
)}
{step === 'done' && <DoneStep />}
</div>
)
}
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
}}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
style={{
flex: 1,
height: 4,
borderRadius: 2,
backgroundColor: i <= current
? 'var(--c--theme--colors--primary-500)'
: 'var(--sunbeam--border)',
transition: 'background-color 0.2s',
}}
/>
))}
</div>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Choose a strong password for your account.
</p>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: message.type === 'error'
? 'var(--c--theme--colors--danger-50)'
: 'var(--c--theme--colors--success-50)',
border: `1px solid ${message.type === 'error'
? 'var(--c--theme--colors--danger-200)'
: 'var(--c--theme--colors--success-200)'}`,
color: message.type === 'error'
? 'var(--c--theme--colors--danger-800)'
: 'var(--c--theme--colors--success-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Input
label="New password"
type="password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
fullWidth
/>
<Input
label="Confirm password"
type="password"
value={confirm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirm(e.target.value)}
state={mismatch ? 'error' : undefined}
text={mismatch ? 'Passwords do not match' : undefined}
fullWidth
/>
<Button color="brand" onClick={handleSubmit} disabled={saving || !password || mismatch} fullWidth>
{saving ? 'Setting password...' : 'Set password & continue'}
</Button>
</div>
)
}
function TotpStep({ onComplete }: { onComplete: () => void }) {
const [flowId, setFlowId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [started, setStarted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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<string, string>),
})
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Two-factor authentication is required. You'll need an authenticator
app like Google Authenticator, 1Password, or Authy.
</p>
<Button color="brand" onClick={startFlow} disabled={loading} fullWidth>
{loading ? 'Loading...' : 'Set up authenticator app'}
</Button>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Scan the QR code with your authenticator app, then enter the code below.
</p>
{error && (
<div style={{
padding: '0.75rem 1rem', borderRadius: 8,
backgroundColor: 'var(--c--theme--colors--danger-50)',
border: '1px solid var(--c--theme--colors--danger-200)',
color: 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>{error}</div>
)}
{flowId && <TotpFlow flowId={flowId} onSubmit={handleTotpSubmit} disabled={submitting} />}
</div>
)
}
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 <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
if (!flow) return null
return (
<div style={{ opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? 'none' : 'auto' }}>
<FlowForm ui={flow.ui} only="totp" onSubmit={onSubmit} />
</div>
)
}
function DoneStep() {
const returnTo = sessionStorage.getItem('onboarding_return_to') || '/profile'
const handleContinue = () => {
sessionStorage.removeItem('onboarding_return_to')
window.location.replace(returnTo)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>&#10003;</div>
<p style={{ margin: 0, fontSize: '1rem', fontWeight: 500 }}>
You're all set!
</p>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Your account is secured with a password and two-factor authentication.
</p>
<Button color="brand" onClick={handleContinue} fullWidth>
Continue
</Button>
</div>
)
}

View File

@@ -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 <div>Redirecting...</div>
}
return <RecoveryFlow flowId={flowId} />
}
function RecoveryFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('recovery', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading recovery flow: {String(error)}</div>
if (!flow) return null
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem' }}>Account recovery</h2>
<FlowForm ui={flow.ui} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/login">Back to sign in</a>
</div>
</div>
)
}

View File

@@ -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 <div>Redirecting...</div>
}
return <RegistrationFlow flowId={flowId} />
}
function RegistrationFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('registration', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading registration flow: {String(error)}</div>
if (!flow) return null
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem' }}>Create account</h2>
<FlowForm ui={flow.ui} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/login">Already have an account? Sign in</a>
</div>
</div>
)
}

View File

@@ -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 <div>Redirecting...</div>
}
return <VerificationFlow flowId={flowId} />
}
function VerificationFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('verification', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading verification flow: {String(error)}</div>
if (!flow) return null
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem' }}>Email verification</h2>
<FlowForm ui={flow.ui} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/login">Back to sign in</a>
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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<string | null>(null)
const { data: messages, isLoading, error } = useCourierMessages({
page_size: 50,
status: statusFilter || undefined,
})
const { data: detail } = useCourierMessage(selectedId ?? '')
if (isLoading) return <div>Loading courier messages...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
return (
<div>
<h1>Courier Messages</h1>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
{['', 'queued', 'sent', 'processing', 'abandoned'].map((s) => (
<Button
key={s}
size="small"
color={statusFilter === s ? 'brand' : 'neutral'}
onClick={() => setStatusFilter(s)}
>
{s || 'All'}
</Button>
))}
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<table>
<thead>
<tr>
<th>Recipient</th>
<th>Subject</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{(messages ?? []).map((msg) => (
<tr
key={msg.id}
style={{
cursor: 'pointer',
backgroundColor: selectedId === msg.id ? 'var(--c--theme--colors--primary-50)' : undefined,
}}
onClick={() => setSelectedId(msg.id)}
>
<td>{msg.recipient}</td>
<td>{msg.subject}</td>
<td>{msg.type}</td>
<td>
<span style={{ color: STATUS_COLORS[msg.status] ?? 'var(--sunbeam--text-secondary)' }}>
{msg.status}
</span>
</td>
<td>{new Date(msg.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
{(messages ?? []).length === 0 && (
<p style={{ color: 'var(--sunbeam--text-secondary)', textAlign: 'center', marginTop: '2rem' }}>No messages found.</p>
)}
</div>
{selectedId && detail && (
<div style={{
width: 400,
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
padding: '1rem',
}}>
<h3 style={{ marginTop: 0 }}>Message Detail</h3>
<table>
<tbody>
<tr>
<td style={{ fontWeight: 600 }}>ID</td>
<td><code>{detail.id}</code></td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Recipient</td>
<td>{detail.recipient}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Subject</td>
<td>{detail.subject}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Type</td>
<td>{detail.type}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Status</td>
<td>
<span style={{ color: STATUS_COLORS[detail.status] ?? 'var(--sunbeam--text-secondary)' }}>
{detail.status}
</span>
</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Created</td>
<td>{new Date(detail.created_at).toLocaleString()}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Updated</td>
<td>{new Date(detail.updated_at).toLocaleString()}</td>
</tr>
</tbody>
</table>
{detail.body && (
<>
<h4>Body</h4>
<pre style={{ maxHeight: 300 }}>{detail.body}</pre>
</>
)}
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={() => setSelectedId(null)}>Close</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -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<string | null>(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 <div>Loading schemas...</div>
return (
<div>
<h1>Create Identity</h1>
<div style={{ marginBottom: '1rem' }}>
<Select
label="Schema"
options={schemaOptions}
value={selectedSchema}
onChange={(e) => setSelectedSchema(e.target.value as string)}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<Select
label="State"
options={stateOptions}
value={state}
onChange={(e) => setState(e.target.value as 'active' | 'inactive')}
/>
</div>
{error && (
<div style={{ color: 'var(--c--theme--colors--danger-500)', marginBottom: '1rem' }}>{error}</div>
)}
{traitsSchema ? (
<SchemaForm
schema={traitsSchema}
onSubmit={handleSubmit}
disabled={createIdentity.isPending}
>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<Button type="submit" color="brand" disabled={createIdentity.isPending}>
{createIdentity.isPending ? 'Creating...' : 'Create Identity'}
</Button>
<Button type="button" color="neutral" onClick={() => navigate('/identities')}>Cancel</Button>
</div>
</SchemaForm>
) : (
selectedSchema && <div>Schema has no traits definition.</div>
)}
</div>
)
}

View File

@@ -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 <div>Loading identity...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
if (!identity) return <div>Identity not found.</div>
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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h1>Identity Detail</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Link to={`/identities/${identity.id}/edit`}>
<Button color="neutral" size="small">Edit</Button>
</Link>
<Button color="neutral" size="small" onClick={handleRecoveryLink}>Recovery Link</Button>
<Button color="neutral" size="small" onClick={handleRecoveryCode}>Recovery Code</Button>
<Button color="neutral" size="small" onClick={() => deleteAllSessions.mutateAsync(identity.id)}>Revoke All Sessions</Button>
<Button color="error" size="small" onClick={() => setConfirmDelete(true)}>Delete</Button>
</div>
</div>
{recoveryResult && (
<div style={{
background: 'var(--c--theme--colors--info-50)',
border: '1px solid var(--c--theme--colors--info-300)',
borderRadius: 8,
padding: '1rem',
marginBottom: '1rem',
}}>
<strong>Recovery:</strong>
{recoveryResult.code && <div>Code: <code>{recoveryResult.code}</code></div>}
{recoveryResult.link && <div>Link: <a href={recoveryResult.link} target="_blank" rel="noreferrer">{recoveryResult.link}</a></div>}
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={() => setRecoveryResult(null)}>Close</Button>
</div>
</div>
)}
<table style={{ marginBottom: '1.5rem' }}>
<tbody>
<tr>
<td style={{ fontWeight: 600 }}>ID</td>
<td><code>{identity.id}</code></td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Schema</td>
<td>{identity.schema_id}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>State</td>
<td>
<span style={{ color: identity.state === 'active' ? 'var(--c--theme--colors--success-600)' : 'var(--sunbeam--text-muted)' }}>
{identity.state}
</span>
</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Created</td>
<td>{new Date(identity.created_at).toLocaleString()}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Updated</td>
<td>{new Date(identity.updated_at).toLocaleString()}</td>
</tr>
</tbody>
</table>
<h2>Traits</h2>
<pre>{JSON.stringify(identity.traits, null, 2)}</pre>
{identity.metadata_public && (
<>
<h2>Public Metadata</h2>
<pre>{JSON.stringify(identity.metadata_public, null, 2)}</pre>
</>
)}
{identity.metadata_admin && (
<>
<h2>Admin Metadata</h2>
<pre>{JSON.stringify(identity.metadata_admin, null, 2)}</pre>
</>
)}
{identity.credentials && (
<>
<h2>Credentials</h2>
<table>
<thead>
<tr>
<th>Type</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{Object.entries(identity.credentials).map(([key, cred]) => (
<tr key={key}>
<td>{cred.type}</td>
<td>{new Date(cred.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</>
)}
<h2>Sessions</h2>
{sessions && sessions.length > 0 ? (
<table>
<thead>
<tr>
<th>ID</th>
<th>Active</th>
<th>AAL</th>
<th>Authenticated</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.id}>
<td><code>{s.id.slice(0, 8)}...</code></td>
<td>{s.active ? 'Yes' : 'No'}</td>
<td>{s.authenticator_assurance_level}</td>
<td>{new Date(s.authenticated_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ color: 'var(--sunbeam--text-secondary)' }}>No active sessions.</p>
)}
<ConfirmModal
isOpen={confirmDelete}
title="Delete Identity"
message="Are you sure you want to delete this identity? This cannot be undone."
confirmLabel="Delete"
confirmColor="error"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
)
}

View File

@@ -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<string | null>(null)
const [state, setState] = useState<string | null>(null)
if (isLoading) return <div>Loading identity...</div>
if (!identity) return <div>Identity not found.</div>
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<string, unknown>,
state: currentState as 'active' | 'inactive',
})
navigate(`/identities/${id}`)
} catch (e) {
setError(String(e))
}
}
return (
<div>
<h1>Edit Identity</h1>
<p style={{ color: 'var(--sunbeam--text-secondary)', marginBottom: '1rem' }}>
Schema: {identity.schema_id} | ID: <code>{identity.id}</code>
</p>
<div style={{ marginBottom: '1rem' }}>
<Select
label="State"
options={stateOptions}
value={currentState}
onChange={(e) => setState(e.target.value as string)}
/>
</div>
{error && (
<div style={{ color: 'var(--c--theme--colors--danger-500)', marginBottom: '1rem' }}>{error}</div>
)}
{traitsSchema ? (
<SchemaForm
schema={traitsSchema}
formData={identity.traits}
onSubmit={handleSubmit}
disabled={updateIdentity.isPending}
>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<Button type="submit" color="brand" disabled={updateIdentity.isPending}>
{updateIdentity.isPending ? 'Saving...' : 'Save Changes'}
</Button>
<Button type="button" color="neutral" onClick={() => navigate(`/identities/${id}`)}>Cancel</Button>
</div>
</SchemaForm>
) : (
<div>
<h2>Traits (raw JSON)</h2>
<pre>{JSON.stringify(identity.traits, null, 2)}</pre>
<p style={{ color: 'var(--sunbeam--text-secondary)' }}>Schema not available for form editing.</p>
<Button color="neutral" onClick={() => navigate(`/identities/${id}`)}>Back</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,171 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Button, Input, Checkbox } from '@gouvfr-lasuite/cunningham-react'
import { useIdentities, useDeleteIdentity, useGenerateRecoveryLink, useGenerateRecoveryCode, useDeleteAllIdentitySessions } from '../../api/identities'
import ConfirmModal from '../../components/ConfirmModal'
export default function IdentitiesPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [recoveryResult, setRecoveryResult] = useState<{ link?: string; code?: string } | null>(null)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
const { data: identities, isLoading, error } = useIdentities(
debouncedSearch ? { credentials_identifier: debouncedSearch } : { page_size: 50 }
)
const deleteIdentity = useDeleteIdentity()
const generateLink = useGenerateRecoveryLink()
const generateCode = useGenerateRecoveryCode()
const deleteAllSessions = useDeleteAllIdentitySessions()
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
clearTimeout((window as unknown as { _searchTimer?: number })._searchTimer)
;(window as unknown as { _searchTimer?: number })._searchTimer = window.setTimeout(
() => setDebouncedSearch(e.target.value),
300
)
}
const toggleSelect = (id: string) => {
const next = new Set(selected)
next.has(id) ? next.delete(id) : next.add(id)
setSelected(next)
}
const handleBulkDelete = async () => {
for (const id of selected) await deleteIdentity.mutateAsync(id)
setSelected(new Set())
setConfirmBulkDelete(false)
}
const handleRecoveryLink = async (id: string) => {
const r = await generateLink.mutateAsync({ identity_id: id })
setRecoveryResult({ link: r.recovery_link })
}
const handleRecoveryCode = async (id: string) => {
const r = await generateCode.mutateAsync({ identity_id: id })
setRecoveryResult({ link: r.recovery_link, code: r.recovery_code })
}
if (isLoading) return <div>Loading identities...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h1>Identities</h1>
<Link to="/identities/create">
<Button color="brand" size="small">+ Create Identity</Button>
</Link>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<Input
label="Search by email"
value={search}
onChange={handleSearch}
/>
</div>
{selected.size > 0 && (
<Button color="error" size="small" onClick={() => setConfirmBulkDelete(true)}>
Delete {selected.size} selected
</Button>
)}
</div>
{recoveryResult && (
<div style={{
background: 'var(--c--theme--colors--info-50)',
border: '1px solid var(--c--theme--colors--info-300)',
borderRadius: 8,
padding: '1rem',
marginBottom: '1rem',
}}>
<strong>Recovery:</strong>
{recoveryResult.code && <div>Code: <code>{recoveryResult.code}</code></div>}
{recoveryResult.link && <div>Link: <a href={recoveryResult.link} target="_blank" rel="noreferrer">{recoveryResult.link}</a></div>}
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={() => setRecoveryResult(null)}>Close</Button>
</div>
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '2rem' }}></th>
<th>Email</th>
<th>Schema</th>
<th>State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(identities ?? []).map((identity) => {
const email = (identity.traits as { email?: string })?.email ?? identity.id.slice(0, 8)
return (
<tr key={identity.id}>
<td>
<Checkbox
aria-label="Select identity"
checked={selected.has(identity.id)}
onChange={() => toggleSelect(identity.id)}
/>
</td>
<td>
<Link to={`/identities/${identity.id}`}>{email}</Link>
</td>
<td>{identity.schema_id}</td>
<td>
<span style={{ color: identity.state === 'active' ? 'var(--c--theme--colors--success-600)' : 'var(--sunbeam--text-muted)' }}>
{identity.state}
</span>
</td>
<td style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
<Link to={`/identities/${identity.id}`}>
<Button color="neutral" size="small">View</Button>
</Link>
<Link to={`/identities/${identity.id}/edit`}>
<Button color="neutral" size="small">Edit</Button>
</Link>
<Button color="neutral" size="small" onClick={() => handleRecoveryLink(identity.id)}>Link</Button>
<Button color="neutral" size="small" onClick={() => handleRecoveryCode(identity.id)}>Code</Button>
<Button color="neutral" size="small" onClick={() => deleteAllSessions.mutateAsync(identity.id)}>Revoke Sessions</Button>
<Button color="error" size="small" onClick={() => setConfirmDelete(identity.id)}>Delete</Button>
</td>
</tr>
)
})}
</tbody>
</table>
<ConfirmModal
isOpen={!!confirmDelete}
title="Delete Identity"
message="Are you sure you want to delete this identity? This cannot be undone."
confirmLabel="Delete"
confirmColor="error"
onConfirm={async () => {
if (confirmDelete) await deleteIdentity.mutateAsync(confirmDelete)
setConfirmDelete(null)
}}
onCancel={() => setConfirmDelete(null)}
/>
<ConfirmModal
isOpen={confirmBulkDelete}
title="Bulk Delete"
message={`Delete ${selected.size} identities? This cannot be undone.`}
confirmLabel="Delete All"
confirmColor="error"
onConfirm={handleBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
</div>
)
}

View File

@@ -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 <div>Loading schemas...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
const schemaTitle = (schema as Record<string, unknown>)?.title as string | undefined
return (
<div>
<h1 style={{ marginTop: 0 }}>Identity Schemas</h1>
<div style={{ display: 'flex', gap: '1.5rem' }}>
<div style={{ width: 200, flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{(schemas ?? []).map((s) => (
<Button
key={s.id}
size="small"
color={selectedId === s.id ? 'brand' : 'neutral'}
onClick={() => setSelectedId(s.id)}
fullWidth
>
{s.id}
</Button>
))}
</div>
{(schemas ?? []).length === 0 && (
<p style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>No schemas found.</p>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{schema ? (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.125rem' }}>{selectedId}</h2>
{schemaTitle && (
<div style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.8125rem', marginTop: 2 }}>
{schemaTitle}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
size="small"
color={viewMode === 'json' ? 'brand' : 'neutral'}
onClick={() => setViewMode('json')}
>
JSON
</Button>
<Button
size="small"
color={viewMode === 'preview' ? 'brand' : 'neutral'}
onClick={() => setViewMode('preview')}
>
Form Preview
</Button>
</div>
</div>
{viewMode === 'json' ? (
<pre style={{ maxHeight: 600 }}>
{JSON.stringify(schema, null, 2)}
</pre>
) : (
<div style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
padding: '1rem',
}}>
<p style={{ color: 'var(--sunbeam--text-secondary)', marginBottom: '1rem', fontSize: '0.875rem' }}>
Preview of the form generated from this schema. Submit is disabled.
</p>
<SchemaForm
schema={schema as RJSFSchema}
onSubmit={() => {}}
disabled
>
<Button type="submit" color="brand" size="small" disabled style={{ marginTop: '1rem' }}>
Submit (disabled)
</Button>
</SchemaForm>
</div>
)}
</>
) : (
<p style={{ color: 'var(--sunbeam--text-secondary)', marginTop: '2rem' }}>
Select a schema to view its details.
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -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<boolean | undefined>(undefined)
const { data: sessions, isLoading, error } = useSessions({
page_size: 50,
active: activeFilter,
})
const revokeSession = useRevokeSession()
const extendSession = useExtendSession()
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null)
if (isLoading) return <div>Loading sessions...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
return (
<div>
<h1>Sessions</h1>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<Button
size="small"
color={activeFilter === undefined ? 'brand' : 'neutral'}
onClick={() => setActiveFilter(undefined)}
>
All
</Button>
<Button
size="small"
color={activeFilter === true ? 'brand' : 'neutral'}
onClick={() => setActiveFilter(true)}
>
Active
</Button>
<Button
size="small"
color={activeFilter === false ? 'brand' : 'neutral'}
onClick={() => setActiveFilter(false)}
>
Inactive
</Button>
</div>
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Identity</th>
<th>Active</th>
<th>AAL</th>
<th>Authenticated</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(sessions ?? []).map((session) => (
<tr key={session.id}>
<td><code>{session.id.slice(0, 8)}...</code></td>
<td>
{session.identity_id ? (
<a href={`/identities/${session.identity_id}`}>
<code>{session.identity_id.slice(0, 8)}...</code>
</a>
) : (
'-'
)}
</td>
<td>
<span style={{ color: session.active ? 'var(--c--theme--colors--success-600)' : 'var(--sunbeam--text-muted)' }}>
{session.active ? 'Yes' : 'No'}
</span>
</td>
<td>{session.authenticator_assurance_level}</td>
<td>{new Date(session.authenticated_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td style={{ display: 'flex', gap: '0.25rem' }}>
<Button
size="small"
color="neutral"
onClick={() => extendSession.mutate(session.id)}
disabled={!session.active}
>
Extend
</Button>
<Button
size="small"
color="error"
onClick={() => setConfirmRevoke(session.id)}
disabled={!session.active}
>
Revoke
</Button>
</td>
</tr>
))}
</tbody>
</table>
{(sessions ?? []).length === 0 && (
<p style={{ color: 'var(--sunbeam--text-secondary)', textAlign: 'center', marginTop: '2rem' }}>No sessions found.</p>
)}
<ConfirmModal
isOpen={!!confirmRevoke}
title="Revoke Session"
message="Are you sure you want to revoke this session? The user will be logged out."
confirmLabel="Revoke"
confirmColor="error"
onConfirm={async () => {
if (confirmRevoke) await revokeSession.mutateAsync(confirmRevoke)
setConfirmRevoke(null)
}}
onCancel={() => setConfirmRevoke(null)}
/>
</div>
)
}

View File

@@ -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 <div>Loading...</div>
const identity = session.identity
const traits = (identity?.traits ?? {}) as Record<string, string>
const isEmployee = identity?.schema_id === 'employee'
return (
<div style={{ maxWidth: '640px' }}>
<h1 style={{ marginTop: 0 }}>Profile</h1>
<AvatarUpload
identityId={identity.id}
picture={traits.picture}
name={traits.given_name ?? traits.email}
onUploaded={fetchSession}
/>
<ProfileForm traits={traits} isEmployee={isEmployee} />
</div>
)
}
function ProfileForm({
traits,
isEmployee,
}: {
traits: Record<string, string>
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 (
<div style={{ marginTop: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: message.type === 'success' ? 'var(--c--theme--colors--success-50)' : 'var(--c--theme--colors--danger-50)',
border: `1px solid ${message.type === 'success' ? 'var(--c--theme--colors--success-200)' : 'var(--c--theme--colors--danger-200)'}`,
color: message.type === 'success' ? 'var(--c--theme--colors--success-800)' : 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Input
label="Email"
value={values.email ?? ''}
disabled
type="email"
fullWidth
/>
<div style={{ display: 'flex', gap: '1rem' }}>
<Input
label="First name"
value={values.given_name ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('given_name', e.target.value)}
fullWidth
/>
<Input
label="Last name"
value={values.family_name ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('family_name', e.target.value)}
fullWidth
/>
</div>
{isEmployee && (
<Input
label="Middle name"
value={values.middle_name ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('middle_name', e.target.value)}
fullWidth
/>
)}
<Input
label="Nickname"
value={values.nickname ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('nickname', e.target.value)}
fullWidth
/>
{isEmployee && (
<Input
label="Phone number"
value={values.phone_number ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('phone_number', e.target.value)}
fullWidth
/>
)}
<div>
<Button color="brand" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save profile'}
</Button>
</div>
{isEmployee && (
<>
<div style={{
marginTop: '1rem',
paddingTop: '1rem',
borderTop: '1px solid var(--sunbeam--border)',
}}>
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.9375rem' }}>Organization</h3>
<p style={{ margin: '0 0 1rem', color: 'var(--sunbeam--text-secondary)', fontSize: '0.8125rem' }}>
These fields are managed by an administrator.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem' }}>
<Input label="Job title" value={traits.job_title ?? ''} disabled fullWidth />
<Input label="Department" value={traits.department ?? ''} disabled fullWidth />
</div>
<Input label="Office location" value={traits.office_location ?? ''} disabled fullWidth />
<div style={{ display: 'flex', gap: '1rem' }}>
<Input label="Employee ID" value={traits.employee_id ?? ''} disabled fullWidth />
<Input label="Hire date" value={traits.hire_date ?? ''} disabled fullWidth />
</div>
<Input label="Manager" value={traits.manager ?? ''} disabled fullWidth />
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -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 <div>Redirecting to account setup...</div>
}
// If redirected back from Kratos with a flow param, show the result
if (flowId) {
return (
<div style={{ maxWidth: '640px' }}>
<h1 style={{ marginTop: 0 }}>Security</h1>
<SettingsFlow flowId={flowId} exclude={['profile']} />
</div>
)
}
return (
<div style={{ maxWidth: '640px' }}>
<h1 style={{ marginTop: 0 }}>Security</h1>
{needs2faSetup && (
<div style={{
padding: '1rem',
borderRadius: 8,
marginBottom: '1.5rem',
backgroundColor: 'var(--c--theme--colors--warning-100, #FFF3CD)',
border: '1px solid var(--c--theme--colors--warning-300, #FFDA6A)',
color: 'var(--c--theme--colors--warning-900, #664D03)',
fontSize: '0.875rem',
fontWeight: 500,
}}>
You must set up two-factor authentication before you can use this app.
Configure an authenticator app or security key below.
</div>
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.125rem' }}>Password</h2>
<PasswordSection />
</section>
<section>
<h2 style={{ fontSize: '1.125rem' }}>Two-Factor Authentication</h2>
<p style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
Add a second layer of security to your account using an authenticator app, security key, or backup codes.
</p>
<MfaSection method="totp" title="Authenticator App" description="Use an app like Google Authenticator or 1Password to generate one-time codes." />
<MfaSection method="webauthn" title="Security Key" description="Use a hardware security key, fingerprint, or Face ID." />
<MfaSection method="lookup_secret" title="Backup Codes" description="Generate one-time recovery codes in case you lose access to your other methods." />
</section>
</div>
)
}
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 && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
marginBottom: '0.75rem',
backgroundColor: message.type === 'success' ? 'var(--c--theme--colors--success-50)' : 'var(--c--theme--colors--danger-50)',
border: `1px solid ${message.type === 'success' ? 'var(--c--theme--colors--success-200)' : 'var(--c--theme--colors--danger-200)'}`,
color: message.type === 'success' ? 'var(--c--theme--colors--success-800)' : 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Button color="brand" onClick={() => setExpanded(true)}>
Change password
</Button>
</>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: message.type === 'success' ? 'var(--c--theme--colors--success-50)' : 'var(--c--theme--colors--danger-50)',
border: `1px solid ${message.type === 'success' ? 'var(--c--theme--colors--success-200)' : 'var(--c--theme--colors--danger-200)'}`,
color: message.type === 'success' ? 'var(--c--theme--colors--success-800)' : 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Input
label="New password"
type="password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
fullWidth
/>
<Input
label="Confirm password"
type="password"
value={confirm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirm(e.target.value)}
state={mismatch ? 'error' : undefined}
text={mismatch ? 'Passwords do not match' : undefined}
fullWidth
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button color="brand" onClick={handleSubmit} disabled={saving || !password || mismatch}>
{saving ? 'Saving...' : 'Update password'}
</Button>
<Button color="neutral" onClick={() => { setExpanded(false); setPassword(''); setConfirm('') }}>
Cancel
</Button>
</div>
</div>
)
}
function MfaSection({ method, title, description }: { method: string; title: string; description: string }) {
const [flowId, setFlowId] = useState<string | null>(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 (
<div style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
padding: '1rem',
marginBottom: '0.75rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9375rem' }}>{title}</div>
<div style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.8125rem', marginTop: 2 }}>
{description}
</div>
</div>
{!expanded && (
<Button color="brand" size="small" onClick={startFlow} disabled={loading}>
{loading ? 'Loading...' : 'Configure'}
</Button>
)}
</div>
{expanded && flowId && (
<div style={{ marginTop: '1rem', borderTop: '1px solid var(--sunbeam--border)', paddingTop: '1rem' }}>
<SettingsFlow flowId={flowId} only={method} />
</div>
)}
</div>
)
}
function SettingsFlow({ flowId, only, exclude }: { flowId: string; only?: string; exclude?: string[] }) {
const { data: flow, isLoading, error } = useFlow('settings', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
if (!flow) return null
return <FlowForm ui={flow.ui} only={only} exclude={exclude} />
}

105
ui/src/stores/session.ts Normal file
View File

@@ -0,0 +1,105 @@
import { create } from 'zustand'
interface Identity {
id: string
schema_id: string
traits: Record<string, unknown>
state: string
metadata_public?: Record<string, unknown>
}
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<void>
logout: () => Promise<void>
}
export const useSessionStore = create<SessionState>((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'
},
}))

22
ui/tsconfig.json Normal file
View File

@@ -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"]
}

14
ui/vite.config.ts Normal file
View File

@@ -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',
},
})