web: Captcha Refinements, Part 2 (#19757)

* Move inline styles into separate file.

* Fix preferred order of captcha vendor discovery.

* Clean up mutation and resize observer lifecycle.

* Flesh out controllers.

* Tidy refresh.

* Fix incompatibilities with Storybook.

* Flesh out captcha stories.

* Bump package.

* Flesh out stories.

* Move inline styles into separate file.

* Fix preferred order of captcha vendor discovery.

* Clean up mutation and resize observer lifecycle.

* Flesh out controllers.

* Tidy refresh.

* Remove unused.

* Bump package.
This commit is contained in:
Teffen Ellis
2026-01-30 16:18:24 +01:00
committed by GitHub
parent da95a6b1e5
commit 388f4262b5
43 changed files with 1225 additions and 984 deletions

View File

@@ -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`
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container></ak-message-container>
${body}
`,
};
export default config;

View File

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

View File

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

74
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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}")`);
}
/**

View File

@@ -2,6 +2,15 @@
* @file Common utility types.
*/
/**
* Type utility to make all properties in T recursively optional.
*/
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
/**
* Type utility to make readonly properties mutable.
*/

View File

@@ -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<TargetLanguageTag>(allLocales);
/**
* The language tag representing the pseudo-locale for testing.

View File

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

View File

@@ -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<LocaleModule> {
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<LocaleMixin>;
#context: ContextProvider<LocaleContext>;
@@ -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<LocaleMixin>, localeHint?: TargetLanguageTag) {
constructor(host: ReactiveElementHost<LocaleContext>, 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<LocaleStatusEventDetail>) => {
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();

View File

@@ -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<MessageContainer>("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<MessageContainer>("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();
}

View File

@@ -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<LocaleContextValue>;
readonly [kAKLocale]?: Readonly<LocaleContextValue>;
/**
* The current locale language tag.
@@ -54,18 +54,31 @@ export const WithLocale = createMixin<LocaleMixin>(
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);
}
}

View File

@@ -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`<ak-storybook-interface-flow theme=${theme} .challenge=${challenge}>
<ak-stage-dummy .challenge=${challenge}></ak-stage-dummy>
</ak-storybook-interface-flow>`;
},
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: "<ak-stage-dummy>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
background: "https://picsum.photos/1920/1080",
},
});
export const BackgroundImage = flowFactory("ak-stage-dummy");

View File

@@ -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";
/// <reference types="../../types/lit.d.ts" />
/**
* 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<HTMLDivElement>(".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<void> => {
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<string, string> | 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<TemplateResult> {
protected async renderChallenge(
component: ChallengeTypes["component"],
): Promise<TemplateResult> {
const { challenge, inspectorOpen } = this;
const stageProps: LitPropertyRecord<BaseStage<NonNullable<typeof challenge>, unknown>> = {
@@ -497,7 +543,7 @@ export class FlowExecutor
return html`<slot class="slotted-content" name="placeholder"></slot>`;
}
public override render(): TemplateResult {
protected override render(): TemplateResult {
const { component } = this.challenge || {};
return html`<ak-locale-select

View File

@@ -5,7 +5,7 @@ import Styles from "./ak-flow-card.css";
import { AKElement } from "#elements/Base";
import { SlottedTemplateResult } from "#elements/types";
import { ChallengeTypes } from "@goauthentik/api";
import { FlowChallengeLike } from "#flow/components/types";
import { CSSResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -13,8 +13,6 @@ import { customElement, property } from "lit/decorators.js";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
type ExcludeComponent<T> = T extends { component: string } ? Omit<T, "component"> : T;
/**
* @element ak-flow-card
* @class FlowCard
@@ -29,7 +27,7 @@ export class FlowCard extends AKElement {
role = "presentation";
@property({ type: Object })
challenge?: ExcludeComponent<ChallengeTypes>;
challenge?: Pick<FlowChallengeLike, "flowInfo">;
@property({ type: Boolean })
loading = false;

View File

@@ -0,0 +1,11 @@
import type { ChallengeTypes } from "@goauthentik/api";
/**
* Type utility to exclude the `component` property.
*/
export type ExcludeComponent<T> = T extends { component: string } ? Omit<T, "component"> : T;
/**
* A {@link ChallengeTypes} without the `component` property.
*/
export type FlowChallengeLike = ExcludeComponent<ChallengeTypes>;

View File

@@ -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 / <ak-stage-access-denied>",
};
export const Challenge: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-access-denied .challenge=${challenge}></ak-stage-access-denied>
</ak-storybook-interface-flow>`;
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",
},
},
},
};
});

View File

@@ -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 / <ak-stage-authenticator-totp>",
};
export const Challenge: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-authenticator-totp .challenge=${challenge}></ak-stage-authenticator-totp>
</ak-storybook-interface-flow>`;
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",
},
},
},
};
});

View File

@@ -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 / <ak-stage-authenticator-validate>",
@@ -29,38 +21,7 @@ const webAuthNChallenge = {
lastUsed: null,
};
function authenticatorValidateFactory(challenge: AuthenticatorValidationChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-authenticator-validate
.challenge=${challenge}
></ak-stage-authenticator-validate>
</ak-storybook-interface-flow>`;
},
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: "<ak-stage-authenticator-validate>",
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: "<ak-stage-authenticator-validate>",
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: "<ak-stage-authenticator-validate>",
},
});
export const DuoDeviceChallenge = flowFactory("ak-stage-authenticator-validate", {
flowInfo: {
title: "<ak-stage-authenticator-validate>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
},
deviceChallenges: [
{

View File

@@ -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 / <ak-stage-autosubmit>",
};
export const StandardChallenge: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-autosubmit .challenge=${challenge}></ak-stage-autosubmit>
</ak-storybook-interface-flow>`;
export const StandardChallenge = flowFactory("ak-stage-autosubmit", {
attrs: {
foo: "bar",
},
args: {
theme: "automatic",
challenge: {
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
flowInfo: {
title: "<ak-stage-autosubmit>",
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",
},
},
},
};
});

View File

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

View File

@@ -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 / <ak-stage-captcha>",
};
function captchaFactory(challenge: CaptchaChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</ak-storybook-interface-flow>`;
},
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",
},
});

View File

@@ -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<CaptchaMessage | LoadMessage>;
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
static styles: CSSResult[] = [
export class CaptchaStage
extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest>
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<CaptchaControllerConstructor>([
// ---
HCaptchaController,
GReCaptchaController,
TurnstileController,
]);
#logger = ConsoleLogger.prefix("flow:captcha");
//#region Properties
@@ -116,19 +103,27 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#region State
@state()
protected activeHandler: CaptchaProvider | null = null;
@state()
protected error: string | null = null;
@property({ attribute: false })
public error: ErrorProp | null = null;
@state()
protected iframeHeight = 65;
#scriptElement?: HTMLScriptElement;
/**
* The currently active Captcha controller, if any.
*/
@state()
protected activeController: CaptchaController | null = null;
/**
* The desired source URL of the iframe. Note that this may differ from the actual
* `src` attribute of the iframe element for certain captcha providers.
*/
#iframeSource = "about:blank";
#iframeRef = createRef<HTMLIFrameElement>();
/**
* A Lit {@linkcode Ref} to the iframe element.
*/
public iframeRef: Ref<HTMLIFrameElement> = createRef();
#iframeLoaded = false;
@@ -139,7 +134,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#region Getters/Setters
protected get captchaDocumentContainer(): HTMLDivElement {
public get captchaDocumentContainer(): HTMLDivElement {
if (this.#captchaDocumentContainer) {
return this.#captchaDocumentContainer;
}
@@ -165,6 +160,8 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return;
}
this.#logger.debug("Received message:", data);
return match(data)
.with({ message: "captcha" }, ({ token }) => this.onTokenChange(token))
.with({ message: "load" }, this.#loadListener)
@@ -175,168 +172,23 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#endregion
//#region g-recaptcha
protected renderGReCaptchaFrame = () => {
return html`<div
id="ak-container"
class="g-recaptcha"
data-theme="${this.activeTheme}"
data-sitekey=${ifPresent(this.challenge?.siteKey)}
data-callback="callback"
></div>`;
};
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`<div
id="ak-container"
class="h-captcha"
data-sitekey=${ifPresent(this.challenge?.siteKey)}
data-theme="${this.activeTheme}"
data-callback="callback"
></div>`;
};
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`<div
id="ak-container"
class="cf-turnstile"
data-sitekey=${ifPresent(this.challenge?.siteKey)}
data-theme="${this.activeTheme}"
data-callback="callback"
data-size="flexible"
data-language=${ifPresent(languageTag)}
></div>`;
};
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<CaptchaProvider, CaptchaHandler>([
[
"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`<ak-empty-state icon="fa-times" .defaultLabel=${false}>
<div>${msg("The CAPTCHA challenge failed to load.")}</div>
<div slot="body">${AKFormErrors({ errors: [this.error] })}</div></ak-empty-state
>`;
}
if (this.challenge?.interactive) {
return html`
<iframe
aria-label=${msg("CAPTCHA challenge")}
${ref(this.#iframeRef)}
${ref(this.iframeRef)}
style="height: ${this.iframeHeight}px;"
data-ready="${this.#iframeLoaded ? "ready" : "loading"}"
data-ready=${this.#iframeLoaded ? "ready" : "loading"}
class="ak-interactive-challenge"
id="ak-captcha"
></iframe>
@@ -346,7 +198,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return akEmptyState({ loading: true }, { heading: msg("Verifying...") });
}
renderMain() {
protected renderMain() {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
${FlowUserDetails({ challenge: this.challenge })} ${this.renderBody()}
@@ -354,7 +206,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
</ak-flow-card>`;
}
render() {
protected render() {
if (!this.challenge) {
return this.embedded ? nothing : akEmptyState({ loading: true });
}
@@ -366,7 +218,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return this.challenge.interactive ? this.renderBody() : nothing;
}
//#endregion;
//#endregion
//#region Lifecycle
@@ -389,16 +241,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
super.disconnectedCallback();
}
//#endregion
public override firstUpdated(changedProperties: PropertyValues<this>) {
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<this>) {
@@ -408,40 +256,72 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return;
}
if (!this.activeHandler) {
this.#logger.debug("refresh triggered");
if (this.activeController) {
return this.challenge.interactive
? this.activeController.refreshInteractive()
: this.activeController.refresh();
}
}
#refreshControllers() {
if (!this.challenge) {
this.#logger.debug("No challenge, skipping controller refresh.");
return;
}
this.#logger.debug("refresh triggered");
this.#run(this.activeHandler);
}
#refreshVendor() {
// First, remove any existing script & listeners...
window.removeEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
this.#scriptElement?.remove();
if (!this.challenge.interactive) {
document.body.appendChild(this.captchaDocumentContainer);
}
const challengeURL =
this.challenge?.jsUrl && URL.canParse(this.challenge.jsUrl)
? new URL(this.challenge.jsUrl)
: null;
if (!challengeURL) {
this.#logger.debug("No challenge URL, skipping controller refresh.");
return;
}
// It's possible that the script has already been loaded by another stage instance.
// So long as the URL matches, we can reuse it.
const matchedScript = Iterator.from(this.ownerDocument.querySelectorAll("script")).find(
(script) => 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<LocaleStatusEventDetail>) => {
if (!this.activeHandler) {
if (!this.activeController) {
return;
}
@@ -457,27 +337,37 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
const { readyLocale } = event.detail;
this.#logger.debug(`Locale changed to \`${readyLocale}\``);
this.#run(this.activeHandler);
this.#run(this.activeController);
};
//#endregion
//#region Resizing
#mutationObserver?: MutationObserver;
#resizeObserver?: ResizeObserver;
/**
* An event listener that is called through the iframe's `postMessage` API
* when the iframe has loaded its content.
*/
#loadListener = () => {
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<CaptchaChallenge, CaptchaChallengeRe
// We watch for any newly inserted iframes, as they may alter the height
// of the parent iframe...
const mutationObserver = new MutationObserver((mutations) => {
this.#mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "childList") continue;
@@ -513,21 +403,20 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
// doesn't yet know the correct height, but at least the user can
// try to load the challenge again with the correct height.
// eslint-disable-next-line @typescript-eslint/no-use-before-define
resizeObserver.observe(node as HTMLIFrameElement);
this.#resizeObserver?.observe(node as HTMLIFrameElement);
requestAnimationFrame(synchronizeHeight);
}
}
});
mutationObserver.observe(contentDocument.body, {
this.#mutationObserver.observe(contentDocument.body, {
childList: true,
subtree: true,
});
} else {
synchronizeHeight = () => {
if (!this.#iframeRef) return;
if (!this.iframeRef) return;
const target = contentDocument.getElementById("ak-container");
@@ -537,10 +426,10 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
};
}
const resizeObserver = new ResizeObserver(synchronizeHeight);
this.#resizeObserver = new ResizeObserver(synchronizeHeight);
requestAnimationFrame(() => {
resizeObserver.observe(contentDocument.body);
this.#resizeObserver?.observe(contentDocument.body);
this.onLoad?.();
this.#iframeLoaded = true;
});
@@ -550,94 +439,123 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#region Loading
#scriptLoadListener = async (): Promise<void> => {
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<void> => {
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<void> {
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;
}
}

View File

@@ -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<string, string>;
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<CaptchaControllerConstructor>,
): Array<CaptchaControllerConstructor | undefined> {
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<void>;
/**
* A callable that executes a non-interactive captcha challenge.
*/
public abstract execute: () => Promise<void>;
/**
* A callable that refreshes a non-interactive captcha challenge.
*/
public abstract refresh: () => Promise<void>;
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<HTMLIFrameElement>;
activeLanguageTag: string;
activeTheme: ResolvedUITheme;
challenge: CaptchaChallenge | null;
error: ErrorProp | null;
onTokenChange(token: string): void;
}

View File

@@ -0,0 +1,58 @@
/// <reference types="@types/grecaptcha"/>
/// <reference types="@hcaptcha/types"/>
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`<div
id="ak-container"
class="g-recaptcha"
data-theme=${this.host.activeTheme}
data-sitekey=${ifPresent(this.host.challenge?.siteKey)}
data-callback="callback"
></div>`;
};
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();
};
}

View File

@@ -0,0 +1,56 @@
/// <reference types="@hcaptcha/types"/>
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`<div
id="ak-container"
class="h-captcha"
data-sitekey=${ifPresent(this.host.challenge?.siteKey)}
data-theme=${this.host.activeTheme}
data-callback="callback"
></div>`;
};
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);
};
}

View File

@@ -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<CaptchaControllerConstructor>([
// // ---
// HCaptchaController,
// GReCaptchaController,
// TurnstileController,
// ]);

View File

@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="turnstile-types"/>
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`<div
id="ak-container"
class="cf-turnstile"
data-sitekey=${ifPresent(this.host.challenge?.siteKey)}
data-theme=${this.host.activeTheme}
data-callback="callback"
data-size="flexible"
data-language=${ifPresent(languageTag)}
></div>`;
};
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();
};
}

View File

@@ -1,11 +0,0 @@
/// <reference types="@types/grecaptcha"/>
export {};
declare global {
interface Window {
grecaptcha: ReCaptchaV2.ReCaptcha & {
enterprise: ReCaptchaV2.ReCaptcha;
};
}
}

View File

@@ -1,9 +0,0 @@
/// <reference types="@hcaptcha/types"/>
export {};
declare global {
interface Window {
hcaptcha?: HCaptcha;
}
}

View File

@@ -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<string, string>;
export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider];
export interface CaptchaHandler {
interactive(): TemplateResult;
execute(): Promise<void>;
refreshInteractive(): Promise<void>;
refresh(): Promise<void>;
}
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(
}
</style>
${children}
<script onload="loadListener()" src="${challengeURL}"></script>
<script onload="loadListener()" src="${challengeURL.toString()}"></script>
`,
});
}

View File

@@ -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 / <ak-stage-captcha> / greCAPTCHA",
} satisfies Meta<typeof import("../CaptchaStage.js").CaptchaStage>;
export const ChallengeRecaptcha = flowFactory("ak-stage-captcha", {
jsUrl: "https://www.google.com/recaptcha/api.js",
siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
interactive: true,
});

View File

@@ -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 / <ak-stage-captcha> / hCaptcha",
} satisfies Meta<typeof import("../CaptchaStage.js").CaptchaStage>;
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",
},
);

View File

@@ -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 / <ak-stage-captcha> / Turnstile",
} satisfies Meta<typeof import("../CaptchaStage.js").CaptchaStage>;
// 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",
},
);

View File

@@ -1,9 +0,0 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="turnstile-types"/>
import { TurnstileObject } from "turnstile-types";
declare global {
interface Window {
turnstile: TurnstileObject;
}
}

View File

@@ -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 / <ak-stage-consent>",
};
function consentFactory(challenge: ConsentChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-consent .challenge=${challenge}></ak-stage-consent>
</ak-storybook-interface-flow>`;
},
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: "<ak-stage-consent>",
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: "<ak-stage-consent>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
},
export const ExistingConsentNewPermissions = flowFactory("ak-stage-consent", {
headerText: "lorem ipsum",
token: "",
permissions: [

View File

@@ -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 / <ak-stage-identification>",
};
function identificationFactory(challenge: IdentificationChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-identification .challenge=${challenge}></ak-stage-identification>
</ak-storybook-interface-flow>`;
},
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: "",

View File

@@ -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 / <ak-stage-password>",
};
function passwordFactory(challenge: PasswordChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-password .challenge=${challenge}></ak-stage-password>
</ak-storybook-interface-flow>`;
},
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: "<ak-stage-password>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
},
});
export const WithRecovery = passwordFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
flowInfo: {
title: "<ak-stage-password>",
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: "<ak-stage-password>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
},
export const WithError = flowFactory("ak-stage-password", {
recoveryUrl: "foo",
allowShowPassword: true,
responseErrors: {

View File

@@ -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 / <ak-stage-prompt>",
};
function promptFactory(challenge: PromptChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme}>
<ak-stage-prompt .challenge=${challenge}></ak-stage-prompt>
</ak-storybook-interface-flow>`;
},
args: {
theme: "automatic",
challenge: challenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
}
export const ChallengeDefault = promptFactory({
flowInfo: {
title: "<ak-stage-prompt>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
},
export const ChallengeDefault = flowFactory("ak-stage-prompt", {
fields: [],
});
export const AllFieldTypes = promptFactory({
flowInfo: {
title: "<ak-stage-prompt>",
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: [],
};
}),

View File

@@ -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<TemplateResult> {
return html`<slot></slot>`;
public override updated(changed: PropertyValues<this>): void {
if (changed.has("activeTheme")) {
this.#synchronizeTheme();
}
}
public override firstUpdated(changed: PropertyValues<this>): void {
super.firstUpdated(changed);
this.#synchronizeTheme();
}
protected render() {
return html`
<div class="pf-c-page__drawer">
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-flow-executor
class="pf-c-login"
.challenge=${this.challenge}
></ak-flow-executor>
</div>
</div>
</div>
</div>
</div>
`;
}
}
let backgroundSeed = Date.now();
let avatarSeed = backgroundSeed + 1;
function createChallenge<T extends FlowChallengeLike>(
component: ChallengeTypes["component"],
overrides?: DeepPartial<T>,
): 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<C extends ChallengeTypes["component"]>(
component: C,
overrides?: DeepPartial<Extract<ChallengeTypes, { component: C }>>,
annotations?: StoryAnnotations,
): StoryObj<{ theme: UiThemeEnum }> {
const challenge = createChallenge<FlowChallengeLike>(component, overrides);
return {
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
args: {
theme: "automatic",
},
render: ({ theme }) => {
return html`<ak-storybook-interface-flow
theme=${theme}
.challenge=${{
component,
...challenge,
}}
>
</ak-storybook-interface-flow>`;
},
...annotations,
};
}
declare global {

View File

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

View File

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