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

{title}

+

{message}

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

{description}

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

{title}

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

Sunbeam Studios

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

Missing consent challenge.

+ } + + if (isLoading) return

Loading...

+ if (error) return

Error: {String(error)}

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

Redirecting...

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

Redirecting...

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

+ Authorize {clientName} +

+

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

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

Error

+

An unknown error occurred.

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

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

+

{message}

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

+ Additional setup required +

+ +

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

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

Sign in

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

Sign out

+

Do you want to sign out?

+ {logoutReq?.subject && ( +

+ Signed in as: {logoutReq.subject} +

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

+ Account setup +

+

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

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

+ Choose a strong password for your account. +

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

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

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

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

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

+ You're all set! +

+

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

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

Account recovery

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

Create account

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

Email verification

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

Courier Messages

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

No messages found.

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

Message Detail

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

Body

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

Create Identity

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

Identity Detail

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

Traits

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

Public Metadata

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

Admin Metadata

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

Credentials

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

Sessions

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

No active sessions.

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

Edit Identity

+

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

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

Identity Schemas

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

No schemas found.

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

{selectedId}

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

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

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

+ Select a schema to view its details. +

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

Sessions

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

No sessions found.

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

Profile

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

Organization

+

+ These fields are managed by an administrator. +

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

Security

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

Security

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

Password

+ +
+ +
+

Two-Factor Authentication

+

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

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