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:
29
.dockerignore
Normal file
29
.dockerignore
Normal 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
25
.gitignore
vendored
Normal 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
26
Dockerfile
Normal 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
15
deno.json
Normal 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
404
deno.lock
generated
Normal 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
8
dev.toml
Normal 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
79
main.ts
Normal 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
289
server/auth.ts
Normal 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
90
server/csrf.ts
Normal 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
77
server/flow.ts
Normal 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
244
server/hydra.ts
Normal 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
66
server/proxy.ts
Normal 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
283
server/s3.ts
Normal 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
10
ui/cunningham.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
themes: {
|
||||
default: {},
|
||||
dark: {},
|
||||
"dsfr-light": {},
|
||||
"dsfr-dark": {},
|
||||
"anct-light": {},
|
||||
"anct-dark": {},
|
||||
},
|
||||
};
|
||||
16
ui/index.html
Normal file
16
ui/index.html
Normal 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
30
ui/package.json
Normal 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
13
ui/public/favicon.svg
Normal 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
72
ui/src/App.tsx
Normal 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
41
ui/src/api/avatar.ts
Normal 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
25
ui/src/api/client.ts
Normal 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
32
ui/src/api/courier.ts
Normal 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
58
ui/src/api/flows.ts
Normal 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
75
ui/src/api/hydra.ts
Normal 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
100
ui/src/api/identities.ts
Normal 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
23
ui/src/api/schemas.ts
Normal 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
6
ui/src/api/session.ts
Normal 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
37
ui/src/api/sessions.ts
Normal 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'] }),
|
||||
})
|
||||
}
|
||||
50
ui/src/components/Avatar.tsx
Normal file
50
ui/src/components/Avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
ui/src/components/AvatarUpload.tsx
Normal file
108
ui/src/components/AvatarUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
ui/src/components/ConfirmModal.tsx
Normal file
60
ui/src/components/ConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
ui/src/components/DashboardNav.tsx
Normal file
126
ui/src/components/DashboardNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
ui/src/components/FlowNodes/FlowForm.tsx
Normal file
103
ui/src/components/FlowNodes/FlowForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
ui/src/components/FlowNodes/NodeAnchor.tsx
Normal file
19
ui/src/components/FlowNodes/NodeAnchor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
ui/src/components/FlowNodes/NodeImage.tsx
Normal file
23
ui/src/components/FlowNodes/NodeImage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
ui/src/components/FlowNodes/NodeInput.tsx
Normal file
124
ui/src/components/FlowNodes/NodeInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
ui/src/components/FlowNodes/NodeScript.tsx
Normal file
39
ui/src/components/FlowNodes/NodeScript.tsx
Normal 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} />
|
||||
}
|
||||
27
ui/src/components/FlowNodes/NodeText.tsx
Normal file
27
ui/src/components/FlowNodes/NodeText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
ui/src/components/SchemaForm/index.tsx
Normal file
44
ui/src/components/SchemaForm/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
ui/src/components/SchemaForm/templates.tsx
Normal file
32
ui/src/components/SchemaForm/templates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
ui/src/components/SchemaForm/widgets.tsx
Normal file
86
ui/src/components/SchemaForm/widgets.tsx
Normal 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,
|
||||
}
|
||||
93
ui/src/components/WaffleButton.tsx
Normal file
93
ui/src/components/WaffleButton.tsx
Normal 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[]
|
||||
}
|
||||
}
|
||||
37
ui/src/cunningham/useCunninghamTheme.tsx
Normal file
37
ui/src/cunningham/useCunninghamTheme.tsx
Normal 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 })
|
||||
},
|
||||
}))
|
||||
35
ui/src/layouts/AuthLayout.tsx
Normal file
35
ui/src/layouts/AuthLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
ui/src/layouts/DashboardLayout.tsx
Normal file
89
ui/src/layouts/DashboardLayout.tsx
Normal 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
16
ui/src/main.tsx
Normal 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>,
|
||||
)
|
||||
156
ui/src/pages/auth/ConsentPage.tsx
Normal file
156
ui/src/pages/auth/ConsentPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
ui/src/pages/auth/ErrorPage.tsx
Normal file
41
ui/src/pages/auth/ErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
ui/src/pages/auth/LoginPage.tsx
Normal file
88
ui/src/pages/auth/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
ui/src/pages/auth/LogoutPage.tsx
Normal file
65
ui/src/pages/auth/LogoutPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
311
ui/src/pages/auth/OnboardingWizard.tsx
Normal file
311
ui/src/pages/auth/OnboardingWizard.tsx
Normal 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' }}>✓</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>
|
||||
)
|
||||
}
|
||||
33
ui/src/pages/auth/RecoveryPage.tsx
Normal file
33
ui/src/pages/auth/RecoveryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
ui/src/pages/auth/RegistrationPage.tsx
Normal file
34
ui/src/pages/auth/RegistrationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
ui/src/pages/auth/VerificationPage.tsx
Normal file
33
ui/src/pages/auth/VerificationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
ui/src/pages/courier/index.tsx
Normal file
141
ui/src/pages/courier/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
ui/src/pages/identities/create.tsx
Normal file
86
ui/src/pages/identities/create.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
ui/src/pages/identities/detail.tsx
Normal file
177
ui/src/pages/identities/detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
ui/src/pages/identities/edit.tsx
Normal file
87
ui/src/pages/identities/edit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
ui/src/pages/identities/index.tsx
Normal file
171
ui/src/pages/identities/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
ui/src/pages/schemas/index.tsx
Normal file
106
ui/src/pages/schemas/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
ui/src/pages/sessions/index.tsx
Normal file
121
ui/src/pages/sessions/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
ui/src/pages/settings/profile.tsx
Normal file
200
ui/src/pages/settings/profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
250
ui/src/pages/settings/security.tsx
Normal file
250
ui/src/pages/settings/security.tsx
Normal 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
105
ui/src/stores/session.ts
Normal 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
22
ui/tsconfig.json
Normal 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
14
ui/vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user