diff --git a/web/.storybook/main.js b/web/.storybook/main.js index d26966346a..b7789ecc8b 100644 --- a/web/.storybook/main.js +++ b/web/.storybook/main.js @@ -3,6 +3,13 @@ * @import { StorybookConfig } from "@storybook/web-components-vite"; */ +/** + * @param {TemplateStringsArray} strings + * @param {...any} values + * @returns {string} + */ +const html = (strings, ...values) => String.raw({ raw: strings }, ...values); + /** * @satisfies {StorybookConfig} */ @@ -18,6 +25,27 @@ const config = { "@storybook/addon-docs", ], framework: "@storybook/web-components-vite", + viteFinal: async (config) => { + return { + ...config, + define: { + ...config.define, + "import.meta.env.AK_BUNDLER": JSON.stringify("storybook"), + }, + resolve: { + ...config.resolve, + // Avoid multiple instances of web components packages. + conditions: [], + }, + }; + }, + + previewBody: (body) => html` + + + + ${body} + `, }; export default config; diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js index 6d7a3eb91d..a9a788a228 100644 --- a/web/.storybook/preview.js +++ b/web/.storybook/preview.js @@ -5,6 +5,7 @@ */ import "#styles/authentik/interface.global.css"; +import "#styles/authentik/static.global.css"; import "#styles/authentik/storybook.css"; import { ThemedDocsContainer } from "./DocsContainer.tsx"; diff --git a/web/bundler/utils/node.js b/web/bundler/utils/node.js index 9d4fbe1494..6be336eeec 100644 --- a/web/bundler/utils/node.js +++ b/web/bundler/utils/node.js @@ -27,6 +27,7 @@ export function createBundleDefinitions() { AK_DOCS_RELEASE_NOTES_URL: ReleaseNotesURL.href, AK_DOCS_PRE_RELEASE_URL: PreReleaseDocsURL.href, AK_API_BASE_PATH: process.env.AK_API_BASE_PATH ?? "", + AK_BUNDLER: JSON.stringify(process.env.AK_BUNDLER ?? "authentik"), }; return { diff --git a/web/package-lock.json b/web/package-lock.json index 985cb9d0c6..5259b95c06 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -103,7 +103,7 @@ "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.2.0", - "storybook": "^10.0.8", + "storybook": "^10.2.1", "style-mod": "^4.1.3", "trusted-types": "^2.0.0", "ts-pattern": "^5.9.0", @@ -188,7 +188,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2610,6 +2609,7 @@ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, @@ -3879,6 +3879,18 @@ } } }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", + "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + } + }, "node_modules/@swagger-api/apidom-reference": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.1.tgz", @@ -4017,7 +4029,6 @@ "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -4347,7 +4358,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -4722,7 +4734,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4741,7 +4752,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4831,7 +4841,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -5537,7 +5546,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6080,7 +6088,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6369,7 +6376,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -6401,7 +6407,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -6672,7 +6677,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -7067,7 +7071,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7228,7 +7231,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -7496,6 +7498,7 @@ "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20" }, @@ -7508,6 +7511,7 @@ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz", "integrity": "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==", "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -7558,7 +7562,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.3.1", @@ -7853,7 +7858,6 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7947,7 +7951,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9239,6 +9242,7 @@ "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz", "integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/fisker/git-hooks-list?sponsor=1" } @@ -10848,7 +10852,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -11111,6 +11114,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13554,7 +13558,6 @@ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.58.0" }, @@ -13656,7 +13659,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13691,6 +13693,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13705,6 +13708,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13716,7 +13720,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prismjs": { "version": "1.30.0", @@ -13924,7 +13929,6 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -14004,7 +14008,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14014,7 +14017,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14551,7 +14553,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15152,13 +15153,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.0.1.tgz", "integrity": "sha512-R89fO+z3x7hiKPXX5P0qim+ge6Y60AjtlW+QQpRozrrNcR1lw9Pkpm5MLB56HoNvdcLHL4wbpq16OcvGpEDJIg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/sort-package-json": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.5.0.tgz", "integrity": "sha512-moY4UtptUuP5sPuu9H9dp8xHNel7eP5/Kz/7+90jTvC0IOiPH2LigtRM/aSFSxreaWoToHUVUpEV4a2tAs2oKQ==", "license": "MIT", + "peer": true, "dependencies": { "detect-indent": "^7.0.1", "detect-newline": "^4.0.1", @@ -15293,7 +15296,6 @@ "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.1.tgz", "integrity": "sha512-hgiiwT4ZWJ/yrRpoXnHpCzWOsUvLUwQqgM/ws6mCIDsKJ7Gc7irL6DjWpi8G7l1Uq5VXYsQjXQo5ydb8Pyajdg==", "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -15694,6 +15696,7 @@ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", "license": "MIT", + "peer": true, "dependencies": { "@pkgr/core": "^0.2.9" }, @@ -15899,6 +15902,18 @@ "node": ">=6" } }, + "node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, "node_modules/tree-sitter-json": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", @@ -16141,7 +16156,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16155,7 +16169,6 @@ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", @@ -16574,7 +16587,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16663,7 +16675,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -17356,7 +17367,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 84b97df7da..e45008760d 100644 --- a/web/package.json +++ b/web/package.json @@ -178,7 +178,7 @@ "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.2.0", - "storybook": "^10.0.8", + "storybook": "^10.2.1", "style-mod": "^4.1.3", "trusted-types": "^2.0.0", "ts-pattern": "^5.9.0", diff --git a/web/src/common/api/middleware.ts b/web/src/common/api/middleware.ts index 848b4a7c5e..c0cfc7e875 100644 --- a/web/src/common/api/middleware.ts +++ b/web/src/common/api/middleware.ts @@ -26,8 +26,9 @@ export class LoggingMiddleware implements Middleware { constructor(brand: CurrentBrand) { const prefix = - brand.matchedDomain === "authentik-default" ? "api" : `api/${brand.matchedDomain}`; - + brand.matchedDomain && brand.matchedDomain !== "authentik-default" + ? `api/${brand.matchedDomain}` + : "api"; this.#logger = ConsoleLogger.prefix(prefix); } diff --git a/web/src/common/theme.ts b/web/src/common/theme.ts index 4933ece894..30b6962b05 100644 --- a/web/src/common/theme.ts +++ b/web/src/common/theme.ts @@ -337,6 +337,11 @@ function pluckCurrentBackgroundURL( return null; } +export interface BackgroundImageInit { + baseOrigin?: string; + target?: HTMLElement | null; +} + /** * Applies the given background image URL to the document body. * @@ -344,22 +349,26 @@ function pluckCurrentBackgroundURL( */ export function applyBackgroundImageProperty( value?: string | null, - baseOrigin = window.location.origin, + init?: BackgroundImageInit, ): void { + const baseOrigin = init?.baseOrigin ?? window.location.origin; + if (!value || !URL.canParse(value, baseOrigin)) { return; } + const target = init?.target ?? document.body; + const nextURL = new URL(value, baseOrigin); - const { backgroundImage } = getComputedStyle(document.body, "::before"); + const { backgroundImage } = getComputedStyle(target, "::before"); const currentURL = pluckCurrentBackgroundURL(backgroundImage, baseOrigin); if (currentURL?.href === nextURL.href) { return; } - document.body.style.setProperty(AKBackgroundImageProperty, `url("${nextURL.href}")`); + target.style.setProperty(AKBackgroundImageProperty, `url("${nextURL.href}")`); } /** diff --git a/web/src/common/types.ts b/web/src/common/types.ts index 25b8c39ae4..95a6559f34 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -2,6 +2,15 @@ * @file Common utility types. */ +/** + * Type utility to make all properties in T recursively optional. + */ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + /** * Type utility to make readonly properties mutable. */ diff --git a/web/src/common/ui/locale/definitions.ts b/web/src/common/ui/locale/definitions.ts index 2da39a1965..7094814d0d 100644 --- a/web/src/common/ui/locale/definitions.ts +++ b/web/src/common/ui/locale/definitions.ts @@ -1,8 +1,9 @@ -import { type allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js"; +import { allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js"; import type { LocaleModule } from "@lit/localize"; export type TargetLanguageTag = (typeof allLocales)[number]; +export const TargetLanguageTags = new Set(allLocales); /** * The language tag representing the pseudo-locale for testing. diff --git a/web/src/common/ui/locale/utils.ts b/web/src/common/ui/locale/utils.ts index 0cba58aef2..ddfbb4e44e 100644 --- a/web/src/common/ui/locale/utils.ts +++ b/web/src/common/ui/locale/utils.ts @@ -1,7 +1,11 @@ import { allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js"; import { resolveChineseScript, resolveChineseScriptLegacy } from "#common/ui/locale/cjk"; -import { PseudoLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions"; +import { + PseudoLanguageTag, + TargetLanguageTag, + TargetLanguageTags, +} from "#common/ui/locale/definitions"; //#region Cache @@ -150,6 +154,19 @@ export function getSessionLocale(): string | null { return null; } +//#region Type Guards + +/** + * Predicate to determine if a given language tag is a supported locale target. + * + * @param languageTagHint The language tag to check. + */ +export function isTargetLanguageTag( + languageTagHint: Intl.UnicodeBCP47LocaleIdentifier, +): languageTagHint is TargetLanguageTag { + return TargetLanguageTags.has(languageTagHint as TargetLanguageTag); +} + //#endregion //#region Auto-Detection diff --git a/web/src/elements/controllers/LocaleContextController.ts b/web/src/elements/controllers/LocaleContextController.ts index 438ed8a44e..29a2714597 100644 --- a/web/src/elements/controllers/LocaleContextController.ts +++ b/web/src/elements/controllers/LocaleContextController.ts @@ -2,35 +2,87 @@ import { sourceLocale, targetLocales } from "../../locale-codes.js"; import { LocaleLoaderRecord, TargetLanguageTag } from "#common/ui/locale/definitions"; import { formatDisplayName } from "#common/ui/locale/format"; -import { autoDetectLanguage } from "#common/ui/locale/utils"; +import { autoDetectLanguage, isTargetLanguageTag } from "#common/ui/locale/utils"; -import { kAKLocale, LocaleContext, LocaleMixin } from "#elements/mixins/locale"; +import { kAKLocale, LocaleContext, LocaleContextValue, LocaleMixin } from "#elements/mixins/locale"; import type { ReactiveElementHost } from "#elements/types"; import { ConsoleLogger } from "#logger/browser"; import { ContextProvider } from "@lit/context"; -import { configureLocalization, LOCALE_STATUS_EVENT, LocaleStatusEventDetail } from "@lit/localize"; +import { + configureLocalization, + LOCALE_STATUS_EVENT, + LocaleModule, + LocaleStatusEventDetail, +} from "@lit/localize"; import type { ReactiveController } from "lit"; +const logger = ConsoleLogger.prefix("controller/locale"); + +/** + * Loads the locale module for the given locale code. + * + * @param locale The locale code to load. + * + * @remarks + * This is used by `@lit/localize` to dynamically load locale modules, + * as well synchronizing the document's `lang` attribute. + */ +function loadLocale(locale: string): Promise { + const languageNames = new Intl.DisplayNames([locale, sourceLocale], { + type: "language", + }); + + const displayName = formatDisplayName(locale, locale, languageNames); + + if (!isTargetLanguageTag(locale)) { + // Lit localize ensures this function is only called with valid locales + // but we add a runtime check nonetheless. + + throw new TypeError(`Unsupported locale code: ${locale} (${displayName})`); + } + + logger.debug(`Loading "${displayName}" module...`); + + const loader = LocaleLoaderRecord[locale]; + + return loader(); +} + /** * A controller that provides the application configuration to the element. */ export class LocaleContextController implements ReactiveController { + /** + * A shared locale context value. + */ + protected static context: LocaleContextValue = configureLocalization({ + sourceLocale, + targetLocales, + loadLocale, + }); + protected static DocumentObserverInit: MutationObserverInit = { attributes: true, attributeFilter: ["lang"], attributeOldValue: true, }; - protected logger = ConsoleLogger.prefix("controller/locale"); + public get activeLanguageTag(): TargetLanguageTag { + return LocaleContextController.context!.getLocale() as TargetLanguageTag; + } + + public set activeLanguageTag(value: TargetLanguageTag) { + LocaleContextController.context!.setLocale(value); + } /** * Attempts to apply the given locale code. * @param nextLocale A user or agent preferred locale code. */ #applyLocale(nextLocale: TargetLanguageTag) { - const activeLanguageTag = this.#context.value.getLocale(); + const { activeLanguageTag } = this; const languageNames = new Intl.DisplayNames([nextLocale, sourceLocale], { type: "language", @@ -39,14 +91,14 @@ export class LocaleContextController implements ReactiveController { const displayName = formatDisplayName(nextLocale, nextLocale, languageNames); if (activeLanguageTag === nextLocale) { - this.logger.debug("Skipping locale update, already set to:", displayName); + logger.debug("Skipping locale update, already set to:", displayName); return; } this.#context.value.setLocale(nextLocale); this.#host.activeLanguageTag = nextLocale; - this.logger.info("Applied locale:", displayName); + logger.info("Applied locale:", displayName); } // #region Attribute Observation @@ -71,10 +123,10 @@ export class LocaleContextController implements ReactiveController { current: document.documentElement.lang, }; - this.logger.debug("Detected document `lang` attribute change", attribute); + logger.debug("Detected document `lang` attribute change", attribute); if (attribute.previous === attribute.current) { - this.logger.debug("Skipping locale update, `lang` unchanged", attribute); + logger.debug("Skipping locale update, `lang` unchanged", attribute); continue; } @@ -103,33 +155,6 @@ export class LocaleContextController implements ReactiveController { //#region Lifecycle - /** - * Loads the locale module for the given locale code. - * - * @param _locale The locale code to load. - * - * @remarks - * This is used by `@lit/localize` to dynamically load locale modules, - * as well synchronizing the document's `lang` attribute. - */ - #loadLocale = (_locale: string) => { - // TypeScript cannot infer the type here, but Lit Localize will only call this - // function with one of the `targetLocales`. - const locale = _locale as TargetLanguageTag; - - const languageNames = new Intl.DisplayNames([locale, sourceLocale], { - type: "language", - }); - - const displayName = formatDisplayName(locale, locale, languageNames); - - this.logger.debug(`Loading "${displayName}" module...`); - - const loader = LocaleLoaderRecord[locale]; - - return loader(); - }; - #host: ReactiveElementHost; #context: ContextProvider; @@ -137,21 +162,15 @@ export class LocaleContextController implements ReactiveController { * @param host The host element. * @param localeHint The initial locale code to set. */ - constructor(host: ReactiveElementHost, localeHint?: TargetLanguageTag) { + constructor(host: ReactiveElementHost, localeHint?: TargetLanguageTag) { this.#host = host; - const contextValue = configureLocalization({ - sourceLocale, - targetLocales, - loadLocale: this.#loadLocale, - }); - this.#context = new ContextProvider(this.#host, { context: LocaleContext, - initialValue: contextValue, + initialValue: LocaleContextController.context, }); - this.#host[kAKLocale] = contextValue; + this.#host[kAKLocale] = LocaleContextController.context; const nextLocale = localeHint || autoDetectLanguage(); @@ -162,7 +181,7 @@ export class LocaleContextController implements ReactiveController { #localeStatusListener = (event: CustomEvent) => { if (event.detail.status === "error") { - this.logger.debug("Error loading locale:", event.detail); + logger.debug("Error loading locale:", event.detail); return; } @@ -171,7 +190,7 @@ export class LocaleContextController implements ReactiveController { } const { readyLocale } = event.detail; - this.logger.debug(`Updating \`lang\` attribute to: \`${readyLocale}\``); + logger.debug(`Updating \`lang\` attribute to: \`${readyLocale}\``); // Prevent observation while we update the `lang` attribute... this.#disconnectDocumentObserver(); diff --git a/web/src/elements/messages/MessageContainer.ts b/web/src/elements/messages/MessageContainer.ts index ed867fe618..d6685f6eec 100644 --- a/web/src/elements/messages/MessageContainer.ts +++ b/web/src/elements/messages/MessageContainer.ts @@ -2,10 +2,11 @@ import "#elements/messages/Message"; import { APIError, pluckErrorDetail } from "#common/errors/network"; import { APIMessage, MessageLevel } from "#common/messages"; -import { SentryIgnoredError } from "#common/sentry/index"; import { AKElement } from "#elements/Base"; +import { ConsoleLogger } from "#logger/browser"; + import { instanceOfValidationError } from "@goauthentik/api"; import { msg } from "@lit/localize"; @@ -14,6 +15,8 @@ import { customElement, property, state } from "lit/decorators.js"; import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css"; +const logger = ConsoleLogger.prefix("messages"); + /** * Adds a message to the message container, displaying it to the user. * @@ -27,19 +30,22 @@ export function showMessage(message: APIMessage | null, unique = false): void { return; } - const container = document.querySelector("ak-message-container"); - - if (!container) { - throw new SentryIgnoredError("failed to find message container"); - } - if (!message.message.trim()) { - console.warn("authentik/messages: `showMessage` received an empty message", message); + logger.warn("authentik/messages: `showMessage` received an empty message", message); message.message = msg("An unknown error occurred"); message.description ??= msg("Please check the browser console for more details."); } + const container = document.querySelector("ak-message-container"); + + if (!container) { + logger.warn("authentik/messages: No message container found in DOM"); + logger.info("authentik/messages: Message to show:", message); + + return; + } + container.addMessage(message, unique); container.requestUpdate(); } diff --git a/web/src/elements/mixins/locale.ts b/web/src/elements/mixins/locale.ts index 099c707f23..b484211fbf 100644 --- a/web/src/elements/mixins/locale.ts +++ b/web/src/elements/mixins/locale.ts @@ -1,4 +1,4 @@ -import { TargetLanguageTag } from "#common/ui/locale/definitions"; +import { SourceLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions"; import { createMixin } from "#elements/types"; @@ -32,7 +32,7 @@ export interface LocaleMixin { * * @internal */ - readonly [kAKLocale]: Readonly; + readonly [kAKLocale]?: Readonly; /** * The current locale language tag. @@ -54,18 +54,31 @@ export const WithLocale = createMixin( subscribe = true, }) => { abstract class LocaleProvider extends SuperClass implements LocaleMixin { + #contextWarning = false; + @consume({ context: LocaleContext, subscribe, }) - public [kAKLocale]!: LocaleContextValue; + public [kAKLocale]?: LocaleContextValue; public get activeLanguageTag(): TargetLanguageTag { - return this[kAKLocale].getLocale() as TargetLanguageTag; + if (!this[kAKLocale]) { + if (!this.#contextWarning) { + console.warn( + `[WithLocale] The locale context is not available on <${this.constructor.name}>. Did you forget to add the LocaleContextController?`, + ); + this.#contextWarning = true; + } + + return SourceLanguageTag; + } + + return this[kAKLocale]?.getLocale() as TargetLanguageTag; } public set activeLanguageTag(value: TargetLanguageTag) { - this[kAKLocale].setLocale(value); + this[kAKLocale]?.setLocale(value); } } diff --git a/web/src/flow/FlowExecutor.stories.ts b/web/src/flow/FlowExecutor.stories.ts index 49e8f0fe65..3ead62272b 100644 --- a/web/src/flow/FlowExecutor.stories.ts +++ b/web/src/flow/FlowExecutor.stories.ts @@ -1,45 +1,11 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../stories/flow-interface.js"; -import "./stages/dummy/DummyStage.js"; +import "#stories/flow-interface"; +import "#flow/stages/dummy/DummyStage"; -import { ContextualFlowInfoLayoutEnum, DummyChallenge, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { flowFactory } from "#stories/flow-interface"; export default { title: "Flow / ak-flow-executor", }; -function flowFactory(challenge: DummyChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} - -export const BackgroundImage = flowFactory({ - name: "foo", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - background: "https://picsum.photos/1920/1080", - }, -}); +export const BackgroundImage = flowFactory("ak-stage-dummy"); diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 3343861c69..3ea0e752b4 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -13,7 +13,7 @@ import "#flow/stages/RedirectStage"; import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; import { DEFAULT_CONFIG } from "#common/api/config"; -import { pluckErrorDetail } from "#common/errors/network"; +import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network"; import { globalAK } from "#common/global"; import { configureSentry } from "#common/sentry/index"; import { applyBackgroundImageProperty } from "#common/theme"; @@ -22,6 +22,7 @@ import { WebsocketClient } from "#common/ws/WebSocketClient"; import { listen } from "#elements/decorators/listen"; import { Interface } from "#elements/Interface"; +import { showAPIErrorMessage } from "#elements/messages/MessageContainer"; import { WithBrandConfig } from "#elements/mixins/branding"; import { WithCapabilitiesConfig } from "#elements/mixins/capabilities"; import { LitPropertyRecord } from "#elements/types"; @@ -31,6 +32,8 @@ import { ThemedImage } from "#elements/utils/images"; import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events"; import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base"; +import { ConsoleLogger } from "#logger/browser"; + import { CapabilitiesEnum, ChallengeTypes, @@ -60,12 +63,18 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; /// +/** + * An executor for authentik flows. + * + * @attr {string} slug - The slug of the flow to execute. + * @prop {ChallengeTypes | null} challenge - The current challenge to render. + */ @customElement("ak-flow-executor") export class FlowExecutor extends WithCapabilitiesConfig(WithBrandConfig(Interface)) implements StageHost { - static readonly DefaultLayout: FlowLayoutEnum = + public static readonly DefaultLayout: FlowLayoutEnum = globalAK()?.flow?.layout || FlowLayoutEnum.Stacked; //#region Styles @@ -97,6 +106,10 @@ export class FlowExecutor this.#challenge = value; + if (value?.flowInfo) { + this.flowInfo = value.flowInfo; + } + if (!nextTitle) { document.title = this.brandingTitle; } else if (nextTitle !== previousTitle) { @@ -118,6 +131,7 @@ export class FlowExecutor //#region State #inspectorLoaded = false; + #logger = ConsoleLogger.prefix("flow-executor"); @property({ type: Boolean }) public inspectorOpen?: boolean; @@ -167,6 +181,26 @@ export class FlowExecutor }); } + /** + * Synchronize flow info such as background image with the current state. + */ + #synchronizeFlowInfo() { + if (!this.flowInfo) { + return; + } + + const background = + this.flowInfo.backgroundThemedUrls?.[this.activeTheme] || this.flowInfo.background; + + // Storybook has a different document structure, so we need to adjust the target accordingly. + const target = + import.meta.env.AK_BUNDLER === "storybook" + ? this.closest(".docs-story") + : this.ownerDocument.body; + + applyBackgroundImageProperty(background, { target }); + } + //#region Listeners @listen(AKSessionAuthenticatedEvent) @@ -185,7 +219,12 @@ export class FlowExecutor WebsocketClient.close(); } - protected refresh = () => { + protected refresh = (): Promise => { + if (!this.flowSlug) { + this.#logger.debug("Skipping refresh, no flow slug provided"); + return Promise.resolve(); + } + this.loading = true; return new FlowsApi(DEFAULT_CONFIG) @@ -195,18 +234,18 @@ export class FlowExecutor }) .then((challenge) => { this.challenge = challenge; - - if (this.challenge.flowInfo) { - this.flowInfo = this.challenge.flowInfo; - } }) - .catch((error) => { + .catch(async (error) => { + const parsedError = await parseAPIResponseError(error); + const challenge: FlowErrorChallenge = { component: "ak-stage-flow-error", - error: pluckErrorDetail(error), + error: pluckErrorDetail(parsedError), requestId: "", }; + showAPIErrorMessage(parsedError); + this.challenge = challenge as ChallengeTypes; }) .finally(() => { @@ -240,12 +279,7 @@ export class FlowExecutor (changedProperties.has("flowInfo") || changedProperties.has("activeTheme")) && this.flowInfo ) { - // Use themed background URL if available, otherwise fall back to default - const backgroundUrl = - (this.flowInfo.backgroundThemedUrls as Record | null | undefined)?.[ - this.activeTheme - ] ?? this.flowInfo.background; - applyBackgroundImageProperty(backgroundUrl); + this.#synchronizeFlowInfo(); } if ( @@ -270,6 +304,16 @@ export class FlowExecutor if (!payload) throw new Error("No payload provided"); if (!this.challenge) throw new Error("No challenge provided"); + if (!this.flowSlug) { + if (import.meta.env.AK_BUNDLER === "storybook") { + this.#logger.debug("Skipping submit flow slug check in storybook"); + + return true; + } + + throw new Error("No flow slug provided"); + } + payload.component = this.challenge.component as FlowChallengeResponseRequest["component"]; if (!options?.invisible) { @@ -312,7 +356,9 @@ export class FlowExecutor //#region Render Challenge - async renderChallenge(component: ChallengeTypes["component"]): Promise { + protected async renderChallenge( + component: ChallengeTypes["component"], + ): Promise { const { challenge, inspectorOpen } = this; const stageProps: LitPropertyRecord, unknown>> = { @@ -497,7 +543,7 @@ export class FlowExecutor return html``; } - public override render(): TemplateResult { + protected override render(): TemplateResult { const { component } = this.challenge || {}; return html` = T extends { component: string } ? Omit : T; - /** * @element ak-flow-card * @class FlowCard @@ -29,7 +27,7 @@ export class FlowCard extends AKElement { role = "presentation"; @property({ type: Object }) - challenge?: ExcludeComponent; + challenge?: Pick; @property({ type: Boolean }) loading = false; diff --git a/web/src/flow/components/types.ts b/web/src/flow/components/types.ts new file mode 100644 index 0000000000..f8def28bc0 --- /dev/null +++ b/web/src/flow/components/types.ts @@ -0,0 +1,11 @@ +import type { ChallengeTypes } from "@goauthentik/api"; + +/** + * Type utility to exclude the `component` property. + */ +export type ExcludeComponent = T extends { component: string } ? Omit : T; + +/** + * A {@link ChallengeTypes} without the `component` property. + */ +export type FlowChallengeLike = ExcludeComponent; diff --git a/web/src/flow/stages/access_denied/AccessDeniedStage.stories.ts b/web/src/flow/stages/access_denied/AccessDeniedStage.stories.ts index 007d1dcf7b..06e1e389bc 100644 --- a/web/src/flow/stages/access_denied/AccessDeniedStage.stories.ts +++ b/web/src/flow/stages/access_denied/AccessDeniedStage.stories.ts @@ -1,40 +1,14 @@ -import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./AccessDeniedStage.js"; -import { AccessDeniedChallenge, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { flowFactory } from "#stories/flow-interface"; export default { title: "Flow / Stages / ", }; -export const Challenge: StoryObj = { - render: ({ theme, challenge }) => { - return html` - - `; +export const Challenge = flowFactory("ak-stage-access-denied", { + errorMessage: "This is an error message", + flowInfo: { + title: "lorem ipsum foo bar baz", }, - args: { - theme: "automatic", - challenge: { - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - errorMessage: "This is an error message", - flowInfo: { - title: "lorem ipsum foo bar baz", - }, - } as AccessDeniedChallenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, -}; +}); diff --git a/web/src/flow/stages/authenticator_totp/AuthenticatorTOTPStage.stories.ts b/web/src/flow/stages/authenticator_totp/AuthenticatorTOTPStage.stories.ts index 35eee2e39f..eaddfd0b48 100644 --- a/web/src/flow/stages/authenticator_totp/AuthenticatorTOTPStage.stories.ts +++ b/web/src/flow/stages/authenticator_totp/AuthenticatorTOTPStage.stories.ts @@ -1,41 +1,16 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./AuthenticatorTOTPStage.js"; -import { AuthenticatorTOTPChallenge, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { flowFactory } from "#stories/flow-interface"; export default { title: "Flow / Stages / ", }; -export const Challenge: StoryObj = { - render: ({ theme, challenge }) => { - return html` - - `; +export const Challenge = flowFactory("ak-stage-authenticator-totp", { + configUrl: + "otpauth%3A%2F%2Ftotp%2Fauthentik%3Afoo%3Fsecret%3Dqwerqewrqewrqewrqewr%26algorithm%3DSHA1%26digits%3D6%26period%3D30%26issuer%3Dauthentik%0A", + flowInfo: { + title: "Flow title", }, - args: { - theme: "automatic", - challenge: { - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - configUrl: - "otpauth%3A%2F%2Ftotp%2Fauthentik%3Afoo%3Fsecret%3Dqwerqewrqewrqewrqewr%26algorithm%3DSHA1%26digits%3D6%26period%3D30%26issuer%3Dauthentik%0A", - flowInfo: { - title: "Flow title", - }, - } as AuthenticatorTOTPChallenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, -}; +}); diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.stories.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.stories.ts index 84a9130991..520763ae69 100644 --- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.stories.ts +++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.stories.ts @@ -1,17 +1,9 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./AuthenticatorValidateStage.js"; -import { - AuthenticatorValidationChallenge, - ContextualFlowInfoLayoutEnum, - DeviceClassesEnum, - UiThemeEnum, -} from "@goauthentik/api"; +import { flowFactory } from "#stories/flow-interface"; -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { DeviceClassesEnum } from "@goauthentik/api"; export default { title: "Flow / Stages / ", @@ -29,38 +21,7 @@ const webAuthNChallenge = { lastUsed: null, }; -function authenticatorValidateFactory(challenge: AuthenticatorValidationChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} - -export const MultipleDeviceChallenge = authenticatorValidateFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const MultipleDeviceChallenge = flowFactory("ak-stage-authenticator-validate", { deviceChallenges: [ { deviceClass: DeviceClassesEnum.Duo, @@ -98,32 +59,28 @@ export const MultipleDeviceChallenge = authenticatorValidateFactory({ }, ], configurationStages: [], -}); - -export const WebAuthnDeviceChallenge = authenticatorValidateFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", flowInfo: { title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", }, +}); + +export const WebAuthnDeviceChallenge = flowFactory("ak-stage-authenticator-validate", { deviceChallenges: [ { deviceClass: DeviceClassesEnum.Webauthn, ...webAuthNChallenge, }, ], - configurationStages: [], -}); -export const DuoDeviceChallenge = authenticatorValidateFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", + configurationStages: [], + flowInfo: { + title: "", + }, +}); + +export const DuoDeviceChallenge = flowFactory("ak-stage-authenticator-validate", { flowInfo: { title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", }, deviceChallenges: [ { diff --git a/web/src/flow/stages/autosubmit/AutosubmitStage.stories.ts b/web/src/flow/stages/autosubmit/AutosubmitStage.stories.ts index 739c69df94..00339e42e9 100644 --- a/web/src/flow/stages/autosubmit/AutosubmitStage.stories.ts +++ b/web/src/flow/stages/autosubmit/AutosubmitStage.stories.ts @@ -1,45 +1,14 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./AutosubmitStage.js"; -import { AutosubmitChallenge, ContextualFlowInfoLayoutEnum, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { flowFactory } from "#stories/flow-interface"; export default { title: "Flow / Stages / ", }; -export const StandardChallenge: StoryObj = { - render: ({ theme, challenge }) => { - return html` - - `; +export const StandardChallenge = flowFactory("ak-stage-autosubmit", { + attrs: { + foo: "bar", }, - args: { - theme: "automatic", - challenge: { - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, - attrs: { - foo: "bar", - }, - url: undefined as unknown as string, - } as AutosubmitChallenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, -}; +}); diff --git a/web/src/flow/stages/captcha/CaptchaStage.css b/web/src/flow/stages/captcha/CaptchaStage.css new file mode 100644 index 0000000000..14d3f0dc93 --- /dev/null +++ b/web/src/flow/stages/captcha/CaptchaStage.css @@ -0,0 +1,37 @@ +:host { + --captcha-background-to: var(--pf-global--BackgroundColor--light-100); + --captcha-background-from: var(--pf-global--BackgroundColor--light-300); +} + +:host([theme="dark"]) { + --captcha-background-to: var(--ak-dark-background-light); + --captcha-background-from: var(--pf-global--BackgroundColor--300); +} + +@keyframes captcha-background-animation { + 0% { + background-color: var(--captcha-background-from); + } + 50% { + background-color: var(--captcha-background-to); + } + 100% { + background-color: var(--captcha-background-from); + } +} + +.ak-interactive-challenge { + /** + * We use & here to hint to the ShadyDOM polyfill that this rule is meant + * for the iframe itself, not the contents of the iframe. + */ + & { + width: 100%; + min-height: 65px; + } + + &[data-ready="loading"] { + background-color: var(--captcha-background-from); + animation: captcha-background-animation 1s infinite var(--pf-global--TimingFunction); + } +} diff --git a/web/src/flow/stages/captcha/CaptchaStage.stories.ts b/web/src/flow/stages/captcha/CaptchaStage.stories.ts deleted file mode 100644 index ef80a24329..0000000000 --- a/web/src/flow/stages/captcha/CaptchaStage.stories.ts +++ /dev/null @@ -1,99 +0,0 @@ -import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; -import "./CaptchaStage.js"; - -import { CaptchaChallenge, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; - -export default { - title: "Flow / Stages / ", -}; - -function captchaFactory(challenge: CaptchaChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} - -export const ChallengeHCaptcha = captchaFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - jsUrl: "https://js.hcaptcha.com/1/api.js", - siteKey: "10000000-ffff-ffff-ffff-000000000001", - interactive: true, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, -}); - -// https://developers.cloudflare.com/turnstile/troubleshooting/testing/ -export const ChallengeTurnstileVisible = captchaFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", - siteKey: "1x00000000000000000000AA", - interactive: true, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, -}); -export const ChallengeTurnstileInvisible = captchaFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", - siteKey: "1x00000000000000000000BB", - interactive: true, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, -}); -export const ChallengeTurnstileForce = captchaFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", - siteKey: "3x00000000000000000000FF", - interactive: true, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, -}); - -export const ChallengeRecaptcha = captchaFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - jsUrl: "https://www.google.com/recaptcha/api.js", - siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", - interactive: true, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, -}); diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index cbada1c267..5878563f41 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -4,13 +4,23 @@ import "#flow/components/ak-flow-card"; import { pluckErrorDetail } from "#common/errors/network"; import { akEmptyState } from "#elements/EmptyState"; -import { ifPresent } from "#elements/utils/attributes"; import { ListenerController } from "#elements/utils/listenerController"; import { randomId } from "#elements/utils/randomId"; +import { AKFormErrors, ErrorProp } from "#components/ak-field-errors"; + import { FlowUserDetails } from "#flow/FormStatic"; import { BaseStage } from "#flow/stages/base"; -import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared"; +import Styles from "#flow/stages/captcha/CaptchaStage.css"; +import { + CaptchaController, + CaptchaControllerConstructor, + CaptchaHandlerHost, +} from "#flow/stages/captcha/controllers/CaptchaController"; +import { GReCaptchaController } from "#flow/stages/captcha/controllers/grecaptcha"; +import { HCaptchaController } from "#flow/stages/captcha/controllers/hcaptcha"; +import { TurnstileController } from "#flow/stages/captcha/controllers/turnstile"; +import { iframeTemplate } from "#flow/stages/captcha/shared"; import { ConsoleLogger } from "#logger/browser"; @@ -19,9 +29,9 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/ import { match } from "ts-pattern"; import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize"; -import { css, CSSResult, html, nothing, PropertyValues } from "lit"; +import { CSSResult, html, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { createRef, ref } from "lit/directives/ref.js"; +import { createRef, ref, type Ref } from "lit/directives/ref.js"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; @@ -46,54 +56,31 @@ interface LoadMessage { type IframeMessageEvent = MessageEvent; @customElement("ak-stage-captcha") -export class CaptchaStage extends BaseStage { - static styles: CSSResult[] = [ +export class CaptchaStage + extends BaseStage + implements CaptchaHandlerHost +{ + public static readonly styles: CSSResult[] = [ + // --- PFLogin, PFForm, PFFormControl, PFTitle, - css` - :host { - --captcha-background-to: var(--pf-global--BackgroundColor--light-100); - --captcha-background-from: var(--pf-global--BackgroundColor--light-300); - } - - :host([theme="dark"]) { - --captcha-background-to: var(--ak-dark-background-light); - --captcha-background-from: var(--pf-global--BackgroundColor--300); - } - - @keyframes captcha-background-animation { - 0% { - background-color: var(--captcha-background-from); - } - 50% { - background-color: var(--captcha-background-to); - } - 100% { - background-color: var(--captcha-background-from); - } - } - - .ak-interactive-challenge { - /** - * We use & here to hint to the ShadyDOM polyfill that this rule is meant - * for the iframe itself, not the contents of the iframe. - */ - & { - width: 100%; - min-height: 65px; - } - - &[data-ready="loading"] { - background-color: var(--captcha-background-from); - animation: captcha-background-animation 1s infinite - var(--pf-global--TimingFunction); - } - } - `, + Styles, ]; + /** + * Set of Captcha provider controllers. + * + * Note that this `Set` is in the preferred order of discovery. + */ + public static readonly controllers = new Set([ + // --- + HCaptchaController, + GReCaptchaController, + TurnstileController, + ]); + #logger = ConsoleLogger.prefix("flow:captcha"); //#region Properties @@ -116,19 +103,27 @@ export class CaptchaStage extends BaseStage(); + /** + * A Lit {@linkcode Ref} to the iframe element. + */ + public iframeRef: Ref = createRef(); #iframeLoaded = false; @@ -139,7 +134,7 @@ export class CaptchaStage extends BaseStage this.onTokenChange(token)) .with({ message: "load" }, this.#loadListener) @@ -175,168 +172,23 @@ export class CaptchaStage extends BaseStage { - return html``; - }; - - async executeGReCaptcha() { - return grecaptcha.ready(() => { - return grecaptcha.execute( - grecaptcha.render(this.captchaDocumentContainer, { - sitekey: this.challenge?.siteKey ?? "", - callback: this.onTokenChange, - size: "invisible", - hl: this.activeLanguageTag, - }), - ); - }); - } - - async refreshGReCaptchaFrame() { - this.#iframeRef.value?.contentWindow?.grecaptcha.reset(); - } - - async refreshGReCaptcha() { - window.grecaptcha.reset(); - window.grecaptcha.execute(); - } - - //#endregion - - //#region h-captcha - - protected renderHCaptchaFrame = () => { - return html``; - }; - - async executeHCaptcha() { - await hcaptcha.execute( - hcaptcha.render(this.captchaDocumentContainer, { - sitekey: this.challenge?.siteKey ?? "", - callback: this.onTokenChange, - size: "invisible", - hl: this.activeLanguageTag, - }), - ); - } - - async refreshHCaptchaFrame() { - this.#iframeRef.value?.contentWindow?.hcaptcha?.reset(); - } - - async refreshHCaptcha() { - window.hcaptcha.reset(); - window.hcaptcha.execute(); - } - - //#endregion - - //#region Turnstile - - /** - * Renders the Turnstile captcha frame. - * - * @remarks - * - * Turnstile will log a warning if the `data-language` attribute - * is not in lower-case format. - * - * @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages} - */ - protected renderTurnstileFrame = () => { - const languageTag = this.activeLanguageTag.toLowerCase(); - - return html``; - }; - - async executeTurnstile() { - window.turnstile.render(this.captchaDocumentContainer, { - sitekey: this.challenge?.siteKey ?? "", - callback: this.onTokenChange, - }); - } - - async refreshTurnstileFrame() { - this.#iframeRef.value?.contentWindow?.turnstile.reset(); - } - - async refreshTurnstile() { - window.turnstile.reset(); - } - - //#endregion - - /** - * Mapping of captcha provider names to their respective JS API global. - * - * Note that this is a `Map` to ensure the preferred order of discovering provider globals. - */ - #handlers = new Map([ - [ - "grecaptcha", - { - interactive: this.renderGReCaptchaFrame, - execute: this.executeGReCaptcha, - refreshInteractive: this.refreshGReCaptchaFrame, - refresh: this.refreshGReCaptcha, - }, - ], - [ - "hcaptcha", - { - interactive: this.renderHCaptchaFrame, - execute: this.executeHCaptcha, - refreshInteractive: this.refreshHCaptchaFrame, - refresh: this.refreshHCaptcha, - }, - ], - [ - "turnstile", - { - interactive: this.renderTurnstileFrame, - refreshInteractive: this.refreshTurnstileFrame, - execute: this.executeTurnstile, - refresh: this.refreshTurnstile, - }, - ], - ]); - //#region Render - renderBody() { + protected renderBody() { if (this.error) { - return akEmptyState({ icon: "fa-times" }, { heading: this.error }); + return html` + ${msg("The CAPTCHA challenge failed to load.")} + ${AKFormErrors({ errors: [this.error] })}`; } if (this.challenge?.interactive) { return html` @@ -346,7 +198,7 @@ export class CaptchaStage extends BaseStage ${FlowUserDetails({ challenge: this.challenge })} ${this.renderBody()} @@ -354,7 +206,7 @@ export class CaptchaStage extends BaseStage`; } - render() { + protected render() { if (!this.challenge) { return this.embedded ? nothing : akEmptyState({ loading: true }); } @@ -366,7 +218,7 @@ export class CaptchaStage extends BaseStage) { super.firstUpdated(changedProperties); - if (!(changedProperties.has("challenge") && typeof this.challenge !== "undefined")) { - return; + if (changedProperties.has("challenge") && this.challenge) { + this.#refreshControllers(); } - - this.#refreshVendor(); } public updated(changedProperties: PropertyValues) { @@ -408,40 +256,72 @@ export class CaptchaStage extends BaseStage script.src === challengeURL.href, + ); + + if (matchedScript) { + this.#logger.debug("Reusing existing script element."); + + if (this.activeController) { + return this.#run(this.activeController); + } + + return this.#scriptLoadListener(); + } // Then, load the new script... const scriptElement = document.createElement("script"); - scriptElement.src = this.challenge?.jsUrl ?? ""; + scriptElement.src = challengeURL.toString(); scriptElement.async = true; scriptElement.defer = true; scriptElement.onload = this.#scriptLoadListener; - this.#scriptElement?.remove(); + document.head.appendChild(scriptElement); - this.#scriptElement = document.head.appendChild(scriptElement); - - if (!this.challenge?.interactive) { - document.body.appendChild(this.captchaDocumentContainer); + if (this.activeController) { + this.removeController(this.activeController); + this.activeController = null; } } #localeStatusListener = (event: CustomEvent) => { - if (!this.activeHandler) { + if (!this.activeController) { return; } @@ -457,27 +337,37 @@ export class CaptchaStage extends BaseStage { - const iframe = this.#iframeRef.value; + this.#mutationObserver?.disconnect(); + this.#resizeObserver?.disconnect(); + + const iframe = this.iframeRef.value; const contentDocument = iframe?.contentDocument; if (!iframe || !contentDocument) return; let synchronizeHeight: () => void; - if (this.activeHandler === CaptchaProvider.reCAPTCHA) { + if (this.activeController instanceof GReCaptchaController) { // reCAPTCHA's use of nested iframes prevents their internal resize observer from // reporting the correct height back to our iframe, so we have to do it ourselves. synchronizeHeight = () => { - if (!this.#iframeRef) return; + if (!this.iframeRef) return; const target = contentDocument.getElementById("ak-container"); @@ -500,7 +390,7 @@ export class CaptchaStage extends BaseStage { + this.#mutationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== "childList") continue; @@ -513,21 +403,20 @@ export class CaptchaStage extends BaseStage { - if (!this.#iframeRef) return; + if (!this.iframeRef) return; const target = contentDocument.getElementById("ak-container"); @@ -537,10 +426,10 @@ export class CaptchaStage extends BaseStage { - resizeObserver.observe(contentDocument.body); + this.#resizeObserver?.observe(contentDocument.body); this.onLoad?.(); this.#iframeLoaded = true; }); @@ -550,94 +439,123 @@ export class CaptchaStage extends BaseStage => { - this.#logger.debug("script loaded"); + /** + * An event listener that is called when the captcha provider's script has loaded, + * attempting to initialize each available controller in order. + */ + #scriptLoadListener = async (event?: Event): Promise => { + const scriptElement = event?.currentTarget as HTMLScriptElement | null; + this.#logger.debug("Script loaded", scriptElement?.src ?? "unknown source"); this.error = null; this.#iframeLoaded = false; - for (const name of this.#handlers.keys()) { - if (!Object.hasOwn(window, name)) { - continue; - } + const [Controller, ...rest] = CaptchaController.discover(CaptchaStage.controllers); - try { - await this.#run(name); - this.#logger.debug(`[${name}]: handler succeeded`); + if (!Controller) { + this.error = msg("Could not find a suitable CAPTCHA provider."); + return; + } - this.activeHandler = name; - } catch (error) { - this.#logger.debug(`[${name}]: handler failed`); - this.#logger.debug(error); + // hCaptcha aliases gReCaptcha for compatibility reasons, no need to panic if that's the case. + if ( + rest.length && + Controller === HCaptchaController && + rest.some((C) => C !== GReCaptchaController) + ) { + this.#logger.debug( + `Other CAPTCHA providers were also available: ${rest + .map((C) => C?.globalName ?? "unknown") + .join(", ")}`, + ); + } - this.error = pluckErrorDetail(error, "Unspecified error"); - } + const { globalName } = Controller; + const controller = new Controller(this); - // We begin listening for locale changes once a handler has been successfully run - // to avoid interrupting the initial load. - window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener, { - signal: this.#listenController.signal, - }); + try { + await this.#run(controller); + this.#logger.debug(`[${globalName}]: handler succeeded`); + + this.activeController = controller; + } catch (error) { + this.#logger.debug(`[${globalName}]: handler failed`); + this.#logger.debug(error); + + this.error = pluckErrorDetail(error, "Unspecified error"); + this.removeController(controller); + } + + // We begin listening for locale changes once a handler has been successfully run + // to avoid interrupting the initial load. + window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener, { + signal: this.#listenController.signal, + }); + }; + + async #run(controller: CaptchaController): Promise { + if (!this.challenge) { + throw new Error("No challenge available"); + } + + if (!this.challenge.interactive) { + await controller.execute(); + } + + const iframe = this.iframeRef.value; + + if (!iframe) { + this.#logger.debug(`No iframe found, skipping.`); + return; + } + + const { contentDocument } = iframe; + + if (!contentDocument) { + this.#logger.debug("No iframe content window found, skipping."); return; } - }; - async #run(captchaProvider: CaptchaProvider) { - const handler = this.#handlers.get(captchaProvider)!; + this.#logger.debug(`Rendering interactive.`); - if (this.challenge?.interactive) { - const iframe = this.#iframeRef.value; + const challengeURL = controller.prepareURL(); - if (!iframe) { - this.#logger.debug(`No iframe found, skipping.`); - return; - } + if (!challengeURL) { + throw new Error("Could not prepare challenge URL"); + } - const { contentDocument } = iframe; + const captchaElement = controller.interactive(); + const template = iframeTemplate(captchaElement, { + challengeURL: challengeURL.toString(), + theme: this.activeTheme, + }); - if (!contentDocument) { - this.#logger.debug("No iframe content window found, skipping."); + if ( + controller instanceof GReCaptchaController || + controller instanceof HCaptchaController + ) { + // reCAPTCHA's & hCaptcha's domain verification can't seem to penetrate the true origin + // of the page when loaded from a blob URL, likely due to their double-nested + // iframe structure. + // We fallback to the deprecated `document.write` to get around this. + this.#iframeSource = "about:blank"; - return; - } - - this.#logger.debug(`Rendering interactive.`); - - const captchaElement = handler.interactive(); - const template = iframeTemplate(captchaElement, { - challengeURL: this.challenge.jsUrl, - theme: this.activeTheme, - }); - - if ( - captchaProvider === CaptchaProvider.reCAPTCHA || - captchaProvider === CaptchaProvider.hCaptcha - ) { - // reCAPTCHA's & hCaptcha's domain verification can't seem to penetrate the true origin - // of the page when loaded from a blob URL, likely due to their double-nested - // iframe structure. - // We fallback to the deprecated `document.write` to get around this. - this.#iframeSource = "about:blank"; + requestAnimationFrame(() => { contentDocument.open(); contentDocument.write(template); contentDocument.close(); - - // this.#loadListener(); - } else { - URL.revokeObjectURL(this.#iframeSource); - - const url = URL.createObjectURL(new Blob([template], { type: "text/html" })); - - this.#iframeSource = url; - - iframe.src = url; - } + }); return; } - await handler.execute.apply(this); + URL.revokeObjectURL(this.#iframeSource); + + const url = URL.createObjectURL(new Blob([template], { type: "text/html" })); + + this.#iframeSource = url; + iframe.src = url; } } diff --git a/web/src/flow/stages/captcha/controllers/CaptchaController.ts b/web/src/flow/stages/captcha/controllers/CaptchaController.ts new file mode 100644 index 0000000000..6da5320f84 --- /dev/null +++ b/web/src/flow/stages/captcha/controllers/CaptchaController.ts @@ -0,0 +1,111 @@ +import type { ResolvedUITheme } from "#common/theme"; + +import { ErrorProp } from "#components/ak-field-errors"; + +import { ConsoleLogger, Logger } from "#logger/browser"; + +import { CaptchaChallenge } from "@goauthentik/api"; + +import { ReactiveController, ReactiveControllerHost, TemplateResult } from "lit"; +import { Ref } from "lit/directives/ref.js"; + +/** + * Mapping of captcha provider names to their respective JS API global. + */ +export const CaptchaProvider = { + reCAPTCHA: "grecaptcha", + hCaptcha: "hcaptcha", + Turnstile: "turnstile", +} as const satisfies Record; + +export abstract class CaptchaController implements ReactiveController { + /** + * The runtime global name of this Captcha provider, e.g. `grecaptcha`. + */ + public static readonly globalName: string = ""; + + public get globalName(): string { + return (this.constructor as typeof CaptchaController).globalName; + } + + /** + * A prefix for log messages from this controller. + */ + protected static logPrefix = "controller"; + + /** + * Given a source of {@linkcode CaptchaControllerConstructor}s, return those + * whose global is present in `window`. + */ + public static discover( + controllerConstructors: Iterable, + ): Array { + return Array.from(controllerConstructors).filter((Controller) => { + // Can we find the global for this captcha provider? + return Object.hasOwn(window, Controller.globalName); + }); + } + + public hostConnected(): void { + this.logger.debug("Host connected."); + } + + public hostDisconnected(): void { + this.logger.debug("Host disconnected."); + } + + /** + * Log a debug message with the controller's prefix. + */ + protected readonly logger: Logger; + + public readonly host: CaptchaHandlerHost; + + /** + * A callable that returns the interactive captcha element. + */ + public abstract interactive: () => TemplateResult; + + /** + * A callable that refreshes the interactive captcha element. + */ + public abstract refreshInteractive: () => Promise; + /** + * A callable that executes a non-interactive captcha challenge. + */ + + public abstract execute: () => Promise; + + /** + * A callable that refreshes a non-interactive captcha challenge. + */ + public abstract refresh: () => Promise; + + public prepareURL(): URL | null { + const source = this.host.challenge?.jsUrl; + + return source && URL.canParse(source) ? new URL(source) : null; + } + + public constructor(host: CaptchaHandlerHost) { + const { logPrefix } = this.constructor as typeof CaptchaController; + + this.logger = ConsoleLogger.prefix(`controller/${logPrefix}`); + this.host = host; + this.host.addController(this); + } +} + +export type CaptchaControllerConstructor = { + globalName: string; +} & (new (host: CaptchaHandlerHost) => CaptchaController); + +export interface CaptchaHandlerHost extends ReactiveControllerHost { + captchaDocumentContainer: HTMLElement; + iframeRef: Ref; + activeLanguageTag: string; + activeTheme: ResolvedUITheme; + challenge: CaptchaChallenge | null; + error: ErrorProp | null; + onTokenChange(token: string): void; +} diff --git a/web/src/flow/stages/captcha/controllers/grecaptcha.ts b/web/src/flow/stages/captcha/controllers/grecaptcha.ts new file mode 100644 index 0000000000..0382025a77 --- /dev/null +++ b/web/src/flow/stages/captcha/controllers/grecaptcha.ts @@ -0,0 +1,58 @@ +/// + +/// +import { ifPresent } from "#elements/utils/attributes"; + +import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController"; + +import { html } from "lit"; + +declare global { + interface Window { + grecaptcha: ReCaptchaV2.ReCaptcha & { + enterprise: ReCaptchaV2.ReCaptcha; + }; + } +} + +declare global { + interface Window { + hcaptcha?: HCaptcha; + } +} + +export class GReCaptchaController extends CaptchaController { + public static readonly globalName = "grecaptcha"; + + public interactive = () => { + return html``; + }; + + public refreshInteractive = async () => { + this.host.iframeRef.value?.contentWindow?.grecaptcha.reset(); + }; + + public execute = async () => { + return grecaptcha.ready(() => { + return grecaptcha.execute( + grecaptcha.render(this.host.captchaDocumentContainer, { + sitekey: this.host.challenge?.siteKey ?? "", + callback: this.host.onTokenChange, + size: "invisible", + hl: this.host.activeLanguageTag, + }), + ); + }); + }; + + public refresh = async () => { + window.grecaptcha.reset(); + window.grecaptcha.execute(); + }; +} diff --git a/web/src/flow/stages/captcha/controllers/hcaptcha.ts b/web/src/flow/stages/captcha/controllers/hcaptcha.ts new file mode 100644 index 0000000000..53e50040c7 --- /dev/null +++ b/web/src/flow/stages/captcha/controllers/hcaptcha.ts @@ -0,0 +1,56 @@ +/// + +import { ifPresent } from "#elements/utils/attributes"; + +import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController"; + +import { html } from "lit"; + +declare global { + interface Window { + hcaptcha?: HCaptcha; + } +} + +export class HCaptchaController extends CaptchaController { + public static readonly globalName = "hcaptcha"; + + #hcaptchaID: HCaptchaId | null = null; + + public interactive = () => { + return html``; + }; + + public refreshInteractive = async () => { + this.host.iframeRef.value?.contentWindow?.hcaptcha?.reset(); + }; + + public execute = async () => { + this.#hcaptchaID = hcaptcha.render(this.host.captchaDocumentContainer, { + sitekey: this.host.challenge?.siteKey ?? "", + callback: this.host.onTokenChange, + size: "invisible", + hl: this.host.activeLanguageTag, + }); + + await hcaptcha.execute(this.#hcaptchaID, { + async: true, + }); + }; + + public refresh = async () => { + if (this.#hcaptchaID === null) { + this.logger.warn("Skipping refresh: no hCaptcha ID set"); + return; + } + + window.hcaptcha.reset(this.#hcaptchaID); + window.hcaptcha.execute(this.#hcaptchaID); + }; +} diff --git a/web/src/flow/stages/captcha/controllers/shared.ts b/web/src/flow/stages/captcha/controllers/shared.ts new file mode 100644 index 0000000000..922c18f832 --- /dev/null +++ b/web/src/flow/stages/captcha/controllers/shared.ts @@ -0,0 +1,16 @@ +// import { CaptchaControllerConstructor } from "#flow/stages/captcha/controllers/CaptchaController"; +// import { GReCaptchaController } from "#flow/stages/captcha/controllers/grecaptcha"; +// import { HCaptchaController } from "#flow/stages/captcha/controllers/hcaptcha"; +// import { TurnstileController } from "#flow/stages/captcha/controllers/turnstile"; + +// /** +// * Set of Captcha provider controllers. +// * +// * Note that this `Set` is in the preferred order of discovery. +// */ +// export const CaptchaControllers = new Set([ +// // --- +// HCaptchaController, +// GReCaptchaController, +// TurnstileController, +// ]); diff --git a/web/src/flow/stages/captcha/controllers/turnstile.ts b/web/src/flow/stages/captcha/controllers/turnstile.ts new file mode 100644 index 0000000000..eb5e21bdc4 --- /dev/null +++ b/web/src/flow/stages/captcha/controllers/turnstile.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/triple-slash-reference */ +/// +import { ifPresent } from "#elements/utils/attributes"; + +import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController"; + +import { TurnstileObject } from "turnstile-types"; + +import { html } from "lit"; + +declare global { + interface Window { + turnstile: TurnstileObject; + } +} + +export class TurnstileController extends CaptchaController { + public static readonly globalName = "turnstile"; + + public prepareURL = (): URL | null => { + const input = this.host.challenge?.jsUrl; + + return input && URL.canParse(input) ? new URL(input) : null; + }; + + /** + * See {@link https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/ Turnstile Client-Side Error Codes} + */ + #delegateError = (errorCode: string) => { + this.host.error = `Turnstile error: ${errorCode}`; + }; + + /** + * Renders the Turnstile captcha frame. + * + * @remarks + * + * Turnstile will log a warning if the `data-language` attribute + * is not in lower-case format. + * + * @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages} + */ + public interactive = () => { + const languageTag = this.host.activeLanguageTag.toLowerCase(); + + return html``; + }; + + public refreshInteractive = async () => { + return this.host.iframeRef.value?.contentWindow?.turnstile.reset(); + }; + + public execute = async () => { + window.turnstile.render(this.host.captchaDocumentContainer, { + "sitekey": this.host.challenge?.siteKey ?? "", + "callback": this.host.onTokenChange, + "error-callback": this.#delegateError, + "theme": this.host.activeTheme, + }); + }; + + public refresh = async () => { + return window.turnstile.reset(); + }; +} diff --git a/web/src/flow/stages/captcha/grecaptcha.ts b/web/src/flow/stages/captcha/grecaptcha.ts deleted file mode 100644 index f726d46498..0000000000 --- a/web/src/flow/stages/captcha/grecaptcha.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// - -export {}; - -declare global { - interface Window { - grecaptcha: ReCaptchaV2.ReCaptcha & { - enterprise: ReCaptchaV2.ReCaptcha; - }; - } -} diff --git a/web/src/flow/stages/captcha/hcaptcha.ts b/web/src/flow/stages/captcha/hcaptcha.ts deleted file mode 100644 index 38831c4a33..0000000000 --- a/web/src/flow/stages/captcha/hcaptcha.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// - -export {}; - -declare global { - interface Window { - hcaptcha?: HCaptcha; - } -} diff --git a/web/src/flow/stages/captcha/shared.ts b/web/src/flow/stages/captcha/shared.ts index d73cd4135a..850347909e 100644 --- a/web/src/flow/stages/captcha/shared.ts +++ b/web/src/flow/stages/captcha/shared.ts @@ -4,24 +4,6 @@ import { createDocumentTemplate } from "#elements/utils/iframe"; import { html, TemplateResult } from "lit"; -/** - * Mapping of captcha provider names to their respective JS API global. - */ -export const CaptchaProvider = { - reCAPTCHA: "grecaptcha", - hCaptcha: "hcaptcha", - Turnstile: "turnstile", -} as const satisfies Record; - -export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider]; - -export interface CaptchaHandler { - interactive(): TemplateResult; - execute(): Promise; - refreshInteractive(): Promise; - refresh(): Promise; -} - const ThemeColor = { dark: "#18191a", light: "#ffffff", @@ -41,7 +23,7 @@ export function themeMeta(theme: ResolvedUITheme) { } export interface IFrameTemplateInit { - challengeURL: string; + challengeURL: URL | string; theme: ResolvedUITheme; } @@ -108,7 +90,7 @@ export function iframeTemplate( } ${children} - + `, }); } diff --git a/web/src/flow/stages/captcha/stories/grecaptcha.stories.ts b/web/src/flow/stages/captcha/stories/grecaptcha.stories.ts new file mode 100644 index 0000000000..ac41d98f4e --- /dev/null +++ b/web/src/flow/stages/captcha/stories/grecaptcha.stories.ts @@ -0,0 +1,16 @@ +import "@patternfly/patternfly/components/Login/login.css"; +import "../CaptchaStage.js"; + +import { flowFactory } from "#stories/flow-interface"; + +import { Meta } from "@storybook/web-components"; + +export default { + title: "Flow / Stages / / greCAPTCHA", +} satisfies Meta; + +export const ChallengeRecaptcha = flowFactory("ak-stage-captcha", { + jsUrl: "https://www.google.com/recaptcha/api.js", + siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", + interactive: true, +}); diff --git a/web/src/flow/stages/captcha/stories/hcaptcha.stories.ts b/web/src/flow/stages/captcha/stories/hcaptcha.stories.ts new file mode 100644 index 0000000000..faf9a08d5c --- /dev/null +++ b/web/src/flow/stages/captcha/stories/hcaptcha.stories.ts @@ -0,0 +1,70 @@ +import "@patternfly/patternfly/components/Login/login.css"; +import "../CaptchaStage.js"; + +import { flowFactory } from "#stories/flow-interface"; + +import { Meta } from "@storybook/web-components"; + +export default { + title: "Flow / Stages / / hCaptcha", +} satisfies Meta; + +export const VisibleChallengePasses = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://js.hcaptcha.com/1/api.js", + siteKey: "10000000-ffff-ffff-ffff-000000000001", + interactive: true, + }, + { + name: "Visible Challenge - Always Passes", + }, +); + +export const EnterpriseAccountSafe = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://js.hcaptcha.com/1/api.js", + siteKey: "20000000-ffff-ffff-ffff-000000000002", + interactive: true, + }, + { + name: "Enterprise Account - Safe", + }, +); + +export const EnterpriseAccountBotDetected = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://js.hcaptcha.com/1/api.js", + siteKey: "30000000-ffff-ffff-ffff-000000000003", + interactive: true, + }, + { + name: "Enterprise Account - Bot Detected", + }, +); + +export const InvisibleChallengePasses = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://js.hcaptcha.com/1/api.js", + siteKey: "10000000-ffff-ffff-ffff-000000000001", + interactive: false, + }, + { + name: "Invisible Challenge - Always Passes", + }, +); + +export const InvisibleEnterpriseAccountBotDetected = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://js.hcaptcha.com/1/api.js", + siteKey: "30000000-ffff-ffff-ffff-000000000003", + interactive: false, + }, + { + name: "Invisible Enterprise Account - Bot Detected", + }, +); diff --git a/web/src/flow/stages/captcha/stories/turnstile.stories.ts b/web/src/flow/stages/captcha/stories/turnstile.stories.ts new file mode 100644 index 0000000000..7fc0b7fdcb --- /dev/null +++ b/web/src/flow/stages/captcha/stories/turnstile.stories.ts @@ -0,0 +1,71 @@ +import "@patternfly/patternfly/components/Login/login.css"; +import "../CaptchaStage.js"; + +import { flowFactory } from "#stories/flow-interface"; + +import { Meta } from "@storybook/web-components"; + +export default { + title: "Flow / Stages / / Turnstile", +} satisfies Meta; + +// https://developers.cloudflare.com/turnstile/troubleshooting/testing/ +export const VisibleChallengePasses = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", + siteKey: "1x00000000000000000000AA", + interactive: true, + }, + { + name: "Visible Challenge - Always Passes", + }, +); + +export const VisibleChallengeFails = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", + siteKey: "2x00000000000000000000AB", + interactive: true, + }, + { + name: "Visible Challenge - Always Fails", + }, +); + +export const InvisibleChallengePasses = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", + siteKey: "1x00000000000000000000BB", + interactive: false, + }, + { + name: "Invisible Challenge (Passes)", + }, +); + +export const InvisibleChallengeFails = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", + siteKey: "2x00000000000000000000BB", + interactive: false, + }, + { + name: "Invisible Challenge (Fails)", + }, +); + +export const ForcedInteractiveChallenge = flowFactory( + "ak-stage-captcha", + { + jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", + siteKey: "3x00000000000000000000FF", + interactive: true, + }, + { + name: "Forced Interactive Challenge", + }, +); diff --git a/web/src/flow/stages/captcha/turnstile.ts b/web/src/flow/stages/captcha/turnstile.ts deleted file mode 100644 index 7b4659dc6e..0000000000 --- a/web/src/flow/stages/captcha/turnstile.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable @typescript-eslint/triple-slash-reference */ -/// -import { TurnstileObject } from "turnstile-types"; - -declare global { - interface Window { - turnstile: TurnstileObject; - } -} diff --git a/web/src/flow/stages/consent/ConsentStage.stories.ts b/web/src/flow/stages/consent/ConsentStage.stories.ts index fde01d58db..16b978a63e 100644 --- a/web/src/flow/stages/consent/ConsentStage.stories.ts +++ b/web/src/flow/stages/consent/ConsentStage.stories.ts @@ -1,47 +1,13 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./ConsentStage.js"; -import { ConsentChallenge, ContextualFlowInfoLayoutEnum, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { flowFactory } from "#stories/flow-interface"; export default { title: "Flow / Stages / ", }; -function consentFactory(challenge: ConsentChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} - -export const NewConsent = consentFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const NewConsent = flowFactory("ak-stage-consent", { headerText: "lorem ipsum", token: "", permissions: [ @@ -52,14 +18,7 @@ export const NewConsent = consentFactory({ additionalPermissions: [], }); -export const ExistingConsentNewPermissions = consentFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const ExistingConsentNewPermissions = flowFactory("ak-stage-consent", { headerText: "lorem ipsum", token: "", permissions: [ diff --git a/web/src/flow/stages/identification/IdentificationStage.stories.ts b/web/src/flow/stages/identification/IdentificationStage.stories.ts index 1b610d3f1e..10d1103eea 100644 --- a/web/src/flow/stages/identification/IdentificationStage.stories.ts +++ b/web/src/flow/stages/identification/IdentificationStage.stories.ts @@ -1,78 +1,36 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./IdentificationStage.js"; -import { FlowDesignationEnum, IdentificationChallenge, UiThemeEnum } from "@goauthentik/api"; +import { flowFactory } from "#stories/flow-interface"; -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { FlowDesignationEnum } from "@goauthentik/api"; export default { title: "Flow / Stages / ", }; -function identificationFactory(challenge: IdentificationChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} - -export const ChallengeDefault = identificationFactory({ +export const ChallengeDefault = flowFactory("ak-stage-identification", { userFields: ["username"], passwordFields: false, flowDesignation: FlowDesignationEnum.Authentication, primaryAction: "Login", - showSourceLabels: false, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, }); // https://developers.cloudflare.com/turnstile/troubleshooting/testing/ -export const ChallengePassword = identificationFactory({ +export const ChallengePassword = flowFactory("ak-stage-identification", { userFields: ["username"], passwordFields: true, flowDesignation: FlowDesignationEnum.Authentication, primaryAction: "Login", - showSourceLabels: false, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, }); // https://developers.cloudflare.com/turnstile/troubleshooting/testing/ -export const ChallengeCaptchaTurnstileVisible = identificationFactory({ +export const ChallengeCaptchaTurnstileVisible = flowFactory("ak-stage-identification", { userFields: ["username"], passwordFields: false, flowDesignation: FlowDesignationEnum.Authentication, primaryAction: "Login", showSourceLabels: false, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, captchaStage: { pendingUser: "", pendingUserAvatar: "", @@ -83,17 +41,12 @@ export const ChallengeCaptchaTurnstileVisible = identificationFactory({ }); // https://developers.cloudflare.com/turnstile/troubleshooting/testing/ -export const ChallengePasswordCaptchaTurnstileVisible = identificationFactory({ +export const ChallengePasswordCaptchaTurnstileVisible = flowFactory("ak-stage-identification", { userFields: ["username"], passwordFields: true, flowDesignation: FlowDesignationEnum.Authentication, primaryAction: "Login", showSourceLabels: false, - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, captchaStage: { pendingUser: "", pendingUserAvatar: "", @@ -104,7 +57,7 @@ export const ChallengePasswordCaptchaTurnstileVisible = identificationFactory({ }); // https://developers.cloudflare.com/turnstile/troubleshooting/testing/ -export const ChallengeEverything = identificationFactory({ +export const ChallengeEverything = flowFactory("ak-stage-identification", { userFields: ["username"], passwordFields: true, flowDesignation: FlowDesignationEnum.Authentication, @@ -112,11 +65,6 @@ export const ChallengeEverything = identificationFactory({ showSourceLabels: false, allowShowPassword: true, passwordlessUrl: "qwer", - flowInfo: { - layout: "stacked", - cancelUrl: "", - title: "Foo", - }, captchaStage: { pendingUser: "", pendingUserAvatar: "", diff --git a/web/src/flow/stages/password/PasswordStage.stories.ts b/web/src/flow/stages/password/PasswordStage.stories.ts index 9a72c1758c..e7deaaf423 100644 --- a/web/src/flow/stages/password/PasswordStage.stories.ts +++ b/web/src/flow/stages/password/PasswordStage.stories.ts @@ -1,68 +1,18 @@ -import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./PasswordStage.js"; -import { ContextualFlowInfoLayoutEnum, PasswordChallenge, UiThemeEnum } from "@goauthentik/api"; - -import type { StoryObj } from "@storybook/web-components"; - -import { html } from "lit"; +import { flowFactory } from "#stories/flow-interface"; export default { title: "Flow / Stages / ", }; -function passwordFactory(challenge: PasswordChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} +export const ChallengeDefault = flowFactory("ak-stage-password"); -export const ChallengeDefault = passwordFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, -}); - -export const WithRecovery = passwordFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const WithRecovery = flowFactory("ak-stage-password", { recoveryUrl: "foo", }); -export const WithError = passwordFactory({ - pendingUser: "foo", - pendingUserAvatar: "https://picsum.photos/64", - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const WithError = flowFactory("ak-stage-password", { recoveryUrl: "foo", allowShowPassword: true, responseErrors: { diff --git a/web/src/flow/stages/prompt/PromptStage.stories.ts b/web/src/flow/stages/prompt/PromptStage.stories.ts index 3690f6ece1..cd60b23981 100644 --- a/web/src/flow/stages/prompt/PromptStage.stories.ts +++ b/web/src/flow/stages/prompt/PromptStage.stories.ts @@ -1,59 +1,21 @@ import "@patternfly/patternfly/components/Login/login.css"; -import "../../../stories/flow-interface.js"; import "./PromptStage.js"; -import { - ContextualFlowInfoLayoutEnum, - PromptChallenge, - PromptTypeEnum, - UiThemeEnum, -} from "@goauthentik/api"; +import { flowFactory } from "#stories/flow-interface"; -import type { StoryObj } from "@storybook/web-components"; +import { PromptTypeEnum } from "@goauthentik/api"; -import { html } from "lit"; +import { capitalCase } from "change-case"; export default { title: "Flow / Stages / ", }; -function promptFactory(challenge: PromptChallenge): StoryObj { - return { - render: ({ theme, challenge }) => { - return html` - - `; - }, - args: { - theme: "automatic", - challenge: challenge, - }, - argTypes: { - theme: { - options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], - control: { - type: "select", - }, - }, - }, - }; -} - -export const ChallengeDefault = promptFactory({ - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const ChallengeDefault = flowFactory("ak-stage-prompt", { fields: [], }); -export const AllFieldTypes = promptFactory({ - flowInfo: { - title: "", - layout: ContextualFlowInfoLayoutEnum.Stacked, - cancelUrl: "", - }, +export const AllFieldTypes = flowFactory("ak-stage-prompt", { fields: [ PromptTypeEnum.Text, PromptTypeEnum.TextArea, @@ -77,12 +39,12 @@ export const AllFieldTypes = promptFactory({ return { fieldKey: `fk_${type}`, type: type, - label: `label_${type}`, + label: `${capitalCase(type)} (${type})`, order: idx, required: true, - placeholder: `pl_${type}`, - initialValue: `iv_${type}`, - subText: `st_${type}`, + placeholder: `Placeholder (${type})`, + initialValue: `initial_value_${type}`, + subText: `Subtext (${type})`, choices: [], }; }), diff --git a/web/src/stories/flow-interface.ts b/web/src/stories/flow-interface.ts index 7eefc597db..14898949ad 100644 --- a/web/src/stories/flow-interface.ts +++ b/web/src/stories/flow-interface.ts @@ -1,21 +1,125 @@ -import { FlowExecutor } from "#flow/FlowExecutor"; +import "#flow/FlowExecutor"; -import { html, TemplateResult } from "lit"; -import { customElement } from "lit/decorators.js"; +import { resolveUITheme } from "#common/theme"; +import { DeepPartial } from "#common/types"; + +import { AKElement } from "#elements/Base"; + +import { FlowChallengeLike } from "#flow/components/types"; + +import { ChallengeTypes, ContextualFlowInfoLayoutEnum, UiThemeEnum } from "@goauthentik/api"; + +import { StoryObj } from "@storybook/web-components"; +import { deepmerge } from "deepmerge-ts"; +import { StoryAnnotations } from "storybook/internal/csf"; + +import { html, PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; @customElement("ak-storybook-interface-flow") -export class StoryFlowInterface extends FlowExecutor { - public override firstUpdated() { - return Promise.resolve(); +export class StoryFlowInterface extends AKElement { + protected override createRenderRoot(): HTMLElement | DocumentFragment { + return this; } - public override submit = () => { - return Promise.resolve(true); + @property({ type: String, attribute: "slug", useDefault: true }) + public flowSlug = "default-authentication-flow"; + + @property({ attribute: false }) + public challenge: ChallengeTypes | null = null; + + #synchronizeTheme = () => { + this.ownerDocument.documentElement.dataset.themeChoice = resolveUITheme(this.activeTheme); }; - async renderChallenge(): Promise { - return html``; + public override updated(changed: PropertyValues): void { + if (changed.has("activeTheme")) { + this.#synchronizeTheme(); + } } + + public override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + this.#synchronizeTheme(); + } + + protected render() { + return html` + + + + + + + + + + + + `; + } +} + +let backgroundSeed = Date.now(); +let avatarSeed = backgroundSeed + 1; + +function createChallenge( + component: ChallengeTypes["component"], + overrides?: DeepPartial, +): T { + const challenge = deepmerge( + { + pendingUser: "Jessie Lorem", + pendingUserAvatar: `https://picsum.photos/seed/${avatarSeed++}/64`, + flowInfo: { + title: `<${component}>`, + layout: ContextualFlowInfoLayoutEnum.Stacked, + cancelUrl: "", + background: `https://picsum.photos/seed/${backgroundSeed++}/1920/1080`, + }, + } satisfies FlowChallengeLike, + overrides, + ); + + return challenge as T; +} + +export function flowFactory( + component: C, + overrides?: DeepPartial>, + annotations?: StoryAnnotations, +): StoryObj<{ theme: UiThemeEnum }> { + const challenge = createChallenge(component, overrides); + + return { + argTypes: { + theme: { + options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], + control: { + type: "select", + }, + }, + }, + + args: { + theme: "automatic", + }, + + render: ({ theme }) => { + return html` + `; + }, + ...annotations, + }; } declare global { diff --git a/web/src/styles/authentik/storybook.css b/web/src/styles/authentik/storybook.css index 59fb95654f..4c47d3cd03 100644 --- a/web/src/styles/authentik/storybook.css +++ b/web/src/styles/authentik/storybook.css @@ -20,13 +20,16 @@ html { } .sbdocs.sbdocs-preview { - background: var(--ak-docs-preview-background, #fff) !important; + background: var( + --ak-global--background-image, + var(--ak-docs-preview-background, , #fff) + ) !important; } @media (prefers-color-scheme: dark) { :root { --ak-base-background: hsl(260 26% 5%); - --ak-docs-preview-background: #18191a; + --ak-docs-preview-background: var(--ak-global--background-image, #18191a); } .sb-preparing-docs { @@ -37,6 +40,25 @@ html { } } +.docs-story::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + + background-size: cover; + background-repeat: no-repeat; + background-position: center; + background-attachment: local; + background-image: none; + z-index: 0; + background-image: var(--ak-global--background-image, none); +} + +.sb-main-fullscreen::before { + display: none !important; +} + .sbdocs > h1, .sbdocs-title { border-bottom: 1px solid; diff --git a/web/types/esbuild.d.ts b/web/types/esbuild.d.ts index 4456f09b7b..fccaa45728 100644 --- a/web/types/esbuild.d.ts +++ b/web/types/esbuild.d.ts @@ -26,6 +26,11 @@ declare global { */ readonly AK_DOCS_PRE_RELEASE_URL: string; + /** + * The bundler used to build the application. + */ + readonly AK_BUNDLER: "authentik" | "storybook"; + /** * The current release notes URL. *