Compare commits

...

17 Commits

Author SHA1 Message Date
Teffen Ellis
8a64762dd6 Merge branch 'fix-flow-advance-event' into 2025-12-release-fixes-v4 2026-01-09 17:29:14 +01:00
Teffen Ellis
61f42de07e web: Fix flow inspector advancement event. 2026-01-09 17:26:53 +01:00
Teffen Ellis
e759d0d7d9 Merge branch '2025-12-flow-info' into 2025-12-release-fixes-v4 2026-01-08 23:44:23 +01:00
Teffen Ellis
ade566126b Fix linter warnings. 2026-01-08 23:44:04 +01:00
Teffen Ellis
024def44e5 Merge branch '2025-12-locale-fixes' into 2025-12-release-fixes-v4 2026-01-08 01:26:56 +01:00
Teffen Ellis
429da7242f Merge branch '2025-12-flow-info' into 2025-12-release-fixes-v4 2026-01-08 01:26:15 +01:00
Teffen Ellis
a7ebd6dc1d Merge branch '2025-12-fixes-images' into 2025-12-release-fixes-v4 2026-01-08 01:25:57 +01:00
Teffen Ellis
c6d94e9a7e Remove legacy XLF files. 2026-01-08 01:03:07 +01:00
Teffen Ellis
400c0f56e8 Update locale after changing user settings. 2026-01-08 00:53:55 +01:00
Teffen Ellis
617eaf1175 Fix stale locale on API provided values. 2026-01-08 00:53:01 +01:00
Teffen Ellis
b6c559c05a Fix context cache lifecycle, compatibility. 2026-01-08 00:52:56 +01:00
Teffen Ellis
efca157d80 Add fallback weights to accept language header. 2026-01-08 00:52:50 +01:00
Teffen Ellis
464b4d2bb2 Clean up flow user details. 2026-01-08 00:50:04 +01:00
Teffen Ellis
bbdf9df3fd Add back button to denied stage. 2026-01-08 00:49:59 +01:00
Teffen Ellis
fb2355b4db Localize email sent message. 2026-01-08 00:49:54 +01:00
Teffen Ellis
0bdd97530c Fix low-resolution icon scaling. 2026-01-08 00:46:25 +01:00
Teffen Ellis
9966cfedf3 Fix referencing of theme directly from element, rather than the root. 2026-01-08 00:46:17 +01:00
56 changed files with 612 additions and 118124 deletions

View File

@@ -6,7 +6,7 @@ import { globalAK } from "#common/global";
import { ModalButton } from "#elements/buttons/ModalButton";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithLicenseSummary } from "#elements/mixins/license";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";
import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthentik/api";
@@ -95,11 +95,12 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
aria-labelledby="modal-title"
>
<div class="pf-c-about-modal-box__brand">
${renderImage(
this.brandingFavicon,
msg("authentik Logo"),
"pf-c-about-modal-box__brand-image",
)}
${ThemedImage({
src: this.brandingFavicon,
alt: msg("authentik Logo"),
className: "pf-c-about-modal-box__brand-image",
theme: this.activeTheme,
})}
</div>
<div class="pf-c-about-modal-box__close">
<button class="pf-c-button pf-m-plain" type="button" @click=${this.close}>

View File

@@ -3,5 +3,5 @@ import type { TypeCreate } from "@goauthentik/api";
import { createContext } from "@lit/context";
export const applicationWizardProvidersContext = createContext<TypeCreate[]>(
Symbol.for("ak-application-wizard-providers-context"),
Symbol("ak-application-wizard-providers-context"),
);

View File

@@ -1,5 +1,5 @@
import { AKRequestPostEvent, APIRequestInfo } from "#common/api/events";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { formatAcceptLanguageHeader } from "#common/ui/locale/utils";
import { getCookie } from "#common/utils";
import { ConsoleLogger, Logger } from "#logger/browser";
@@ -74,11 +74,11 @@ export class LocaleMiddleware implements Middleware, Disposable {
return;
}
this.#locale = event.detail.readyLocale;
this.#locale = formatAcceptLanguageHeader(event.detail.readyLocale);
};
constructor(localeHint?: string) {
this.#locale = autoDetectLanguage(localeHint);
constructor(languageTagHint: Intl.UnicodeBCP47LocaleIdentifier) {
this.#locale = formatAcceptLanguageHeader(languageTagHint);
window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
}

View File

@@ -338,7 +338,7 @@ export function applyBackgroundImageProperty(
/**
* Returns the root interface element of the page.
*
* @todo Can this be handled with a Lit Mixin?
* @deprecated Use context controllers to access the interface root instead.
*/
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
const element = document.body.querySelector<T>("[data-test-id=interface-root]");

View File

@@ -9,8 +9,6 @@ import {
import { safeParseLocale } from "#common/ui/locale/utils";
import { msg, str } from "@lit/localize";
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
/**
* Safely get a minimized locale ID, with fallback for older browsers.
@@ -198,34 +196,41 @@ export function formatLocaleDisplayNames(
return entries.sort(createIntlCollator(activeLocaleTag, collatorOptions));
}
export function renderLocaleDisplayNames(
entries: LocaleDisplay[],
activeLocaleTag: TargetLanguageTag | null,
export function formatRelativeLocaleDisplayName(
languageTag: TargetLanguageTag,
localizedDisplayName: string,
relativeDisplayName: string,
) {
return repeat(
entries,
([languageTag]) => languageTag,
([languageTag, localizedDisplayName, relativeDisplayName]) => {
const pseudo = languageTag === PseudoLanguageTag;
const pseudo = languageTag === PseudoLanguageTag;
const same =
relativeDisplayName &&
normalizeDisplayName(relativeDisplayName) ===
normalizeDisplayName(localizedDisplayName);
const same =
relativeDisplayName &&
normalizeDisplayName(relativeDisplayName) === normalizeDisplayName(localizedDisplayName);
let localizedMessage = localizedDisplayName;
if (same || pseudo) {
return localizedDisplayName;
}
if (!same && !pseudo) {
localizedMessage = msg(str`${relativeDisplayName} (${localizedDisplayName})`, {
id: "locale-option-localized-label",
desc: "Locale option label showing the localized language name along with the native language name in parentheses.",
});
}
return html`${pseudo ? html`<hr />` : null}
<option value=${languageTag} ?selected=${languageTag === activeLocaleTag}>
${localizedMessage}
</option>`;
},
);
return msg(str`${relativeDisplayName} (${localizedDisplayName})`, {
id: "locale-option-localized-label",
desc: "Locale option label showing the localized language name along with the native language name in parentheses.",
});
}
/**
* Format the display name for the auto-detect locale option.
*
* @param detectedLocale The detected locale display, if any.
*/
export function formatAutoDetectLocaleDisplayName(detectedLocale?: LocaleDisplay | null) {
const prefix = msg("Auto-detect", {
id: "locale-auto-detect-option",
desc: "Label for the auto-detect locale option in language selection dropdown",
});
if (!detectedLocale) {
return prefix;
}
return `${prefix} (${formatRelativeLocaleDisplayName(...detectedLocale)})`;
}

View File

@@ -157,8 +157,8 @@ export function getSessionLocale(): string | null {
/**
* Auto-detect the best locale to use from several sources.
*
* @param localeHint An optional locale code hint.
* @param fallbackLocaleCode An optional fallback locale code.
* @param languageTagHint An optional locale code hint.
* @param fallbackLanguageTag An optional fallback locale code.
* @returns The best-matching supported locale code.
*
* @remarks
@@ -172,8 +172,8 @@ export function getSessionLocale(): string | null {
* 6. The source locale (English)
*/
export function autoDetectLanguage(
localeHint?: string,
fallbackLocaleCode?: string,
languageTagHint?: Intl.UnicodeBCP47LocaleIdentifier,
fallbackLanguageTag?: Intl.UnicodeBCP47LocaleIdentifier,
): TargetLanguageTag {
let localeParam: string | null = null;
@@ -188,9 +188,9 @@ export function autoDetectLanguage(
const candidates = [
localeParam,
sessionLocale,
localeHint,
self.navigator?.language,
fallbackLocaleCode,
languageTagHint,
...(self.navigator?.languages || []),
fallbackLanguageTag,
].filter((item): item is string => !!item);
const firstSupportedLocale = findSupportedLocale(candidates);
@@ -198,8 +198,8 @@ export function autoDetectLanguage(
if (!firstSupportedLocale) {
console.debug(`authentik/locale: Falling back to source locale`, {
SourceLanguageTag,
localeHint,
fallbackLocaleCode,
languageTagHint,
fallbackLanguageTag,
candidates,
});
@@ -208,3 +208,28 @@ export function autoDetectLanguage(
return firstSupportedLocale;
}
/**
* Given a locale code, format it for use in an `Accept-Language` header.
*/
export function formatAcceptLanguageHeader(languageTag: Intl.UnicodeBCP47LocaleIdentifier): string {
const [preferredLanguageTag, ...languageTags] = new Set([
languageTag,
...(self.navigator?.languages || []),
SourceLanguageTag,
"*",
]);
const fallbackCount = languageTags.length;
return [
preferredLanguageTag,
...languageTags.map((tag, idx) => {
const weight = ((fallbackCount - idx) / (fallbackCount + 1)).toFixed(
fallbackCount > 9 ? 2 : 1,
);
return `${tag};q=${weight}`;
}),
].join(", ");
}

View File

@@ -7,7 +7,7 @@ import { AKElement } from "#elements/Base";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithSession } from "#elements/mixins/session";
import { isAdminRoute } from "#elements/router/utils";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";
import { msg } from "@lit/localize";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
@@ -377,7 +377,11 @@ export class AKPageNavbar
<aside role="presentation" class="brand ${this.open ? "" : "pf-m-collapsed"}">
<a aria-label="${msg("Home")}" href="#/">
<div class="logo">
${renderImage(this.brandingLogo, msg("authentik Logo"), "")}
${ThemedImage({
src: this.brandingLogo,
alt: msg("authentik Logo"),
theme: this.activeTheme,
})}
</div>
</a>
</aside>

View File

@@ -3,5 +3,5 @@ import type { WizardStepState } from "./types.js";
import { createContext } from "@lit/context";
export const wizardStepContext = createContext<WizardStepState>(
Symbol.for("authentik-wizard-step-labels"),
Symbol("authentik-wizard-step-labels"),
);

View File

@@ -3,16 +3,18 @@ import { NotificationsContextController } from "#elements/controllers/Notificati
import { SessionContextController } from "#elements/controllers/SessionContextController";
import { VersionContextController } from "#elements/controllers/VersionContextController";
import { Interface } from "#elements/Interface";
import { LicenseContext } from "#elements/mixins/license";
import { NotificationsContext } from "#elements/mixins/notifications";
import { SessionContext } from "#elements/mixins/session";
import { VersionContext } from "#elements/mixins/version";
export class AuthenticatedInterface extends Interface {
constructor() {
super();
this.addController(new LicenseContextController(this));
this.addController(new LicenseContextController(this), LicenseContext);
this.addController(new SessionContextController(this), SessionContext);
this.addController(new VersionContextController(this));
this.addController(new VersionContextController(this), VersionContext);
this.addController(new NotificationsContextController(this), NotificationsContext);
}
}

View File

@@ -7,7 +7,9 @@ import { ConfigContextController } from "#elements/controllers/ConfigContextCont
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
import { LocaleContextController } from "#elements/controllers/LocaleContextController";
import { ModalOrchestrationController } from "#elements/controllers/ModalOrchestrationController";
import { ReactiveContextController } from "#elements/types";
import { ReactiveContextController } from "#elements/controllers/ReactiveContextController";
import { BrandingContext } from "#elements/mixins/branding";
import { AuthentikConfigContext } from "#elements/mixins/config";
import { Context, ContextType } from "@lit/context";
import { ReactiveController } from "lit";
@@ -16,6 +18,14 @@ import { ReactiveController } from "lit";
* The base interface element for the application.
*/
export abstract class Interface extends AKElement {
/**
* Private map of controllers to their registry keys.
*
* This is used to track which controllers have been registered,
* and to unregister them when removed.
*/
#registryKeys = new WeakMap<ReactiveController, ContextType<Context<unknown, unknown>>>();
constructor() {
super();
@@ -24,24 +34,44 @@ export abstract class Interface extends AKElement {
createUIThemeEffect(applyDocumentTheme);
this.addController(new LocaleContextController(this, locale));
this.addController(new ConfigContextController(this, config));
this.addController(new BrandingContextController(this, brand));
this.addController(new ConfigContextController(this, config), AuthentikConfigContext);
this.addController(new BrandingContextController(this, brand), BrandingContext);
this.addController(new ModalOrchestrationController());
this.dataset.testId = "interface-root";
}
public override addController(
controller: ReactiveController,
registryKey?: ContextType<Context<unknown, unknown>>,
): void {
if (controller instanceof ReactiveContextController) {
if (!registryKey) {
throw new TypeError(
`ReactiveContextController (${controller.constructor.name}) requires a registry key.`,
);
}
if (this.#registryKeys.has(controller)) {
throw new Error(
`Controller (${controller.constructor.name}) is already registered.`,
);
}
this.#registryKeys.set(controller, registryKey);
ContextControllerRegistry.set(registryKey, controller);
}
super.addController(controller);
}
public override removeController(controller: ReactiveController): void {
super.removeController(controller);
const registryKey = this.#registryKeys.get(controller);
if (registryKey) {
ContextControllerRegistry.set(registryKey, controller as ReactiveContextController);
ContextControllerRegistry.delete(registryKey);
this.#registryKeys.delete(controller);
}
}
public connectedCallback(): void {
super.connectedCallback();
this.dataset.testId = "interface-root";
}
}

View File

@@ -1,5 +1,34 @@
import { type ContextControllerRegistryMap } from "#elements/types";
/**
* Check if the environment supports Symbol-keyed WeakMaps.
*
* @see {@link https://caniuse.com/mdn-javascript_builtins_weakmap_symbol_as_keys | Can I use}
*
* @todo Re-evaluate browser coverage after 2027-01-01
*/
function supportsSymbolKeyedWeakMap(): boolean {
const testKey = Symbol("test");
const wm = new WeakMap();
try {
wm.set(testKey, "value");
return wm.has(testKey);
} catch (_error) {
return false;
}
}
/**
* A constructor for either WeakMap or Map, depending on environment support.
*
* @remarks
*
* A preference for `WeakMap` is optional at the moment.
* However, if we ever support short-lived context controllers, such as
*/
const ContextControllerConstructor = supportsSymbolKeyedWeakMap() ? WeakMap : Map;
/**
* A registry of context controllers added to the Interface.
*
@@ -9,4 +38,5 @@ import { type ContextControllerRegistryMap } from "#elements/types";
*
* This is exported separately to avoid circular dependencies.
*/
export const ContextControllerRegistry = new WeakMap() as ContextControllerRegistryMap;
export const ContextControllerRegistry =
new ContextControllerConstructor() as ContextControllerRegistryMap;

View File

@@ -1,9 +1,10 @@
import { TargetLanguageTag } from "#common/ui/locale/definitions";
import { formatLocaleDisplayNames, renderLocaleDisplayNames } from "#common/ui/locale/format";
import { formatLocaleDisplayNames } from "#common/ui/locale/format";
import { setSessionLocale } from "#common/ui/locale/utils";
import { AKElement } from "#elements/Base";
import Styles from "#elements/locale/ak-locale-select.css";
import { LocaleOptions } from "#elements/locale/utils";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithLocale } from "#elements/mixins/locale";
@@ -159,7 +160,7 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement)
class="pf-c-form-control ak-m-capitalize"
name="locale"
>
${renderLocaleDisplayNames(entries, activeLocaleTag)}
${LocaleOptions({ entries, activeLocaleTag })}
</select>`;
});
}

View File

@@ -0,0 +1,36 @@
import { PseudoLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions";
import { formatRelativeLocaleDisplayName, LocaleDisplay } from "#common/ui/locale/format";
import type { LitFC, SlottedTemplateResult } from "#elements/types";
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
export interface LocaleOptionsProps {
entries: Iterable<LocaleDisplay>;
activeLocaleTag: TargetLanguageTag | null;
}
/**
* Render locale display name options for a select element.
*/
export const LocaleOptions: LitFC<LocaleOptionsProps> = ({ entries, activeLocaleTag }) => {
return repeat(
entries,
([languageTag]) => languageTag,
([languageTag, localizedDisplayName, relativeDisplayName]) => {
const pseudo = languageTag === PseudoLanguageTag;
const localizedMessage = formatRelativeLocaleDisplayName(
languageTag,
localizedDisplayName,
relativeDisplayName,
);
return html`${pseudo ? html`<hr />` : null}
<option value=${languageTag} ?selected=${languageTag === activeLocaleTag}>
${localizedMessage}
</option>`;
},
) as SlottedTemplateResult;
};

View File

@@ -13,9 +13,7 @@ import { consume, Context, createContext } from "@lit/context";
* @see {@linkcode BrandingMixin}
* @see {@linkcode WithBrandConfig}
*/
export const BrandingContext = createContext<CurrentBrand>(
Symbol.for("authentik-branding-context"),
);
export const BrandingContext = createContext<CurrentBrand>(Symbol("authentik-branding-context"));
export type BrandingContext = Context<symbol, CurrentBrand>;

View File

@@ -13,7 +13,7 @@ export const kAKConfig = Symbol("kAKConfig");
* @see {@linkcode AKConfigMixin}
* @see {@linkcode WithAuthentikConfig}
*/
export const AuthentikConfigContext = createContext<Config>(Symbol.for("authentik-config-context"));
export const AuthentikConfigContext = createContext<Config>(Symbol("authentik-config-context"));
export type AuthentikConfigContext = Context<symbol, Config>;

View File

@@ -4,9 +4,7 @@ import { type LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api"
import { consume, Context, createContext } from "@lit/context";
export const LicenseContext = createContext<LicenseSummary>(
Symbol.for("authentik-license-context"),
);
export const LicenseContext = createContext<LicenseSummary>(Symbol("authentik-license-context"));
export type LicenseContext = Context<symbol, LicenseSummary>;

View File

@@ -16,9 +16,7 @@ export const kAKLocale = Symbol("kAKLocale");
* @see {@linkcode LocaleMixin}
* @see {@linkcode WithLocale}
*/
export const LocaleContext = createContext<LocaleContextValue>(
Symbol.for("authentik-locale-context"),
);
export const LocaleContext = createContext<LocaleContextValue>(Symbol("authentik-locale-context"));
export type LocaleContext = typeof LocaleContext;

View File

@@ -13,9 +13,7 @@ import { property } from "lit/decorators.js";
* @see {@linkcode WithVersion}
*/
export const VersionContext = createContext<Version | null>(
Symbol.for("authentik-version-context"),
);
export const VersionContext = createContext<Version | null>(Symbol("authentik-version-context"));
export type VersionContext = typeof VersionContext;

View File

@@ -100,6 +100,7 @@ export interface ContextControllerRegistryMap {
key: ContextType<T>,
controller: ReactiveContextController<T, object>,
): void;
delete<T extends Context<unknown, unknown>>(key: ContextType<T>): void;
}
export interface ReactiveControllerHostRegistry extends ReactiveControllerHost {

View File

@@ -1,58 +1,48 @@
import { resolveUITheme, rootInterface } from "#common/theme";
import { ResolvedUITheme } from "#common/theme";
import type { AKElement } from "#elements/Base";
import type { SlottedTemplateResult } from "#elements/types";
import type { LitFC } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { spread } from "@open-wc/lit-helpers";
import { ImgHTMLAttributes } from "react";
import { html, nothing } from "lit";
export const FontAwesomeProtocol = "fa://";
export function themeImage(rawPath: string) {
const enabledTheme = rootInterface<AKElement>()?.activeTheme || resolveUITheme();
return rawPath.replaceAll("%(theme)s", enabledTheme);
export function themeImage(rawPath: string, theme: ResolvedUITheme) {
return rawPath.replaceAll("%(theme)s", theme);
}
/**
* Renders an image that can be a regular URL, Font Awesome icon (fa://), or themed image
*
* @param imagePath - URL, fa:// icon, or path with %(theme)s placeholder
* @param alt - Alt text for the image
* @param className - CSS classes to apply
* @returns TemplateResult with either <img> or <i> element
*/
export function renderImage(
imagePath: string,
alt?: string,
className?: string,
): SlottedTemplateResult {
if (!imagePath) {
export interface ThemedImageProps extends ImgHTMLAttributes<HTMLImageElement> {
/**
* The image path, which can be:
* - A regular URL
* - A Font Awesome icon (fa://icon-name)
* - A themed image path with %(theme)s placeholder
*/
src: string;
theme: ResolvedUITheme;
}
export const ThemedImage: LitFC<ThemedImageProps> = ({ src, className, theme, ...props }) => {
if (!src) {
return nothing;
}
// Handle Font Awesome icons (same logic as ak-app-icon)
if (imagePath.startsWith(FontAwesomeProtocol)) {
const classes = [
className,
"font-awesome",
"fas",
imagePath.slice(FontAwesomeProtocol.length),
]
if (src.startsWith(FontAwesomeProtocol)) {
const classes = [className, "font-awesome", "fas", src.slice(FontAwesomeProtocol.length)]
.filter(Boolean)
.join(" ");
return html`<i
part="icon font-awesome"
role="img"
aria-label=${ifPresent(alt)}
class=${classes}
></i>`;
return html`<i part="icon font-awesome" role="img" class=${classes} ${spread(props)}></i>`;
}
const src = themeImage(imagePath);
const themedSrc = themeImage(src, theme);
return html`<img src=${src} alt=${ifPresent(alt)} class=${ifPresent(className)} />`;
}
return html`<img src=${themedSrc} class=${ifPresent(className)} ${spread(props)} />`;
};
export function isDefaultAvatar(path?: string | null): boolean {
return !!path?.endsWith("user_default.png");

View File

@@ -26,7 +26,7 @@ import { WithBrandConfig } from "#elements/mixins/branding";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { LitPropertyRecord } from "#elements/types";
import { exportParts } from "#elements/utils/attributes";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";
import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events";
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
@@ -44,7 +44,7 @@ import {
import { spread } from "@open-wc/lit-helpers";
import { msg } from "@lit/localize";
import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
@@ -85,20 +85,26 @@ export class FlowExecutor
@property({ type: String, attribute: "slug", useDefault: true })
public flowSlug: string = window.location.pathname.split("/")[3];
#challenge?: ChallengeTypes;
#challenge: ChallengeTypes | null = null;
@property({ attribute: false })
public set challenge(value: ChallengeTypes | undefined) {
public set challenge(value: ChallengeTypes | null) {
const previousValue = this.#challenge;
const previousTitle = previousValue?.flowInfo?.title;
const nextTitle = value?.flowInfo?.title;
this.#challenge = value;
if (value?.flowInfo?.title) {
document.title = `${value.flowInfo?.title} - ${this.brandingTitle}`;
} else {
if (!nextTitle) {
document.title = this.brandingTitle;
} else if (nextTitle !== previousTitle) {
document.title = `${nextTitle} - ${this.brandingTitle}`;
}
this.requestUpdate();
this.requestUpdate("challenge", previousValue);
}
public get challenge(): ChallengeTypes | undefined {
public get challenge(): ChallengeTypes | null {
return this.#challenge;
}
@@ -117,7 +123,7 @@ export class FlowExecutor
@property({ type: Boolean })
public inspectorAvailable?: boolean;
@property({ type: String, attribute: "data-layout", useDefault: true })
@property({ type: String, attribute: "data-layout", useDefault: true, reflect: true })
public layout: FlowLayoutEnum = FlowExecutor.DefaultLayout;
@state()
@@ -159,6 +165,8 @@ export class FlowExecutor
});
}
//#region Listeners
@listen(AKSessionAuthenticatedEvent)
protected sessionAuthenticatedListener = () => {
if (!document.hidden) {
@@ -169,17 +177,22 @@ export class FlowExecutor
window.location.reload();
};
@listen(LOCALE_STATUS_EVENT)
protected localeStatusListener = (event: CustomEvent<LocaleStatusEventDetail>) => {
if (event.detail.status !== "ready") {
return;
}
this.refresh();
};
public disconnectedCallback(): void {
super.disconnectedCallback();
WebsocketClient.close();
}
public async firstUpdated(): Promise<void> {
if (this.can(CapabilitiesEnum.CanDebug)) {
this.inspectorAvailable = true;
}
protected refresh = () => {
this.loading = true;
return new FlowsApi(DEFAULT_CONFIG)
@@ -187,11 +200,7 @@ export class FlowExecutor
flowSlug: this.flowSlug,
query: window.location.search.substring(1),
})
.then((challenge: ChallengeTypes) => {
if (this.inspectorOpen) {
window.dispatchEvent(new AKFlowAdvanceEvent());
}
.then((challenge) => {
this.challenge = challenge;
if (this.challenge.flowInfo) {
@@ -210,6 +219,20 @@ export class FlowExecutor
.finally(() => {
this.loading = false;
});
};
public async firstUpdated(changed: PropertyValues<this>): Promise<void> {
super.firstUpdated(changed);
if (this.can(CapabilitiesEnum.CanDebug)) {
this.inspectorAvailable = true;
}
this.refresh().then(() => {
if (this.inspectorOpen) {
window.dispatchEvent(new AKFlowAdvanceEvent());
}
});
}
// DOM post-processing has to happen after the render.
@@ -489,7 +512,12 @@ export class FlowExecutor
part="main"
>
<div class="pf-c-login__main-header pf-c-brand" part="branding">
${renderImage(this.brandingLogo, msg("authentik Logo"), "branding-logo")}
${ThemedImage({
src: this.brandingLogo,
alt: msg("authentik Logo"),
className: "branding-logo",
theme: this.activeTheme,
})}
</div>
${this.loading && this.challenge
? html`<ak-loading-overlay></ak-loading-overlay>`

View File

@@ -2,13 +2,12 @@ import "#elements/EmptyState";
import "#elements/Expand";
import { DEFAULT_CONFIG } from "#common/api/config";
import { AKRequestPostEvent } from "#common/api/events";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { AKFlowInspectorChangeEvent } from "#flow/events";
import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events";
import Styles from "#flow/FlowInspector.css";
import { FlowInspection, FlowsApi, Stage } from "@goauthentik/api";
@@ -56,7 +55,7 @@ export class FlowInspector extends AKElement {
//#endregion
@listen(AKRequestPostEvent)
@listen(AKFlowAdvanceEvent)
protected advanceHandler = (): void => {
new FlowsApi(DEFAULT_CONFIG)
.flowsInspectorGet({

View File

@@ -1,22 +1,39 @@
import { AKElement } from "#elements/Base";
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 { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
@customElement("ak-form-static")
export class FormStatic extends AKElement {
export class AKFormStatic extends AKElement {
public override role = "banner";
public override ariaLabel = msg("User information");
@property()
userAvatar?: string;
@property({ type: String })
public avatar: string = "";
@property()
user?: string;
@property({ type: String })
public username: string = "";
static styles: CSSResult[] = [
PFAvatar,
@@ -64,21 +81,27 @@ export class FormStatic extends AKElement {
`,
];
render() {
if (!this.user) {
protected override render() {
if (!this.username) {
return nothing;
}
return html`
<div class="primary-content">
${this.userAvatar && !isDefaultAvatar(this.userAvatar)
${this.avatar && !isDefaultAvatar(this.avatar)
? html`<img
class="pf-c-avatar"
src=${this.userAvatar}
alt=${this.user ? msg(str`Avatar for ${this.user}`) : msg("User avatar")}
src=${this.avatar}
alt=${this.username
? msg(str`Avatar for ${this.username}`, {
id: "avatar.alt-text-for-user",
})
: msg("User avatar", {
id: "avatar.alt-text",
})}
/>`
: nothing}
<div class="username" aria-description=${msg("Username")}>${this.user}</div>
<div class="username" aria-description=${msg("Username")}>${this.username}</div>
</div>
<div class="links">
<slot name="link"></slot>
@@ -87,8 +110,51 @@ export class FormStatic 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">
>;
}
export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
const { pendingUserAvatar, pendingUser, flowInfo } = challenge || {};
return guard(
[pendingUserAvatar, pendingUser, flowInfo],
() =>
html`<ak-form-static
class="pf-c-form__group"
avatar=${ifPresent(pendingUserAvatar)}
username=${ifPresent(pendingUser)}
>
${flowInfo?.cancelUrl
? html`
<div slot="link">
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
</div>
`
: nothing}
</ak-form-static>`,
);
};
declare global {
interface HTMLElementTagNameMap {
"ak-form-static": FormStatic;
"ak-form-static": AKFormStatic;
}
}

View File

@@ -3,6 +3,7 @@ import "#flow/components/ak-flow-card";
import { globalAK } from "#common/global";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import { SessionEndChallenge } from "@goauthentik/api";
@@ -10,7 +11,6 @@ import { SessionEndChallenge } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -26,17 +26,8 @@ export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<p>
${msg(
str`You've logged out of ${this.challenge.applicationName}. You can go back to the overview to launch another application, or log out of your authentik account.`,

View File

@@ -1,6 +1,7 @@
import "#flow/FormStatic";
import "#flow/components/ak-flow-card";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import { AccessDeniedChallenge, FlowChallengeResponseRequest } from "@goauthentik/api";
@@ -8,35 +9,31 @@ import { AccessDeniedChallenge, FlowChallengeResponseRequest } from "@goauthenti
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
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";
@customElement("ak-stage-access-denied")
export class AccessDeniedStage extends BaseStage<
AccessDeniedChallenge,
FlowChallengeResponseRequest
> {
static styles: CSSResult[] = [PFBase, PFLogin, PFForm, PFTitle, PFFormControl];
static styles: CSSResult[] = [
// ---
PFLogin,
PFForm,
PFTitle,
PFFormControl,
PFButton,
];
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge?.pendingUserAvatar}"
user=${this.challenge?.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<ak-empty-state icon="fa-times"
><span>${msg("Request has been denied.")}</span>
${this.challenge.errorMessage
@@ -47,6 +44,20 @@ export class AccessDeniedStage extends BaseStage<
`
: nothing}
</ak-empty-state>
${this.challenge.flowInfo?.cancelUrl
? html`<fieldset class="pf-c-form__group pf-m-action">
<legend class="sr-only">${msg("Form actions")}</legend>
<a
class="pf-c-button pf-m-primary pf-m-block"
href=${this.challenge.flowInfo?.cancelUrl}
>
${msg("Go back", {
id: "flow.navigation.go-back",
})}
</a>
</fieldset>`
: nothing}
}
</form>
</ak-flow-card>`;
}

View File

@@ -3,6 +3,7 @@ import "#flow/components/ak-flow-card";
import { DEFAULT_CONFIG } from "#common/api/config";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -15,7 +16,6 @@ import {
import { msg } from "@lit/localize";
import { CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -67,17 +67,8 @@ export class AuthenticatorDuoStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<img src=${this.challenge.activationBarcode} alt=${msg("Duo activation QR code")} />
<p>
${msg(

View File

@@ -4,6 +4,7 @@ import "#flow/components/ak-flow-card";
import { AKFormErrors } from "#components/ak-field-errors";
import { AKLabel } from "#components/ak-label";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -11,10 +12,9 @@ import {
AuthenticatorEmailChallengeResponseRequest,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -44,17 +44,8 @@ export class AuthenticatorEmailStage extends BaseStage<
renderEmailInput(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<div class="pf-c-form__group">
${AKLabel(
{ required: true, htmlFor: "email-input" },
@@ -88,20 +79,25 @@ export class AuthenticatorEmailStage extends BaseStage<
}
renderEmailOTPInput(): TemplateResult {
const { email } = this.challenge;
return html`<ak-flow-card .challenge=${this.challenge}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
A verification token has been sent to your configured email address
${ifDefined(this.challenge.email)}
${FlowUserDetails({ challenge: this.challenge })}
<p>
${email
? msg(
str`A verification token has been sent to your configured email address: ${email}`,
{
id: "stage.authenticator.email.sent-to-address",
desc: "Displayed when a verification token has been sent to the user's configured email address.",
},
)
: msg("A verification token has been sent to your email address.", {
id: "stage.authenticator.email.sent",
desc: "Displayed when a verification token has been sent to the user's email address.",
})}
</p>
<form class="pf-c-form" @submit=${this.submitForm}>
<div class="pf-c-form__group">
${AKLabel({ required: true, htmlFor: "code-input" }, msg("Code"))}

View File

@@ -4,6 +4,7 @@ import "#flow/components/ak-flow-card";
import { AKFormErrors } from "#components/ak-field-errors";
import { AKLabel } from "#components/ak-label";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -14,7 +15,6 @@ import {
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -44,17 +44,8 @@ export class AuthenticatorSMSStage extends BaseStage<
renderPhoneNumber(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar=${this.challenge.pendingUserAvatar}
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<div class="pf-c-form__group">
${AKLabel(
{ required: true, htmlFor: "phone-number-input" },
@@ -90,17 +81,7 @@ export class AuthenticatorSMSStage extends BaseStage<
renderCode(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<div class="pf-c-form__group">
${AKLabel({ required: true, htmlFor: "sms-code-input" }, msg("Code"))}
<input

View File

@@ -1,6 +1,7 @@
import "#flow/FormStatic";
import "#flow/components/ak-flow-card";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -11,7 +12,6 @@ import {
import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -53,17 +53,8 @@ export class AuthenticatorStaticStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<ul class="pf-c-form__group token-list">
${this.challenge.codes.map((token) => {
return html`<li>${token}</li>`;

View File

@@ -9,6 +9,7 @@ import { showMessage } from "#elements/messages/MessageContainer";
import { AKFormErrors } from "#components/ak-field-errors";
import { AKLabel } from "#components/ak-label";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -19,7 +20,6 @@ import {
import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -54,17 +54,8 @@ export class AuthenticatorTOTPStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<input type="hidden" name="otp_uri" value=${this.challenge.configUrl} />
<div class="pf-c-form__group">

View File

@@ -9,6 +9,7 @@ import {
transformNewAssertionForServer,
} from "#common/helpers/webauthn";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -19,7 +20,6 @@ import {
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -116,17 +116,8 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<ak-empty-state ?loading="${this.registerRunning}" icon="fa-times">
<span
>${this.registerRunning

View File

@@ -7,12 +7,14 @@ import { intersectionObserver } from "#elements/decorators/intersection-observer
import { WithLocale } from "#elements/mixins/locale";
import { FocusTarget } from "#elements/utils/focus";
import { FlowUserDetails } from "#flow/FormStatic";
import { ConsoleLogger } from "#logger/browser";
import { ContextualFlowInfo, CurrentBrand, ErrorDetail } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, LitElement, nothing, PropertyValues } from "lit";
import { property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
export interface SubmitOptions {
invisible: boolean;
@@ -65,6 +67,8 @@ export abstract class BaseStage<
delegatesFocus: true,
};
protected logger = ConsoleLogger.prefix(`flow:${this.tagName.toLowerCase()}`);
// TODO: Should have a property but this needs some refactoring first.
// @property({ attribute: false })
public host!: StageHost;
@@ -137,11 +141,11 @@ export abstract class BaseStage<
}
}
return this.host?.submit(payload).then((successful) => {
return this.host?.submit(payload).then(async (successful) => {
if (successful) {
this.onSubmitSuccess();
await this.onSubmitSuccess?.(payload);
} else {
this.onSubmitFailure();
await this.onSubmitFailure?.(payload);
}
return successful;
@@ -178,17 +182,7 @@ export abstract class BaseStage<
return nothing;
}
return html`
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<input
name="username"
autocomplete="username"
@@ -198,12 +192,17 @@ export abstract class BaseStage<
`;
}
onSubmitSuccess(): void {
// Method that can be overridden by stages
return;
}
onSubmitFailure(): void {
// Method that can be overridden by stages
return;
}
/**
* Callback method for successful form submission.
*
* @abstract
*/
protected onSubmitSuccess?(payload: Record<string, unknown>): void | Promise<void>;
/**
* Callback method for failed form submission.
*
* @abstract
*/
protected onSubmitFailure?(payload: Record<string, unknown>): void | Promise<void>;
}

View File

@@ -8,6 +8,7 @@ import { ifPresent } from "#elements/utils/attributes";
import { ListenerController } from "#elements/utils/listenerController";
import { randomId } from "#elements/utils/randomId";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared";
@@ -20,7 +21,6 @@ import { match } from "ts-pattern";
import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize";
import { css, CSSResult, html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -351,18 +351,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
renderMain() {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${this.renderBody()}
${FlowUserDetails({ challenge: this.challenge })} ${this.renderBody()}
</form>
</ak-flow-card>`;
}

View File

@@ -1,6 +1,7 @@
import "#flow/FormStatic";
import "#flow/components/ak-flow-card";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import {
@@ -12,7 +13,6 @@ import {
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -113,17 +113,7 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
});
}}
>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
${this.challenge.additionalPermissions.length > 0
? this.renderAdditional()
: this.renderNoPrevious()}

View File

@@ -307,11 +307,11 @@ export class IdentificationStage extends BaseStage<
//#endregion
onSubmitSuccess(): void {
protected override onSubmitSuccess(): void {
this.#form?.remove();
}
onSubmitFailure(): void {
protected override onSubmitFailure(): void {
const captchaInput = this.#captchaInputRef.value;
if (captchaInput) {

View File

@@ -4,6 +4,7 @@ import "#flow/components/ak-flow-password-input";
import { ErrorProp } from "#components/ak-field-errors";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import { PasswordManagerPrefill } from "#flow/stages/identification/IdentificationStage";
@@ -12,7 +13,6 @@ import { PasswordChallenge, PasswordChallengeResponseRequest } from "@goauthenti
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -43,17 +43,8 @@ export class PasswordStage extends BaseStage<PasswordChallenge, PasswordChalleng
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<input
name="username"
type="text"

View File

@@ -1,17 +1,17 @@
import "#elements/Divider";
import "#flow/components/ak-flow-card";
import { formatLocaleDisplayNames, renderLocaleDisplayNames } from "#common/ui/locale/format";
import { getBestMatchLocale } from "#common/ui/locale/utils";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { SlottedTemplateResult } from "#elements/types";
import { AKFormErrors } from "#components/ak-field-errors";
import { AKLabel } from "#components/ak-label";
import { BaseStage } from "#flow/stages/base";
import { LocalePrompt } from "#flow/stages/prompt/components/locale";
import {
CapabilitiesEnum,
PromptChallenge,
PromptChallengeResponseRequest,
PromptTypeEnum,
@@ -33,9 +33,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";
// Fixes horizontal rule <hr> warning in select dropdowns.
/* eslint-disable lit/no-invalid-html */
@customElement("ak-stage-prompt")
export class PromptStage extends WithCapabilitiesConfig(
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
@@ -59,7 +56,7 @@ export class PromptStage extends WithCapabilitiesConfig(
`,
];
renderPromptInner(prompt: StagePrompt, disabled = false): TemplateResult {
renderPromptInner(prompt: StagePrompt, disabled = false): SlottedTemplateResult {
const fieldId = `field-${prompt.fieldKey}`;
switch (prompt.type) {
@@ -233,31 +230,13 @@ ${prompt.initialValue}</textarea
</div> `;
})}`;
case PromptTypeEnum.AkLocale: {
const entries = formatLocaleDisplayNames(this.activeLanguageTag);
const currentLanguageTag = prompt.initialValue
? getBestMatchLocale(prompt.initialValue)
: null;
return html`<select
class="pf-c-form-control ak-m-capitalize"
id=${fieldId}
name="${prompt.fieldKey}"
?disabled=${disabled}
aria-label=${msg("Select language", {
id: "language-selector-label",
desc: "Label for the language selection dropdown",
})}
>
<option value="" ?selected=${!currentLanguageTag}>
${msg("Auto-detect", {
id: "locale-auto-detect-option",
desc: "Label for the auto-detect locale option in language selection dropdown",
})}
</option>
<hr />
${renderLocaleDisplayNames(entries, currentLanguageTag)}
</select>`;
return LocalePrompt({
activeLanguageTag: this.activeLanguageTag,
prompt,
fieldId,
disabled,
debug: this.can(CapabilitiesEnum.CanDebug),
});
}
default:
return html`<p>invalid type '${prompt.type}'</p>`;

View File

@@ -0,0 +1,78 @@
import { TargetLanguageTag } from "#common/ui/locale/definitions";
import {
formatAutoDetectLocaleDisplayName,
formatLocaleDisplayNames,
LocaleDisplay,
} from "#common/ui/locale/format";
import { getBestMatchLocale, getSessionLocale } from "#common/ui/locale/utils";
import { LocaleOptions } from "#elements/locale/utils";
import { LitFC } from "#elements/types";
import { StagePrompt } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html } from "lit";
import { guard } from "lit/directives/guard.js";
// Fixes horizontal rule <hr> warning in select dropdowns.
/* eslint-disable lit/no-invalid-html */
export interface LocalePromptProps {
activeLanguageTag: TargetLanguageTag;
prompt: StagePrompt;
fieldId: string;
disabled?: boolean;
debug?: boolean;
}
export const LocalePrompt: LitFC<LocalePromptProps> = ({
activeLanguageTag,
prompt,
disabled,
debug,
fieldId,
}) => {
const sessionLocale = getSessionLocale();
return guard(
[activeLanguageTag, prompt.fieldKey, prompt.initialValue, disabled, sessionLocale],
() => {
const entries = formatLocaleDisplayNames(activeLanguageTag, {
debug,
});
const languagesByTag = new Map<TargetLanguageTag, LocaleDisplay>(
entries.map((entry) => [entry[0], entry]),
);
const selectedLanguageTag = prompt.initialValue
? getBestMatchLocale(prompt.initialValue)
: null;
/**
* This is a bit subtle.
*
* -
*/
const autoDetectedLocale = formatAutoDetectLocaleDisplayName(
sessionLocale ? languagesByTag.get(selectedLanguageTag || activeLanguageTag) : null,
);
return html`<select
class="pf-c-form-control ak-m-capitalize"
id=${fieldId}
name=${prompt.fieldKey}
?disabled=${disabled}
aria-label=${msg("Select language", {
id: "language-selector-label",
desc: "Label for the language selection dropdown",
})}
>
<option value="" ?selected=${!selectedLanguageTag}>${autoDetectedLocale}</option>
<hr />
${LocaleOptions({ entries, activeLocaleTag: selectedLanguageTag })}
</select>`;
},
);
};

View File

@@ -1,6 +1,7 @@
import "#flow/FormStatic";
import "#flow/components/ak-flow-card";
import { FlowUserDetails } from "#flow/FormStatic";
import { BaseStage } from "#flow/stages/base";
import { UserLoginChallenge, UserLoginChallengeResponseRequest } from "@goauthentik/api";
@@ -8,7 +9,6 @@ import { UserLoginChallenge, UserLoginChallengeResponseRequest } from "@goauthen
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -48,17 +48,8 @@ export class PasswordStage extends BaseStage<
});
}}
>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${FlowUserDetails({ challenge: this.challenge })}
<div class="pf-c-form__group">
<h3 data-test-id="stage-heading" class="pf-c-title pf-m-xl pf-u-mb-xl">
${msg("Stay signed in?")}

View File

@@ -9,7 +9,7 @@ import { getCookie } from "#common/utils";
import { Interface } from "#elements/Interface";
import { WithBrandConfig } from "#elements/mixins/branding";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
@@ -101,7 +101,12 @@ export class APIBrowser extends WithBrandConfig(Interface) {
show-method-in-nav-bar="as-colored-text"
>
<div slot="nav-logo">
${renderImage(this.brandingLogo, msg("authentik Logo"), "logo")}
${ThemedImage({
src: this.brandingLogo,
alt: msg("authentik Logo"),
className: "logo",
theme: this.activeTheme,
})}
</div>
</rapi-doc>
`;

View File

@@ -187,8 +187,7 @@
.branding-logo {
display: block;
width: clamp(75%, calc(var(--ak-login--MaxWidth) / 2), 90%);
min-height: 4rem;
max-width: clamp(75%, calc(var(--ak-login--MaxWidth) / 2), 90%);
}
/* Ensure Font Awesome logos scale similarly to image logos */

View File

@@ -26,7 +26,7 @@ import {
renderNotificationDrawerPanel,
} from "#elements/notifications/utils";
import { ifPresent } from "#elements/utils/attributes";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";
import Styles from "#user/index.entrypoint.css";
import { ROUTES } from "#user/Routes";
@@ -150,7 +150,12 @@ class UserInterface extends WithBrandConfig(WithSession(AuthenticatedInterface))
<header part="page__header" class="pf-c-page__header">
<div part="brand" class="pf-c-page__header-brand">
<a href="#/" class="pf-c-page__header-brand-link">
${renderImage(this.brandingLogo, this.brandingTitle, "pf-c-brand")}
${ThemedImage({
src: this.brandingLogo,
alt: this.brandingTitle,
className: "pf-c-brand",
theme: this.activeTheme,
})}
</a>
</div>
<ak-nav-buttons>${this.renderAdminInterfaceLink()}</ak-nav-buttons>

View File

@@ -40,16 +40,19 @@ export class UserSettingsFlowExecutor
@property()
flowSlug = this.brand?.flowUserSettings;
private _challenge?: ChallengeTypes;
#challenge: ChallengeTypes | null = null;
@property({ attribute: false })
set challenge(value: ChallengeTypes | undefined) {
this._challenge = value;
this.requestUpdate();
set challenge(value: ChallengeTypes | null) {
const previousValue = this.#challenge;
this.#challenge = value;
this.requestUpdate("challenge", previousValue);
}
get challenge(): ChallengeTypes | undefined {
return this._challenge;
get challenge(): ChallengeTypes | null {
return this.#challenge;
}
@property({ type: Boolean })

View File

@@ -3,6 +3,9 @@ import "#flow/components/ak-flow-card";
import { DEFAULT_CONFIG } from "#common/api/config";
import { globalAK } from "#common/global";
import { autoDetectLanguage, setSessionLocale } from "#common/ui/locale/utils";
import { SlottedTemplateResult } from "#elements/types";
import { AKLabel } from "#components/ak-label";
@@ -58,7 +61,7 @@ export class UserSettingsPromptStage extends PromptStage {
return super.renderPromptHelpText(prompt);
}
renderPromptInner(prompt: StagePrompt): TemplateResult {
renderPromptInner(prompt: StagePrompt): SlottedTemplateResult {
if (prompt.type === PromptTypeEnum.Checkbox) {
return html`<input
type="checkbox"
@@ -135,6 +138,38 @@ export class UserSettingsPromptStage extends PromptStage {
</div>
</ak-flow-card>`;
}
/**
* Detects if the locale was changed in a prompt stage and updates the session accordingly.
*/
protected override onSubmitSuccess(payload: Record<string, unknown>): void {
super.onSubmitSuccess?.(payload);
if (this.challenge.component !== "ak-stage-prompt") return;
const localeField = this.challenge.fields.find(
(field) => field.type === PromptTypeEnum.AkLocale,
);
if (!localeField) return;
const previousLanguageTag = localeField.initialValue;
const languageTag = localeField?.fieldKey ? payload[localeField.fieldKey] : null;
if (typeof languageTag !== "string") return;
// Remove the temporary session locale...
setSessionLocale(null);
if (languageTag !== this.activeLanguageTag) {
this.logger.info("A prompt stage changed the locale", {
languageTag,
previousLanguageTag,
});
this.activeLanguageTag = autoDetectLanguage(languageTag);
}
}
}
declare global {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff