web: Fix duplicate Turnstile widgets after extended idle (cherry-pick #21380 to version-2025.12) (#21472)

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

(cherry picked from commit 388f4262b5)

* web: Fix duplicate Turnstile widgets after extended idle (#21380)

* Flesh out turnstile fixes.

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
(cherry picked from commit 59ac8ba597)

* web: align captcha stage with post-21380 main drift

Picks up the non-PR-#21380 changes to captcha files that are already on
main: `.style-scope` selector variants in CaptchaStage.css (from #20134)
and `export default CaptchaStage` (from #20397). Both are functionally
inert on this branch — no code applies the style-scope class to
ak-stage-captcha, and no importer uses the default export — but
including them keeps the cherry-pick zero-drift against main.

* bump.

* Enforce strict tsconfig version. Format.

* Fix linter warning.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
authentik-automation[bot]
2026-04-27 19:35:44 +02:00
committed by GitHub
parent 946977fc52
commit 5fa2c3bdf5
45 changed files with 3385 additions and 3201 deletions
+28
View File
@@ -3,6 +3,13 @@
* @import { StorybookConfig } from "@storybook/web-components-vite";
*/
/**
* @param {TemplateStringsArray} strings
* @param {...any} values
* @returns {string}
*/
const html = (strings, ...values) => String.raw({ raw: strings }, ...values);
/**
* @satisfies {StorybookConfig}
*/
@@ -18,6 +25,27 @@ const config = {
"@storybook/addon-docs",
],
framework: "@storybook/web-components-vite",
viteFinal: async (config) => {
return {
...config,
define: {
...config.define,
"import.meta.env.AK_BUNDLER": JSON.stringify("storybook"),
},
resolve: {
...config.resolve,
// Avoid multiple instances of web components packages.
conditions: [],
},
};
},
previewBody: (body) => html`
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container></ak-message-container>
${body}
`,
};
export default config;
+1
View File
@@ -5,6 +5,7 @@
*/
import "#styles/authentik/interface.global.css";
import "#styles/authentik/static.global.css";
import "#styles/authentik/storybook.css";
import { ThemedDocsContainer } from "./DocsContainer.tsx";
+1
View File
@@ -27,6 +27,7 @@ export function createBundleDefinitions() {
AK_DOCS_RELEASE_NOTES_URL: ReleaseNotesURL.href,
AK_DOCS_PRE_RELEASE_URL: PreReleaseDocsURL.href,
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH ?? "",
AK_BUNDLER: JSON.stringify(process.env.AK_BUNDLER ?? "authentik"),
};
return {
+2159 -2236
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -5,17 +5,17 @@
"private": true,
"scripts": {
"build": "wireit",
"build:sfe": "npm run build -w @goauthentik/web-sfe",
"build-locales": "node scripts/build-locales.mjs",
"build-proxy": "wireit",
"build:sfe": "npm run build -w @goauthentik/web-sfe",
"bundler:watch": "node scripts/build-web.mjs --watch",
"extract-locales": "lit-localize extract",
"format": "wireit",
"lint": "eslint --fix .",
"lint-check": "eslint --max-warnings 0 .",
"lint:imports": "knip --config scripts/knip.config.ts",
"lint:lockfile": "wireit",
"lint:types": "wireit",
"lint-check": "eslint --max-warnings 0 .",
"lit-analyse": "wireit",
"precommit": "wireit",
"prettier": "prettier --cache --write -u .",
@@ -103,7 +103,7 @@
"@goauthentik/esbuild-plugin-live-reload": "^1.3.1",
"@goauthentik/eslint-config": "^1.1.1",
"@goauthentik/prettier-config": "^3.2.1",
"@goauthentik/tsconfig": "^1.0.5",
"@goauthentik/tsconfig": "1.0.5",
"@hcaptcha/types": "^1.1.0",
"@lit/context": "^1.1.6",
"@lit/localize": "^0.12.2",
@@ -176,7 +176,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",
@@ -200,6 +200,9 @@
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
"chromedriver": "^143.0.0"
},
"workspaces": [
"./packages/*"
],
"wireit": {
"build": {
"#comment": [
@@ -297,9 +300,6 @@
"node": ">=24",
"npm": ">=11.6.2"
},
"workspaces": [
"./packages/*"
],
"prettier": "@goauthentik/prettier-config",
"overrides": {
"@goauthentik/esbuild-plugin-live-reload": {
@@ -316,6 +316,7 @@
},
"rapidoc": {
"@apitools/openapi-parser": "0.0.37"
}
},
"tree-sitter": false
}
}
+2 -2
View File
@@ -9,6 +9,7 @@
},
"main": "index.js",
"type": "module",
"types": "./out/index.d.ts",
"exports": {
"./package.json": "./package.json",
"./*/browser": {
@@ -52,6 +53,5 @@
"engines": {
"node": ">=24",
"npm": ">=11.6.2"
},
"types": "./out/index.d.ts"
}
}
+3 -2
View File
@@ -22,8 +22,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);
}
+12 -3
View File
@@ -321,6 +321,11 @@ function pluckCurrentBackgroundURL(
return null;
}
export interface BackgroundImageInit {
baseOrigin?: string;
target?: HTMLElement | null;
}
/**
* Applies the given background image URL to the document body.
*
@@ -328,22 +333,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}")`);
}
/**
+9
View File
@@ -2,6 +2,15 @@
* @file Common utility types.
*/
/**
* Type utility to make all properties in T recursively optional.
*/
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
/**
* Type utility to make readonly properties mutable.
*/
+2 -1
View File
@@ -1,8 +1,9 @@
import { type allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js";
import { allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js";
import type { LocaleModule } from "@lit/localize";
export type TargetLanguageTag = (typeof allLocales)[number];
export const TargetLanguageTags = new Set<TargetLanguageTag>(allLocales);
/**
* The language tag representing the pseudo-locale for testing.
+18 -1
View File
@@ -1,7 +1,11 @@
import { allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js";
import { resolveChineseScript, resolveChineseScriptLegacy } from "#common/ui/locale/cjk";
import { PseudoLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions";
import {
PseudoLanguageTag,
TargetLanguageTag,
TargetLanguageTags,
} from "#common/ui/locale/definitions";
//#region Cache
@@ -150,6 +154,19 @@ export function getSessionLocale(): string | null {
return null;
}
//#region Type Guards
/**
* Predicate to determine if a given language tag is a supported locale target.
*
* @param languageTagHint The language tag to check.
*/
export function isTargetLanguageTag(
languageTagHint: Intl.UnicodeBCP47LocaleIdentifier,
): languageTagHint is TargetLanguageTag {
return TargetLanguageTags.has(languageTagHint as TargetLanguageTag);
}
//#endregion
//#region Auto-Detection
@@ -22,12 +22,14 @@ function resolvePath(...args: string[]): string {
* - Intercepts local links and scrolls to the target element.
*/
export const MDXAnchor = ({
href,
href: initialHref,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const { publicDirectory } = useMDXModule();
let href = initialHref;
if (href?.startsWith(".") && publicDirectory) {
const nextPathname = resolvePath(publicDirectory, href);
@@ -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();
+14 -8
View File
@@ -2,10 +2,11 @@ import "#elements/messages/Message";
import { APIError, pluckErrorDetail } from "#common/errors/network";
import { APIMessage, MessageLevel } from "#common/messages";
import { SentryIgnoredError } from "#common/sentry/index";
import { AKElement } from "#elements/Base";
import { ConsoleLogger } from "#logger/browser";
import { instanceOfValidationError } from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -15,6 +16,8 @@ import { customElement, property, state } from "lit/decorators.js";
import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
const logger = ConsoleLogger.prefix("messages");
/**
* Adds a message to the message container, displaying it to the user.
*
@@ -28,19 +31,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();
}
+18 -5
View File
@@ -1,4 +1,4 @@
import { TargetLanguageTag } from "#common/ui/locale/definitions";
import { SourceLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions";
import { createMixin } from "#elements/types";
@@ -32,7 +32,7 @@ export interface LocaleMixin {
*
* @internal
*/
readonly [kAKLocale]: Readonly<LocaleContextValue>;
readonly [kAKLocale]?: Readonly<LocaleContextValue>;
/**
* The current locale language tag.
@@ -54,18 +54,31 @@ export const WithLocale = createMixin<LocaleMixin>(
subscribe = true,
}) => {
abstract class LocaleProvider extends SuperClass implements LocaleMixin {
#contextWarning = false;
@consume({
context: LocaleContext,
subscribe,
})
public [kAKLocale]!: LocaleContextValue;
public [kAKLocale]?: LocaleContextValue;
public get activeLanguageTag(): TargetLanguageTag {
return this[kAKLocale].getLocale() as TargetLanguageTag;
if (!this[kAKLocale]) {
if (!this.#contextWarning) {
console.warn(
`[WithLocale] The locale context is not available on <${this.constructor.name}>. Did you forget to add the LocaleContextController?`,
);
this.#contextWarning = true;
}
return SourceLanguageTag;
}
return this[kAKLocale]?.getLocale() as TargetLanguageTag;
}
public set activeLanguageTag(value: TargetLanguageTag) {
this[kAKLocale].setLocale(value);
this[kAKLocale]?.setLocale(value);
}
}
+4 -38
View File
@@ -1,45 +1,11 @@
import "@patternfly/patternfly/components/Login/login.css";
import "../stories/flow-interface.js";
import "./stages/dummy/DummyStage.js";
import "#stories/flow-interface";
import "#flow/stages/dummy/DummyStage";
import { ContextualFlowInfoLayoutEnum, DummyChallenge, UiThemeEnum } from "@goauthentik/api";
import type { StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { flowFactory } from "#stories/flow-interface";
export default {
title: "Flow / ak-flow-executor",
};
function flowFactory(challenge: DummyChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface-flow theme=${theme} .challenge=${challenge}>
<ak-stage-dummy .challenge=${challenge}></ak-stage-dummy>
</ak-storybook-interface-flow>`;
},
args: {
theme: "automatic",
challenge: challenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
}
export const BackgroundImage = flowFactory({
name: "foo",
flowInfo: {
title: "<ak-stage-dummy>",
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
background: "https://picsum.photos/1920/1080",
},
});
export const BackgroundImage = flowFactory("ak-stage-dummy");
+63 -17
View File
@@ -14,7 +14,7 @@ import "#flow/tabs/broadcast";
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";
@@ -23,6 +23,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";
@@ -33,6 +34,8 @@ import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events";
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
import { multiTabOrchestrateLeave } from "#flow/tabs/orchestrator";
import { ConsoleLogger } from "#logger/browser";
import {
CapabilitiesEnum,
ChallengeTypes,
@@ -62,12 +65,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
@@ -99,6 +108,10 @@ export class FlowExecutor
this.#challenge = value;
if (value?.flowInfo) {
this.flowInfo = value.flowInfo;
}
if (!nextTitle) {
document.title = this.brandingTitle;
} else if (nextTitle !== previousTitle) {
@@ -120,6 +133,7 @@ export class FlowExecutor
//#region State
#inspectorLoaded = false;
#logger = ConsoleLogger.prefix("flow-executor");
@property({ type: Boolean })
public inspectorOpen?: boolean;
@@ -190,6 +204,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)
@@ -208,7 +242,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)
@@ -218,18 +257,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(() => {
@@ -263,12 +302,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 (
@@ -293,6 +327,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) {
@@ -335,7 +379,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>> = {
@@ -520,7 +566,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
+2 -4
View File
@@ -5,7 +5,7 @@ import Styles from "./ak-flow-card.css";
import { AKElement } from "#elements/Base";
import { SlottedTemplateResult } from "#elements/types";
import { ChallengeTypes } from "@goauthentik/api";
import { FlowChallengeLike } from "#flow/components/types";
import { CSSResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -14,8 +14,6 @@ import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
type ExcludeComponent<T> = T extends { component: string } ? Omit<T, "component"> : T;
/**
* @element ak-flow-card
* @class FlowCard
@@ -30,7 +28,7 @@ export class FlowCard extends AKElement {
role = "presentation";
@property({ type: Object })
challenge?: ExcludeComponent<ChallengeTypes>;
challenge?: Pick<FlowChallengeLike, "flowInfo">;
@property({ type: Boolean })
loading = false;
+11
View File
@@ -0,0 +1,11 @@
import type { ChallengeTypes } from "@goauthentik/api";
/**
* Type utility to exclude the `component` property.
*/
export type ExcludeComponent<T> = T extends { component: string } ? Omit<T, "component"> : T;
/**
* A {@link ChallengeTypes} without the `component` property.
*/
export type FlowChallengeLike = ExcludeComponent<ChallengeTypes>;
@@ -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",
},
},
},
};
});
@@ -0,0 +1,39 @@
:host,
ak-stage-captcha.style-scope {
--captcha-background-to: var(--pf-global--BackgroundColor--light-100);
--captcha-background-from: var(--pf-global--BackgroundColor--light-300);
}
:host([theme="dark"]),
ak-stage-captcha[theme="dark"].style-scope {
--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",
},
});
+226 -307
View File
@@ -4,13 +4,23 @@ import "#flow/components/ak-flow-card";
import { pluckErrorDetail } from "#common/errors/network";
import { akEmptyState } from "#elements/EmptyState";
import { ifPresent } from "#elements/utils/attributes";
import { ListenerController } from "#elements/utils/listenerController";
import { randomId } from "#elements/utils/randomId";
import { AKFormErrors, ErrorProp } from "#components/ak-field-errors";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared";
import Styles from "#flow/stages/captcha/CaptchaStage.css";
import {
CaptchaController,
CaptchaControllerConstructor,
CaptchaHandlerHost,
} from "#flow/stages/captcha/controllers/CaptchaController";
import { GReCaptchaController } from "#flow/stages/captcha/controllers/grecaptcha";
import { HCaptchaController } from "#flow/stages/captcha/controllers/hcaptcha";
import { TurnstileController } from "#flow/stages/captcha/controllers/turnstile";
import { iframeTemplate } from "#flow/stages/captcha/shared";
import { ConsoleLogger } from "#logger/browser";
@@ -19,15 +29,14 @@ 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";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export type TokenListener = (token: string) => void;
@@ -47,55 +56,31 @@ interface LoadMessage {
type IframeMessageEvent = MessageEvent<CaptchaMessage | LoadMessage>;
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
static styles: CSSResult[] = [
PFBase,
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
@@ -118,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;
@@ -141,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;
}
@@ -167,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)
@@ -177,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>
@@ -348,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()}
@@ -356,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 });
}
@@ -368,7 +218,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return this.challenge.interactive ? this.renderBody() : nothing;
}
//#endregion;
//#endregion
//#region Lifecycle
@@ -391,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") || !this.challenge) {
return;
if (changedProperties.has("challenge") && this.challenge) {
this.#refreshControllers();
}
this.#refreshVendor();
}
public updated(changedProperties: PropertyValues<this>) {
@@ -410,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;
}
@@ -459,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");
@@ -502,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;
@@ -515,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");
@@ -539,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;
});
@@ -552,97 +439,129 @@ 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,
scriptOnLoad: !(controller instanceof TurnstileController),
});
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;
}
}
export default CaptchaStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-captcha": CaptchaStage;
@@ -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;
}
@@ -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();
};
}
@@ -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);
};
}
@@ -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,
// ]);
@@ -0,0 +1,89 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="turnstile-types"/>
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;
if (!input || !URL.canParse(input)) return null;
const url = new URL(input);
// Use explicit rendering to prevent Turnstile's 3-hour self-upgrade
// from calling implicitRenderAll() and duplicating widgets.
url.searchParams.set("render", "explicit");
url.searchParams.set("onload", "onTurnstileReady");
return url;
};
/**
* 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.
*
* Uses explicit rendering to avoid Turnstile's self-upgrade mechanism
* (every ~3 hours) from calling `implicitRenderAll()` and duplicating widgets.
*
* @remarks
*
* Turnstile will log a warning if the `language` option
* is not in lower-case format.
*
* @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages}
*/
public interactive = () => {
const siteKey = this.host.challenge?.siteKey ?? "";
const theme = this.host.activeTheme;
const language = this.host.activeLanguageTag.toLowerCase();
return html`<div id="ak-container"></div>
<script>
function onTurnstileReady() {
turnstile.render("#ak-container", {
sitekey: "${siteKey}",
theme: "${theme}",
language: "${language}",
size: "flexible",
callback,
});
loadListener();
}
</script>`;
};
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();
};
}
-11
View File
@@ -1,11 +0,0 @@
/// <reference types="@types/grecaptcha"/>
export {};
declare global {
interface Window {
grecaptcha: ReCaptchaV2.ReCaptcha & {
enterprise: ReCaptchaV2.ReCaptcha;
};
}
}
-9
View File
@@ -1,9 +0,0 @@
/// <reference types="@hcaptcha/types"/>
export {};
declare global {
interface Window {
hcaptcha?: HCaptcha;
}
}
+11 -21
View File
@@ -4,24 +4,6 @@ import { createDocumentTemplate } from "#elements/utils/iframe";
import { html, TemplateResult } from "lit";
/**
* Mapping of captcha provider names to their respective JS API global.
*/
export const CaptchaProvider = {
reCAPTCHA: "grecaptcha",
hCaptcha: "hcaptcha",
Turnstile: "turnstile",
} as const satisfies Record<string, string>;
export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider];
export interface CaptchaHandler {
interactive(): TemplateResult;
execute(): Promise<void>;
refreshInteractive(): Promise<void>;
refresh(): Promise<void>;
}
const ThemeColor = {
dark: "#18191a",
light: "#ffffff",
@@ -41,8 +23,13 @@ export function themeMeta(theme: ResolvedUITheme) {
}
export interface IFrameTemplateInit {
challengeURL: string;
challengeURL: URL | string;
theme: ResolvedUITheme;
/**
* If `true`, the script element will fire `loadListener()` on load.
* Defaults to `true`.
*/
scriptOnLoad?: boolean;
}
/**
@@ -55,7 +42,7 @@ export interface IFrameTemplateInit {
*/
export function iframeTemplate(
children: TemplateResult,
{ challengeURL, theme }: IFrameTemplateInit,
{ challengeURL, theme, scriptOnLoad = true }: IFrameTemplateInit,
) {
return createDocumentTemplate({
head: html`
@@ -108,7 +95,10 @@ export function iframeTemplate(
}
</style>
${children}
<script onload="loadListener()" src="${challengeURL}"></script>
<script
${scriptOnLoad ? 'onload="loadListener()"' : ""}
src="${challengeURL.toString()}"
></script>
`,
});
}
@@ -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,
});
@@ -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",
},
);
@@ -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",
},
);
-9
View File
@@ -1,9 +0,0 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="turnstile-types"/>
import { TurnstileObject } from "turnstile-types";
declare global {
interface Window {
turnstile: TurnstileObject;
}
}
@@ -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: [],
};
}),
+114 -10
View File
@@ -1,21 +1,125 @@
import { FlowExecutor } from "#flow/FlowExecutor";
import "#flow/FlowExecutor";
import { html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { resolveUITheme } from "#common/theme";
import { DeepPartial } from "#common/types";
import { AKElement } from "#elements/Base";
import { FlowChallengeLike } from "#flow/components/types";
import { ChallengeTypes, ContextualFlowInfoLayoutEnum, UiThemeEnum } from "@goauthentik/api";
import { StoryObj } from "@storybook/web-components";
import { deepmerge } from "deepmerge-ts";
import { StoryAnnotations } from "storybook/internal/csf";
import { html, PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-storybook-interface-flow")
export class StoryFlowInterface extends FlowExecutor {
public override firstUpdated() {
return Promise.resolve();
export class StoryFlowInterface extends AKElement {
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
public override submit = () => {
return Promise.resolve(true);
@property({ type: String, attribute: "slug", useDefault: true })
public flowSlug = "default-authentication-flow";
@property({ attribute: false })
public challenge: ChallengeTypes | null = null;
#synchronizeTheme = () => {
this.ownerDocument.documentElement.dataset.themeChoice = resolveUITheme(this.activeTheme);
};
async renderChallenge(): Promise<TemplateResult> {
return html`<slot></slot>`;
public override updated(changed: PropertyValues<this>): void {
if (changed.has("activeTheme")) {
this.#synchronizeTheme();
}
}
public override firstUpdated(changed: PropertyValues<this>): void {
super.firstUpdated(changed);
this.#synchronizeTheme();
}
protected render() {
return html`
<div class="pf-c-page__drawer">
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-flow-executor
class="pf-c-login"
.challenge=${this.challenge}
></ak-flow-executor>
</div>
</div>
</div>
</div>
</div>
`;
}
}
let backgroundSeed = Date.now();
let avatarSeed = backgroundSeed + 1;
function createChallenge<T extends FlowChallengeLike>(
component: ChallengeTypes["component"],
overrides?: DeepPartial<T>,
): T {
const challenge = deepmerge(
{
pendingUser: "Jessie Lorem",
pendingUserAvatar: `https://picsum.photos/seed/${avatarSeed++}/64`,
flowInfo: {
title: `<${component}>`,
layout: ContextualFlowInfoLayoutEnum.Stacked,
cancelUrl: "",
background: `https://picsum.photos/seed/${backgroundSeed++}/1920/1080`,
},
} satisfies FlowChallengeLike,
overrides,
);
return challenge as T;
}
export function flowFactory<C extends ChallengeTypes["component"]>(
component: C,
overrides?: DeepPartial<Extract<ChallengeTypes, { component: C }>>,
annotations?: StoryAnnotations,
): StoryObj<{ theme: UiThemeEnum }> {
const challenge = createChallenge<FlowChallengeLike>(component, overrides);
return {
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
args: {
theme: "automatic",
},
render: ({ theme }) => {
return html`<ak-storybook-interface-flow
theme=${theme}
.challenge=${{
component,
...challenge,
}}
>
</ak-storybook-interface-flow>`;
},
...annotations,
};
}
declare global {
+24 -2
View File
@@ -20,13 +20,16 @@ html {
}
.sbdocs.sbdocs-preview {
background: var(--ak-docs-preview-background, #fff) !important;
background: var(
--ak-global--background-image,
var(--ak-docs-preview-background, , #fff)
) !important;
}
@media (prefers-color-scheme: dark) {
:root {
--ak-base-background: hsl(260 26% 5%);
--ak-docs-preview-background: #18191a;
--ak-docs-preview-background: var(--ak-global--background-image, #18191a);
}
.sb-preparing-docs {
@@ -37,6 +40,25 @@ html {
}
}
.docs-story::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-attachment: local;
background-image: none;
z-index: 0;
background-image: var(--ak-global--background-image, none);
}
.sb-main-fullscreen::before {
display: none !important;
}
.sbdocs > h1,
.sbdocs-title {
border-bottom: 1px solid;
+5
View File
@@ -26,6 +26,11 @@ declare global {
*/
readonly AK_DOCS_PRE_RELEASE_URL: string;
/**
* The bundler used to build the application.
*/
readonly AK_BUNDLER: "authentik" | "storybook";
/**
* The current release notes URL.
*