mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
74
web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
web/src/flow/components/types.ts
Normal file
11
web/src/flow/components/types.ts
Normal 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>;
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
37
web/src/flow/stages/captcha/CaptchaStage.css
Normal file
37
web/src/flow/stages/captcha/CaptchaStage.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
111
web/src/flow/stages/captcha/controllers/CaptchaController.ts
Normal file
111
web/src/flow/stages/captcha/controllers/CaptchaController.ts
Normal 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;
|
||||
}
|
||||
58
web/src/flow/stages/captcha/controllers/grecaptcha.ts
Normal file
58
web/src/flow/stages/captcha/controllers/grecaptcha.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
56
web/src/flow/stages/captcha/controllers/hcaptcha.ts
Normal file
56
web/src/flow/stages/captcha/controllers/hcaptcha.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
16
web/src/flow/stages/captcha/controllers/shared.ts
Normal file
16
web/src/flow/stages/captcha/controllers/shared.ts
Normal 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,
|
||||
// ]);
|
||||
73
web/src/flow/stages/captcha/controllers/turnstile.ts
Normal file
73
web/src/flow/stages/captcha/controllers/turnstile.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/// <reference types="@types/grecaptcha"/>
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha: ReCaptchaV2.ReCaptcha & {
|
||||
enterprise: ReCaptchaV2.ReCaptcha;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/// <reference types="@hcaptcha/types"/>
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
hcaptcha?: HCaptcha;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
16
web/src/flow/stages/captcha/stories/grecaptcha.stories.ts
Normal file
16
web/src/flow/stages/captcha/stories/grecaptcha.stories.ts
Normal 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,
|
||||
});
|
||||
70
web/src/flow/stages/captcha/stories/hcaptcha.stories.ts
Normal file
70
web/src/flow/stages/captcha/stories/hcaptcha.stories.ts
Normal 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",
|
||||
},
|
||||
);
|
||||
71
web/src/flow/stages/captcha/stories/turnstile.stories.ts
Normal file
71
web/src/flow/stages/captcha/stories/turnstile.stories.ts
Normal 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",
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
web/types/esbuild.d.ts
vendored
5
web/types/esbuild.d.ts
vendored
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user