web: Flesh out module driven tag names.

This commit is contained in:
Teffen Ellis
2026-02-06 07:23:00 +01:00
parent 52eef39e7e
commit c8d5832018
40 changed files with 353 additions and 197 deletions

View File

@@ -11,7 +11,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { AKFormErrors, ErrorProp } from "#components/ak-field-errors";
import { StageHost } from "#flow/stages/base";
import type { StageHost } from "#flow/types";
import { Prompt, PromptChallenge, PromptTypeEnum, StagesApi } from "@goauthentik/api";

View File

@@ -0,0 +1,41 @@
/**
* @file Type utilities for ESM modules.
*/
/**
* A type representing a resolved ES module with a default export of type `DefaultExport`.
*
* ```ts
* const mod: ResolvedESModule<MyType> = await import('./my-module.js');
* const myValue: MyType = mod.default;
* ```
*/
export interface ResolvedDefaultESModule<DefaultExport> {
default: DefaultExport;
}
/**
* A callback that returns a promise resolving to a module of type `T`.
*/
export type ImportCallback<T extends object> = () => Promise<T>;
/**
* A callback that returns a promise resolving to a module with a default export of type `T`.
*/
export type DefaultImportCallback<T = unknown> = ImportCallback<ResolvedDefaultESModule<T>>;
/**
* Asserts that the given module has a default export and is an object.
*
* @param mod The module to check.
* @throws {TypeError} If the module is not an object or does not have a default export.
*/
export function assertDefaultExport<T>(mod: unknown): asserts mod is ResolvedDefaultESModule<T> {
if (!mod || typeof mod !== "object") {
throw new TypeError("Module is not an object");
}
if (!Object.hasOwn(mod, "default")) {
throw new TypeError("Module does not have a default export");
}
}

View File

@@ -5,7 +5,6 @@ import "#flow/components/ak-brand-footer";
import "#flow/components/ak-flow-card";
import Styles from "./FlowExecutor.css" with { type: "bundled-text" };
import { stages } from "./FlowExecutorSelections";
import { DEFAULT_CONFIG } from "#common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
@@ -25,7 +24,9 @@ import { exportParts } from "#elements/utils/attributes";
import { ThemedImage } from "#elements/utils/images";
import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events";
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
import { readStageModuleTag, StageMappings } from "#flow/FlowExecutorSelections";
import { BaseStage } from "#flow/stages/base";
import type { FlowChallengeComponentName, StageHost, SubmitOptions } from "#flow/types";
import { ConsoleLogger } from "#logger/browser";
@@ -354,21 +355,22 @@ export class FlowExecutor
//#region Render Challenge
protected async renderChallenge(
component: ChallengeTypes["component"],
component: FlowChallengeComponentName,
): Promise<TemplateResult> {
const { challenge, inspectorOpen } = this;
const stage = stages.get(component);
const stage = StageMappings.get(component);
// The special cases!
if (!stage) {
if (component === "xak-flow-shell") {
return html`${unsafeHTML((challenge as ShellChallenge).body)}`;
}
return html`Invalid native challenge element`;
}
const challengeProps: LitPropertyRecord<BaseStage<NonNullable<typeof challenge>, unknown>> =
const challengeProps: LitPropertyRecord<BaseStage<NonNullable<typeof challenge>, object>> =
{ ".challenge": challenge!, ".host": this };
const litParts = {
@@ -376,13 +378,10 @@ export class FlowExecutor
exportparts: exportParts(["additional-actions", "footer-band"], "challenge"),
};
const { tag, variant, importfn } = stage;
if (importfn) {
await importfn();
}
const tag = await readStageModuleTag(stage);
const props = spread(
match(variant)
match(stage.variant)
.with("challenge", () => challengeProps)
.with("standard", () => ({ ...challengeProps, ...litParts }))
.with("inspect", () => ({ ...challengeProps, "?promptUser": inspectorOpen }))

View File

@@ -5,111 +5,148 @@ import "#flow/stages/FlowErrorStage";
import "#flow/stages/FlowFrameStage";
import "#flow/stages/RedirectStage";
import { isMatching, match, P } from "ts-pattern";
import type { UnwrapSet } from "#common/sets";
export const propVariants = ["standard", "challenge", "inspect"] as const;
type PropVariant = (typeof propVariants)[number];
import type { FlowChallengeComponentName, StageModuleCallback } from "#flow/types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ComponentImport = () => Promise<any>;
export const propVariants = new Set(["standard", "challenge", "inspect"] as const);
type PropVariant = UnwrapSet<typeof propVariants>;
// Any of these are valid variations, so you don't have to specify that this stage takes the
// "standard" set of props, or if the server-side stage token and the client-side component tag are
// the same you only need to specify one. Although the PropVariants and TagName are both strings on
// the wire, custom element tags always contain a dash and the PropVariants don't, making them
// easy to distinguish.
//
type BaseStage =
| [token: string, tag: string, variant: PropVariant]
| [token: string, variant: PropVariant]
| [token: string, tag: string]
| [token: string];
type RawStage =
| BaseStage
| [token: string, tag: string, variant: PropVariant, import: ComponentImport]
| [token: string, variant: PropVariant, import: ComponentImport]
| [token: string, tag: string, import: ComponentImport]
| [token: string, import: ComponentImport];
type Stage = {
tag: string;
variant: PropVariant;
importfn?: ComponentImport;
};
/**
* @remarks
* Any of these are valid variations, so you don't have to specify that this stage takes the
* "standard" set of props, or if the server-side stage token and the client-side component tag are
* the same you only need to specify one. Although the PropVariants and TagName are both strings on
* the wire, custom element tags always contain a dash and the PropVariants don't, making them
* easy to distinguish.
*/
type StageEntry =
| StageModuleCallback
| [PropVariant?, tagName?: string]
| [PropVariant, StageModuleCallback]
| null;
// Tables are allowed to go wide, just for readability. Sorting them alphabetically helps, too.
//
// prettier-ignore
const rawStages: RawStage[] = [
["ak-provider-iframe-logout", async () => await import("#flow/providers/IFrameLogoutStage")],
["ak-provider-oauth2-device-code", "ak-flow-provider-oauth2-code", async () => await import("#flow/providers/oauth2/DeviceCode")],
["ak-provider-oauth2-device-code-finish", "ak-flow-provider-oauth2-code-finish", async () => await import("#flow/providers/oauth2/DeviceCodeFinish")],
["ak-provider-saml-native-logout", async () => await import("#flow/providers/saml/NativeLogoutStage")],
["ak-source-oauth-apple", "ak-flow-source-oauth-apple"],
["ak-source-plex", "ak-flow-source-plex"],
["ak-source-telegram", "ak-flow-source-telegram"],
["ak-stage-access-denied", async () => await import("#flow/stages/access_denied/AccessDeniedStage")],
["ak-stage-authenticator-duo", async () => await import("#flow/stages/authenticator_duo/AuthenticatorDuoStage")],
["ak-stage-authenticator-email", async () => await import("#flow/stages/authenticator_email/AuthenticatorEmailStage")],
["ak-stage-authenticator-sms", async () => await import("#flow/stages/authenticator_sms/AuthenticatorSMSStage")],
["ak-stage-authenticator-static", async () => await import("#flow/stages/authenticator_static/AuthenticatorStaticStage")],
["ak-stage-authenticator-totp", async () => await import("#flow/stages/authenticator_totp/AuthenticatorTOTPStage")],
["ak-stage-authenticator-validate", async () => await import("#flow/stages/authenticator_validate/AuthenticatorValidateStage")],
["ak-stage-authenticator-webauthn"],
["ak-stage-autosubmit", async () => await import("#flow/stages/autosubmit/AutosubmitStage")],
["ak-stage-captcha", async () => await import("#flow/stages/captcha/CaptchaStage")],
["ak-stage-consent", async () => await import("#flow/stages/consent/ConsentStage")],
["ak-stage-dummy", async () => await import("#flow/stages/dummy/DummyStage")],
["ak-stage-email", async () => await import("#flow/stages/email/EmailStage")],
["ak-stage-endpoint-agent", "challenge", async () => await import("#flow/stages/endpoint/agent/EndpointAgentStage")],
["ak-stage-flow-error"],
["ak-stage-identification", async () => await import("#flow/stages/identification/IdentificationStage")],
["ak-stage-password", async () => await import("#flow/stages/password/PasswordStage")],
["ak-stage-prompt", async () => await import("#flow/stages/prompt/PromptStage")],
["ak-stage-session-end", async () => await import("#flow/providers/SessionEnd")],
["ak-stage-user-login", "challenge", async () => await import("#flow/stages/user_login/UserLoginStage")],
["xak-flow-redirect", "ak-stage-redirect", "inspect"],
["xak-flow-frame", "challenge"],
];
const StageModuleRecord: Record<FlowChallengeComponentName, StageEntry> = {
"ak-provider-iframe-logout": () => import("#flow/providers/IFrameLogoutStage"),
"ak-provider-oauth2-device-code-finish": () => import("#flow/providers/oauth2/DeviceCodeFinish"),
"ak-provider-oauth2-device-code": () => import("#flow/providers/oauth2/DeviceCode"),
"ak-provider-saml-native-logout": () => import("#flow/providers/saml/NativeLogoutStage"),
const isImport = isMatching(P.when((x): x is ComponentImport => typeof x === "function"));
"ak-stage-access-denied": () => import("#flow/stages/access_denied/AccessDeniedStage"),
"ak-stage-authenticator-duo": () => import("#flow/stages/authenticator_duo/AuthenticatorDuoStage"),
"ak-stage-authenticator-email": () => import("#flow/stages/authenticator_email/AuthenticatorEmailStage"),
"ak-stage-authenticator-sms": () => import("#flow/stages/authenticator_sms/AuthenticatorSMSStage"),
"ak-stage-authenticator-static": () => import("#flow/stages/authenticator_static/AuthenticatorStaticStage"),
"ak-stage-authenticator-totp": () => import("#flow/stages/authenticator_totp/AuthenticatorTOTPStage"),
"ak-stage-authenticator-validate": () => import("#flow/stages/authenticator_validate/AuthenticatorValidateStage"),
"ak-stage-autosubmit": () => import("#flow/stages/autosubmit/AutosubmitStage"),
"ak-stage-captcha": () => import("#flow/stages/captcha/CaptchaStage"),
"ak-stage-consent": () => import("#flow/stages/consent/ConsentStage"),
"ak-stage-dummy": () => import("#flow/stages/dummy/DummyStage"),
"ak-stage-email": () => import("#flow/stages/email/EmailStage"),
"ak-stage-identification": () => import("#flow/stages/identification/IdentificationStage"),
"ak-stage-password": () => import("#flow/stages/password/PasswordStage"),
"ak-stage-prompt": () => import("#flow/stages/prompt/PromptStage"),
"ak-stage-session-end": () => import("#flow/providers/SessionEnd"),
const PVariant = P.when(
(x): x is PropVariant => typeof x === "string" && propVariants.includes(x as PropVariant),
"ak-stage-endpoint-agent": ["challenge", () => import("#flow/stages/endpoint/agent/EndpointAgentStage")],
"ak-stage-user-login": ["challenge", () => import("#flow/stages/user_login/UserLoginStage")],
"ak-source-oauth-apple": ["standard", "ak-flow-source-oauth-apple"],
"ak-stage-authenticator-webauthn": [],
"ak-stage-flow-error": [],
"ak-source-plex": ["standard", "ak-flow-source-plex"],
"ak-source-telegram": ["standard", "ak-flow-source-telegram"],
"xak-flow-frame": ["challenge"],
"xak-flow-redirect": ["inspect", "ak-stage-redirect"],
"xak-flow-shell": null,
}
type StageMapping =
| {
variant: PropVariant;
tag: string;
importCallback: null;
}
| {
variant: PropVariant;
tag: null;
importCallback: StageModuleCallback;
};
/**
* A mapping of server-side stage tokens to client-side custom element tags, along with the variant of props they consume and an optional import callback for lazy-loading.
*
* @remarks
* This is the actual table of stages consumed by the FlowExecutor.
* It is generated from the more concise `StageModuleRecord` above, which is easier to read and maintain.
* The `StageModuleRecord` allows for specifying just the token, or the token and variant,
* or the token and tag, or all three, and it can also include an import callback if the stage should be lazy-loaded.
* The code below normalizes all of these possibilities into a consistent format that the FlowExecutor can use.
*/
export const StageMappings: ReadonlyMap<FlowChallengeComponentName, StageMapping | null> = new Map(
Object.entries(StageModuleRecord).map(
(foo): [FlowChallengeComponentName, StageMapping | null] => {
const [token, entry] = foo as [FlowChallengeComponentName, StageEntry | null];
if (entry === null) {
return [token, null];
}
if (typeof entry === "function") {
return [token, { tag: null, variant: "standard", importCallback: entry }];
}
const [variant = "standard", maybeTagOrImport = token] = entry;
if (typeof maybeTagOrImport === "function") {
return [token, { tag: null, variant, importCallback: maybeTagOrImport }];
}
return [token, { tag: maybeTagOrImport, variant, importCallback: null }];
},
),
);
// Don't have to type-check what you get from the tap.
const STANDARD = propVariants[0];
/**
* A cache for storing the resolved custom element tag names for stage mappings that require lazy-loading.
*/
const StageMappingTagNameCache = new WeakMap<StageMapping, string>();
type InStage = [token: string, tag: string, variant: PropVariant];
/**
* Given a stage mapping, returns the custom element tag name for that stage, loading the module if necessary.
*
* @param stageMapping The mapping for the stage, which may include a direct tag name or an import callback for lazy-loading.
* @returns The custom element tag name for the stage.
* @throws {TypeError} If the module fails to load or does not define a custom element.
*/
export async function readStageModuleTag(stageMapping: StageMapping): Promise<string> {
if (stageMapping.importCallback === null) {
return stageMapping.tag;
}
export const stages: Map<string, Stage> = new Map(
rawStages.map((rawstage: RawStage) => {
// The RawStages table above is clear and lacks the repetition of the original, but it does
// mean that doing all the pattern matching to normalize this to the format consumed by the
// FlowExecutor looks a little hairy. Repetition, defaults, and optional values can be
// ignored, making the table small and elegant, and this runs exactly once at start-time, so
// its run-time cost is well worth the savings. The use of `.exhaustive()` at the bottom
// guarantees that every variant specified in the `RawStage` type is handled.
//
// P.optional(PImport) does not work with tuples, but since it *is* optional and it *is* the
// last thing when it's present, we lop it off for the purpose of checking the rest, so the
// actual comparison table is for all the other variants, then put it back when we assemble
// the map. This eliminates half the variant checks.
//
const last = rawstage.at(-1);
const importfn = isImport(last) ? last : undefined;
const rest: BaseStage = (importfn ? rawstage.slice(0, -1) : rawstage) as BaseStage;
if (StageMappingTagNameCache.has(stageMapping)) {
return StageMappingTagNameCache.get(stageMapping)!;
}
// prettier-ignore
const [token, tag, variant] = match<BaseStage, InStage>(rest)
.with([P.string, P.string, PVariant], ([token, tag, variant]) => [token, tag, variant])
.with([P.string, PVariant], ([token, variant]) => [token, token, variant])
.with([P.string, P.string], ([token, tag]) => [token, tag, STANDARD])
.with([P.string], ([token])=> [token, token, STANDARD])
.exhaustive();
const module = await stageMapping.importCallback();
const StageConstructor = module.default;
const tag = window.customElements.getName(StageConstructor);
return [token, { tag, variant, importfn }];
}),
);
if (!tag) {
// eslint-disable-next-line no-console
console.trace(
`Failed to load stage module: no custom element found in module`,
stageMapping,
);
throw new TypeError("Failed to load stage module: no custom element found");
}
StageMappingTagNameCache.set(stageMapping, tag);
return tag;
}

View File

@@ -3,19 +3,7 @@ import { LitFC } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { isDefaultAvatar } from "#elements/utils/images";
import {
AccessDeniedChallenge,
AuthenticatorDuoChallenge,
AuthenticatorEmailChallenge,
AuthenticatorStaticChallenge,
AuthenticatorTOTPChallenge,
AuthenticatorWebAuthnChallenge,
CaptchaChallenge,
ConsentChallenge,
PasswordChallenge,
SessionEndChallenge,
UserLoginChallenge,
} from "@goauthentik/api";
import { StageChallengeLike } from "#flow/types";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing } from "lit";
@@ -110,26 +98,8 @@ export class AKFormStatic extends AKElement {
}
}
/**
* @internal
*/
export type FormStaticChallenge =
| SessionEndChallenge
| AccessDeniedChallenge
| AuthenticatorDuoChallenge
| AuthenticatorEmailChallenge
| AuthenticatorStaticChallenge
| AuthenticatorTOTPChallenge
| AuthenticatorWebAuthnChallenge
| CaptchaChallenge
| ConsentChallenge
| PasswordChallenge
| UserLoginChallenge;
export interface FlowUserDetailsProps {
challenge?: Partial<
Pick<FormStaticChallenge, "pendingUserAvatar" | "pendingUser" | "flowInfo">
> | null;
challenge?: StageChallengeLike | null;
}
export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {

View File

@@ -5,7 +5,7 @@ import Styles from "./ak-flow-card.css";
import { AKElement } from "#elements/Base";
import { SlottedTemplateResult } from "#elements/types";
import { FlowChallengeLike } from "#flow/components/types";
import { FormStaticChallenge } from "#flow/types";
import { CSSResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -27,7 +27,7 @@ export class FlowCard extends AKElement {
role = "presentation";
@property({ type: Object })
challenge?: Pick<FlowChallengeLike, "flowInfo">;
challenge?: Pick<FormStaticChallenge, "flowInfo">;
@property({ type: Boolean })
loading = false;

View File

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

View File

@@ -106,7 +106,7 @@ export class IFrameLogoutStage extends BaseStage<
`,
];
public override firstUpdated(changedProperties: PropertyValues): void {
public override firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
// Initialize status tracking
@@ -263,6 +263,8 @@ export class IFrameLogoutStage extends BaseStage<
}
}
export default IFrameLogoutStage;
declare global {
interface HTMLElementTagNameMap {
"ak-provider-iframe-logout": IFrameLogoutStage;

View File

@@ -69,6 +69,8 @@ export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
}
}
export default SessionEnd;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-session-end": SessionEnd;

View File

@@ -68,6 +68,8 @@ export class OAuth2DeviceCode extends BaseStage<
}
}
export default OAuth2DeviceCode;
declare global {
interface HTMLElementTagNameMap {
"ak-flow-provider-oauth2-code": OAuth2DeviceCode;

View File

@@ -25,6 +25,8 @@ export class DeviceCodeFinish extends BaseStage<
}
}
export default DeviceCodeFinish;
declare global {
interface HTMLElementTagNameMap {
"ak-flow-provider-oauth2-code-finish": DeviceCodeFinish;

View File

@@ -31,7 +31,7 @@ export class NativeLogoutStage extends BaseStage<
public static styles: CSSResult[] = [PFLogin, PFForm, PFButton, PFFormControl, PFTitle];
public override firstUpdated(changedProperties: PropertyValues): void {
public override firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
if (!this.challenge) {
@@ -121,6 +121,8 @@ export class NativeLogoutStage extends BaseStage<
}
}
export default NativeLogoutStage;
declare global {
interface HTMLElementTagNameMap {
"ak-provider-saml-native-logout": NativeLogoutStage;

View File

@@ -74,6 +74,8 @@ export class AppleLoginInit extends BaseStage<AppleLoginChallenge, AppleChalleng
}
}
export default AppleLoginInit;
declare global {
interface HTMLElementTagNameMap {
"ak-flow-source-oauth-apple": AppleLoginInit;

View File

@@ -87,6 +87,8 @@ export class PlexLoginInit extends BaseStage<
}
}
export default PlexLoginInit;
declare global {
interface HTMLElementTagNameMap {
"ak-flow-source-plex": PlexLoginInit;

View File

@@ -72,6 +72,8 @@ export class TelegramLogin extends BaseStage<
}
}
export default TelegramLogin;
declare global {
interface HTMLElementTagNameMap {
"ak-flow-source-telegram": TelegramLogin;

View File

@@ -68,6 +68,8 @@ export class FlowErrorStage extends BaseStage<FlowErrorChallenge, FlowChallengeR
}
}
export default FlowErrorStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-flow-error": FlowErrorStage;

View File

@@ -41,6 +41,8 @@ export class FlowFrameStage extends BaseStage<FrameChallenge, FrameChallengeResp
}
}
export default FlowFrameStage;
declare global {
interface HTMLElementTagNameMap {
"xak-flow-frame": FlowFrameStage;

View File

@@ -118,6 +118,8 @@ export class RedirectStage extends BaseStage<RedirectChallenge, FlowChallengeRes
}
}
export default RedirectStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-redirect": RedirectStage;

View File

@@ -55,6 +55,8 @@ export class AccessDeniedStage extends BaseStage<
}
}
export default AccessDeniedStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-access-denied": AccessDeniedStage;

View File

@@ -103,6 +103,8 @@ export class AuthenticatorDuoStage extends BaseStage<
}
}
export default AuthenticatorDuoStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-duo": AuthenticatorDuoStage;

View File

@@ -142,6 +142,8 @@ export class AuthenticatorEmailStage extends BaseStage<
}
}
export default AuthenticatorEmailStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-email": AuthenticatorEmailStage;

View File

@@ -129,6 +129,8 @@ export class AuthenticatorSMSStage extends BaseStage<
}
}
export default AuthenticatorSMSStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-sms": AuthenticatorSMSStage;

View File

@@ -81,6 +81,8 @@ export class AuthenticatorStaticStage extends BaseStage<
}
}
export default AuthenticatorStaticStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-static": AuthenticatorStaticStage;

View File

@@ -190,6 +190,8 @@ export class AuthenticatorTOTPStage extends BaseStage<
}
}
export default AuthenticatorTOTPStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-totp": AuthenticatorTOTPStage;

View File

@@ -7,8 +7,9 @@ import Styles from "./AuthenticatorValidateStage.css";
import { DEFAULT_CONFIG } from "#common/api/config";
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
import { BaseStage } from "#flow/stages/base";
import { PasswordManagerPrefill } from "#flow/stages/identification/IdentificationStage";
import type { StageHost, SubmitOptions } from "#flow/types";
import {
AuthenticatorValidationChallenge,
@@ -313,6 +314,8 @@ export class AuthenticatorValidateStage
}
}
export default AuthenticatorValidateStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-validate": AuthenticatorValidateStage;

View File

@@ -1,4 +1,5 @@
import { BaseStage, FlowInfoChallenge, PendingUserChallenge } from "#flow/stages/base";
import { BaseStage } from "#flow/stages/base";
import { StageChallengeLike } from "#flow/types";
import { DeviceChallenge } from "@goauthentik/api";
@@ -13,10 +14,7 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
export class BaseDeviceStage<
Tin extends FlowInfoChallenge & PendingUserChallenge,
Tout,
> extends BaseStage<Tin, Tout> {
export class BaseDeviceStage<Tin extends StageChallengeLike, Tout> extends BaseStage<Tin, Tout> {
@property({ attribute: false })
deviceChallenge?: DeviceChallenge;

View File

@@ -51,6 +51,8 @@ export class AutosubmitStage extends BaseStage<
}
}
export default AutosubmitStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-autosubmit": AutosubmitStage;

View File

@@ -8,28 +8,13 @@ import { WithLocale } from "#elements/mixins/locale";
import { FocusTarget } from "#elements/utils/focus";
import { FlowUserDetails } from "#flow/FormStatic";
import { IBaseStage, StageChallengeLike, StageHost } from "#flow/types";
import { ConsoleLogger } from "#logger/browser";
import { ContextualFlowInfo, CurrentBrand, ErrorDetail } from "@goauthentik/api";
import { html, LitElement, nothing, PropertyValues } from "lit";
import { html, nothing, PropertyValues } from "lit";
import { property } from "lit/decorators.js";
export interface SubmitOptions {
invisible: boolean;
}
export interface StageHost {
challenge?: unknown;
flowSlug?: string;
loading: boolean;
reset?: () => void;
submit(payload: unknown, options?: SubmitOptions): Promise<boolean>;
readonly brand?: CurrentBrand;
}
export function readFileAsync(file: Blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -41,22 +26,7 @@ export function readFileAsync(file: Blob) {
});
}
// Challenge which contains flow info
export interface FlowInfoChallenge {
flowInfo?: ContextualFlowInfo;
}
// Challenge which has a pending user
export interface PendingUserChallenge {
pendingUser?: string;
pendingUserAvatar?: string;
}
export interface ResponseErrorsChallenge {
responseErrors?: {
[key: string]: ErrorDetail[];
};
}
/**
* Base class for all flow stages.
@@ -65,12 +35,12 @@ export interface ResponseErrorsChallenge {
* @prop {StageHost} host The host managing this stage.
* @prop {Tin} challenge The challenge provided to this stage.
*/
export abstract class BaseStage<
Tin extends FlowInfoChallenge & PendingUserChallenge & ResponseErrorsChallenge,
Tout,
> extends WithLocale(AKElement) {
export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
extends WithLocale(AKElement)
implements IBaseStage<Tin, Tout>
{
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
...AKElement.shadowRootOptions,
delegatesFocus: true,
};

View File

@@ -559,6 +559,8 @@ export class CaptchaStage
}
}
export default CaptchaStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-captcha": CaptchaStage;

View File

@@ -141,6 +141,8 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
}
}
export default ConsentStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-consent": ConsentStage;

View File

@@ -38,6 +38,8 @@ export class DummyStage extends BaseStage<DummyChallenge, DummyChallengeResponse
}
}
export default DummyStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-dummy": DummyStage;

View File

@@ -40,6 +40,8 @@ export class EmailStage extends BaseStage<EmailChallenge, EmailChallengeResponse
}
}
export default EmailStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-email": EmailStage;

View File

@@ -85,6 +85,8 @@ export class EndpointAgentStage extends BaseStage<
}
}
export default EndpointAgentStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-endpoint-agent": EndpointAgentStage;

View File

@@ -548,6 +548,8 @@ export class IdentificationStage extends BaseStage<
//#endregion
}
export default IdentificationStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-identification": IdentificationStage;

View File

@@ -82,6 +82,8 @@ export class PasswordStage extends BaseStage<PasswordChallenge, PasswordChalleng
}
}
export default PasswordStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-password": PasswordStage;

View File

@@ -42,6 +42,8 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
export class PromptStage extends WithCapabilitiesConfig(
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
) {
static shadowRootOptions: ShadowRootInit = BaseStage.shadowRootOptions;
static styles: CSSResult[] = [
PFLogin,
PFAlert,
@@ -307,6 +309,8 @@ ${prompt.initialValue}</textarea
}
}
export default PromptStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-prompt": PromptStage;

View File

@@ -64,6 +64,8 @@ export class PasswordStage extends BaseStage<
}
}
export default PasswordStage;
declare global {
interface HTMLElementTagNameMap {
"ak-stage-user-login": PasswordStage;

93
web/src/flow/types.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* @file Types related to flow stages.
*/
import { DefaultImportCallback } from "#common/modules/types";
import type {
AccessDeniedChallenge,
AuthenticatorDuoChallenge,
AuthenticatorEmailChallenge,
AuthenticatorStaticChallenge,
AuthenticatorTOTPChallenge,
AuthenticatorWebAuthnChallenge,
CaptchaChallenge,
ChallengeTypes,
ConsentChallenge,
CurrentBrand,
PasswordChallenge,
SessionEndChallenge,
UserLoginChallenge,
} from "@goauthentik/api";
import { ReactiveControllerHost } from "lit";
/**
* 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>;
export type FlowChallengeComponentName = ChallengeTypes["component"];
/**
* @internal
*/
export type FormStaticChallenge =
| SessionEndChallenge
| AccessDeniedChallenge
| AuthenticatorDuoChallenge
| AuthenticatorEmailChallenge
| AuthenticatorStaticChallenge
| AuthenticatorTOTPChallenge
| AuthenticatorWebAuthnChallenge
| CaptchaChallenge
| ConsentChallenge
| PasswordChallenge
| UserLoginChallenge;
export type StageChallengeLike = Partial<
Pick<FormStaticChallenge, "pendingUserAvatar" | "pendingUser" | "flowInfo" | "responseErrors">
>;
export interface SubmitOptions {
invisible: boolean;
}
export interface StageHost {
challenge?: unknown;
flowSlug?: string;
loading: boolean;
reset?: () => void;
submit(payload: unknown, options?: SubmitOptions): Promise<boolean>;
readonly brand?: CurrentBrand;
}
export interface IBaseStage<Tin extends StageChallengeLike, Tout = never>
extends HTMLElement, ReactiveControllerHost {
host?: StageHost;
challenge: Tin | null;
submitForm: (event?: SubmitEvent, defaults?: Tout) => Promise<boolean>;
reset?(): void;
}
export type BaseStageConstructor<
Tin extends StageChallengeLike = StageChallengeLike,
Tout = never,
> = new () => IBaseStage<Tin, Tout>;
export type StageModuleConstructor<
Tin extends StageChallengeLike = StageChallengeLike,
Tout = never,
> = DefaultImportCallback<BaseStageConstructor<Tin, Tout>>;
/**
* A type representing an ES module that exports a stage constructor as its default export.
* This is used for dynamic imports of stages.
*/
export type StageModuleCallback = DefaultImportCallback<BaseStageConstructor>;

View File

@@ -5,7 +5,7 @@ import { DeepPartial } from "#common/types";
import { AKElement } from "#elements/Base";
import { FlowChallengeLike } from "#flow/components/types";
import { FlowChallengeLike } from "#flow/types";
import { ChallengeTypes, ContextualFlowInfoLayoutEnum, UiThemeEnum } from "@goauthentik/api";

View File

@@ -11,7 +11,7 @@ import { WithBrandConfig } from "#elements/mixins/branding";
import { WithSession } from "#elements/mixins/session";
import { SlottedTemplateResult } from "#elements/types";
import { StageHost } from "#flow/stages/base";
import type { StageHost } from "#flow/types";
import {
ChallengeTypes,