mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 06:32:15 +02:00
Compare commits
11 Commits
sdko/postg
...
web/style/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a407ebbc | ||
|
|
e966159692 | ||
|
|
b5eab028ee | ||
|
|
25d7ba59fd | ||
|
|
f7096a2c84 | ||
|
|
4273b15320 | ||
|
|
27a550b18d | ||
|
|
a09cf62bac | ||
|
|
cf2ab7f701 | ||
|
|
cc4ce19ccc | ||
|
|
bd304e76c8 |
@@ -35,43 +35,14 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
|
||||
<style data-id="flow-css">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ flow_background_url }}");
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<ak-skip-to-content></ak-skip-to-content>
|
||||
<ak-message-container></ak-message-container>
|
||||
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<ak-flow-executor
|
||||
slug="{{ flow.slug }}"
|
||||
class="pf-c-login"
|
||||
data-layout="{{ flow.layout|default:'stacked' }}"
|
||||
loading
|
||||
>
|
||||
{% include "base/placeholder.html" %}
|
||||
|
||||
<ak-brand-links name="flow-links" slot="footer"></ak-brand-links>
|
||||
</ak-flow-executor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ak-flow-inspector
|
||||
id="flow-inspector"
|
||||
data-registration="lazy"
|
||||
class="pf-c-drawer__panel pf-m-width-33"
|
||||
slug="{{ flow.slug }}"
|
||||
></ak-flow-inspector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ak-flow
|
||||
slug="{{ flow.slug }}"
|
||||
layout={{ flow.layout|default:'stacked' }}"
|
||||
style='--ak-global--background-image: url("{{ flow_background_url }}")'
|
||||
></ak-flow>
|
||||
{% endblock %}
|
||||
|
||||
103
web/src/elements/directives/light.ts
Normal file
103
web/src/elements/directives/light.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { html, nothing, render, TemplateResult } from "lit";
|
||||
import { AsyncDirective, DirectiveResult } from "lit/async-directive.js";
|
||||
import { ChildPart, directive, PartInfo, PartType } from "lit/directive.js";
|
||||
import { RootPart } from "lit/html.js";
|
||||
|
||||
export interface LightChildOptions {
|
||||
// Optional alternative target for any `@event`-style handlers passed into the template. NOTE:
|
||||
// this only works if the handlers do not already have a `this` bound to them, so only ordinary
|
||||
// functions and methods will respond to this parameter; arrow function class fields and bound
|
||||
// functions will use the `this` to which they were bound.
|
||||
|
||||
host?: Element;
|
||||
slotName?: string;
|
||||
}
|
||||
|
||||
class LightChildDirective extends AsyncDirective {
|
||||
#slotName: string | null = null;
|
||||
#slot: HTMLSlotElement | null = null;
|
||||
#host: Element | null = null;
|
||||
#rootPart: RootPart | null = null;
|
||||
#sentinel: Comment | null = null;
|
||||
|
||||
constructor(partInfo: PartInfo) {
|
||||
super(partInfo);
|
||||
if (partInfo.type !== PartType.CHILD) {
|
||||
throw new Error("The `light()` directive can only be use in child position");
|
||||
}
|
||||
}
|
||||
|
||||
render(_template?: TemplateResult | DirectiveResult, options?: LightChildOptions) {
|
||||
this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return html`<slot name="${this.#slotName}"></slot>`;
|
||||
}
|
||||
|
||||
update(
|
||||
part: ChildPart,
|
||||
[template, options = {}]: [TemplateResult | DirectiveResult, LightChildOptions],
|
||||
) {
|
||||
this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// This places a comment in the LightDom that belongs to this directive. Comments are not
|
||||
// part of the DOM tree for the purposes of CSS, so it will be possible to style this child
|
||||
// directly without a wrapper.
|
||||
|
||||
if (!this.#sentinel) {
|
||||
const rootNode = part.parentNode.getRootNode();
|
||||
this.#host ??= (part.options?.host ||
|
||||
(rootNode instanceof ShadowRoot ? rootNode.host : null)) as Element | null;
|
||||
|
||||
if (!this.#host) {
|
||||
throw new Error(
|
||||
"light() must be used inside a shadow root or a valid options.host",
|
||||
);
|
||||
}
|
||||
|
||||
this.#sentinel = document.createComment("");
|
||||
this.#host.appendChild(this.#sentinel);
|
||||
}
|
||||
|
||||
if (!this.#sentinel.parentNode) {
|
||||
throw new Error("Could not assign sentinel to element.");
|
||||
}
|
||||
|
||||
const renderOptions = Object.fromEntries(
|
||||
Object.entries(options).filter(([key]) => ["host"].includes(key)),
|
||||
);
|
||||
|
||||
this.#rootPart = render(template, this.#sentinel.parentNode as HTMLElement, {
|
||||
renderBefore: this.#sentinel,
|
||||
...renderOptions,
|
||||
});
|
||||
|
||||
const rendered = this.#sentinel.previousSibling;
|
||||
if (rendered instanceof Element) {
|
||||
rendered.slot = this.#slotName;
|
||||
}
|
||||
|
||||
return (this.#slot ??= Object.assign(document.createElement("slot"), {
|
||||
name: this.#slotName,
|
||||
}));
|
||||
}
|
||||
|
||||
disconnected() {
|
||||
if (this.#sentinel?.parentNode && this.#host?.isConnected) {
|
||||
// The content being rendered this way, with the `render()` *function*, has its own Lit
|
||||
// VDOM comment nodes in the HTML unrelated to the `host` context. Rendering `nothing`
|
||||
// here ensures that any children of the lightDOM component receive clean-up signals and
|
||||
// correctly disconnect (including listeners, etc.) from the current display as well.
|
||||
// This is what lets us receive other DirectiveResults as template content.
|
||||
|
||||
render(nothing, this.#sentinel.parentNode as HTMLElement, {
|
||||
renderBefore: this.#sentinel,
|
||||
});
|
||||
}
|
||||
this.#rootPart?.setConnected(false);
|
||||
}
|
||||
|
||||
reconnected() {
|
||||
this.#rootPart?.setConnected(true);
|
||||
}
|
||||
}
|
||||
|
||||
export const light = directive(LightChildDirective);
|
||||
84
web/src/flow/Flow.ts
Normal file
84
web/src/flow/Flow.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import "#flow/FlowExecutor";
|
||||
import "#flow/inspector/FlowInspector";
|
||||
import "#flow/components/ak-brand-footer";
|
||||
|
||||
import { light } from "#elements/directives/light";
|
||||
import { Interface } from "#elements/Interface";
|
||||
|
||||
import AKPlaceholder from "#styles/authentik/base/placeholder.css";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css";
|
||||
|
||||
@customElement("ak-flow")
|
||||
export class Flow extends Interface {
|
||||
static readonly styles = [PFDrawer, PFLogin, PFSpinner, AKPlaceholder];
|
||||
|
||||
@property()
|
||||
public slug?: string;
|
||||
|
||||
@property()
|
||||
public layout?: string = "stacked";
|
||||
|
||||
protected renderPlacehonder() {
|
||||
return html`<div class="ak-c-placeholder" id="ak-placeholder" slot="placeholder">
|
||||
<span class="pf-c-spinner" role="progressbar" aria-valuetext=${msg("Loading...")}>
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { slug, layout } = this;
|
||||
const footerClasses = classMap({
|
||||
"pf-c-login_footer": true,
|
||||
"pf-m-dark": this.layout === FlowLayoutEnum.Stacked,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
${light(
|
||||
html`<ak-flow-executor
|
||||
slug="${slug}"
|
||||
class="pf-c-login"
|
||||
data-layout="${layout}"
|
||||
loading
|
||||
>
|
||||
${this.renderPlaceholder()}
|
||||
</ak-flow-executor>`
|
||||
)}
|
||||
<footer
|
||||
aria-label=${msg("Site footer")}
|
||||
name="site-footer"
|
||||
part="footer"
|
||||
class=${footerClasses}
|
||||
>
|
||||
<ak-brand-links name="flow-links"></ak-brand-links>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ak-flow-inspector
|
||||
id="flow-inspector"
|
||||
data-registration="lazy"
|
||||
class="pf-c-drawer__panel pf-m-width-33"
|
||||
slug=${slug}
|
||||
></ak-flow-inspector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import { applyBackgroundImageProperty } from "#common/theme";
|
||||
import { AKSessionAuthenticatedEvent } from "#common/ws/events";
|
||||
import { WebsocketClient } from "#common/ws/WebSocketClient";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { listen } from "#elements/decorators/listen";
|
||||
import { Interface } from "#elements/Interface";
|
||||
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
|
||||
import { WithBrandConfig } from "#elements/mixins/branding";
|
||||
import { LitPropertyRecord, SlottedTemplateResult } from "#elements/types";
|
||||
@@ -78,7 +78,7 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
* @part locale-select-select - The select element of the locale select component.
|
||||
*/
|
||||
@customElement("ak-flow-executor")
|
||||
export class FlowExecutor extends WithBrandConfig(Interface) implements StageHost {
|
||||
export class FlowExecutor extends WithBrandConfig(AKElement) implements StageHost {
|
||||
public static readonly DefaultLayout: FlowLayoutEnum =
|
||||
globalAK()?.flow?.layout || FlowLayoutEnum.Stacked;
|
||||
|
||||
@@ -98,7 +98,11 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
|
||||
//#region Properties
|
||||
|
||||
@property({ type: String, attribute: "slug", useDefault: true })
|
||||
@property({
|
||||
type: String,
|
||||
attribute: "slug",
|
||||
useDefault: true,
|
||||
})
|
||||
public flowSlug: string = window.location.pathname.split("/")[3];
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -107,7 +111,12 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
@property({ type: Boolean })
|
||||
public loading = false;
|
||||
|
||||
@property({ type: String, attribute: "data-layout", useDefault: true, reflect: true })
|
||||
@property({
|
||||
type: String,
|
||||
attribute: "data-layout",
|
||||
useDefault: true,
|
||||
reflect: true,
|
||||
})
|
||||
public layout: FlowLayoutEnum = FlowExecutor.DefaultLayout;
|
||||
|
||||
//#endregion
|
||||
@@ -195,7 +204,9 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
? this.closest<HTMLDivElement>(".docs-story")
|
||||
: this.ownerDocument.body;
|
||||
|
||||
applyBackgroundImageProperty(background, { target });
|
||||
applyBackgroundImageProperty(background, {
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
//#region Listeners
|
||||
@@ -283,7 +294,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
|
||||
public submit = async (
|
||||
payload?: FlowChallengeResponseRequest,
|
||||
options?: SubmitOptions,
|
||||
options?: SubmitOptions
|
||||
): Promise<boolean> => {
|
||||
if (!payload) throw new Error("No payload provided");
|
||||
if (!this.challenge) throw new Error("No challenge provided");
|
||||
@@ -336,7 +347,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
}
|
||||
|
||||
return this.renderChallengeError(
|
||||
`No stage found for component: ${challenge.component}`,
|
||||
`No stage found for component: ${challenge.component}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,11 +375,16 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
const props = spread(
|
||||
match(variant)
|
||||
.with("challenge", () => challengeProps)
|
||||
.with("standard", () => ({ ...challengeProps, ...litParts }))
|
||||
.exhaustive(),
|
||||
.with("standard", () => ({
|
||||
...challengeProps,
|
||||
...litParts,
|
||||
}))
|
||||
.exhaustive()
|
||||
);
|
||||
|
||||
return staticHTML`<${unsafeStatic(tag)} ${props}></${unsafeStatic(tag)}>`;
|
||||
return html`<div part="challange">
|
||||
${light(staticHTML`<${unsafeStatic(tag)} ${props}></${unsafeStatic(tag)}>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderChallengeError(error: unknown): SlottedTemplateResult {
|
||||
@@ -394,59 +410,10 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
return html`<slot name="placeholder"></slot>`;
|
||||
}
|
||||
|
||||
protected renderFrameBackground(): SlottedTemplateResult {
|
||||
return guard([this.layout, this.challenge], () => {
|
||||
if (
|
||||
this.layout !== FlowLayoutEnum.SidebarLeftFrameBackground &&
|
||||
this.layout !== FlowLayoutEnum.SidebarRightFrameBackground
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const src = this.challenge?.flowInfo?.background;
|
||||
|
||||
if (!src) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="ak-c-login__content" part="content">
|
||||
<iframe
|
||||
class="ak-c-login__content-iframe"
|
||||
part="content-iframe"
|
||||
name="flow-content-frame"
|
||||
src=${src}
|
||||
></iframe>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
protected renderFooter(): SlottedTemplateResult {
|
||||
return guard([this.layout], () => {
|
||||
return html`<footer
|
||||
aria-label=${msg("Site footer")}
|
||||
name="site-footer"
|
||||
part="footer"
|
||||
class="pf-c-login__footer ${this.layout === FlowLayoutEnum.Stacked
|
||||
? "pf-m-dark"
|
||||
: ""}"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</footer>`;
|
||||
});
|
||||
}
|
||||
|
||||
protected override render(): SlottedTemplateResult {
|
||||
const { challenge, loading } = this;
|
||||
|
||||
return html`<ak-locale-select
|
||||
part="locale-select"
|
||||
exportparts="label:locale-select-label,select:locale-select-select"
|
||||
class="pf-m-dark"
|
||||
></ak-locale-select>
|
||||
|
||||
<header class="pf-c-login__header">
|
||||
<ak-flow-inspector-button></ak-flow-inspector-button>
|
||||
</header>
|
||||
return html`
|
||||
<main
|
||||
data-layout=${this.layout}
|
||||
class="pf-c-login__main"
|
||||
@@ -469,13 +436,14 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
: this.renderLoading();
|
||||
})}
|
||||
</main>
|
||||
${this.renderFooter()}`;
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
g;
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-flow-executor": FlowExecutor;
|
||||
}
|
||||
|
||||
130
web/src/flow/controllers/FlowMessageController.ts
Normal file
130
web/src/flow/controllers/FlowMessageController.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Interface } from "#elements/Interface";
|
||||
|
||||
type MessageHost = ReactiveControllerHost & Interface;
|
||||
|
||||
export class FlowMessageController implements ReactiveController {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(private host: RememberMeHost) {
|
||||
/* no op */
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
try {
|
||||
const sessionId = localStorage.getItem("authentik-remember-me-session");
|
||||
if (!!this.localSession && sessionId === this.localSession) {
|
||||
this.username = undefined;
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-session", this.localSession);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get localSession() {
|
||||
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
|
||||
}
|
||||
|
||||
get usernameField() {
|
||||
return this.host.renderRoot.querySelector(
|
||||
'input[name="uidField"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
get rememberMeToggle() {
|
||||
return this.host.renderRoot.querySelector(
|
||||
"#authentik-remember-me"
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
get isValidChallenge() {
|
||||
return !(
|
||||
this.host.challenge?.responseErrors &&
|
||||
this.host.challenge.responseErrors.non_field_errors &&
|
||||
this.host.challenge.responseErrors.non_field_errors.find(
|
||||
(cre) => cre.code === "invalid"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
get submitButton() {
|
||||
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
|
||||
}
|
||||
|
||||
get canAutoSubmit() {
|
||||
return (
|
||||
!!this.host.challenge &&
|
||||
!!this.username &&
|
||||
!!this.usernameField?.value &&
|
||||
!this.host.challenge.passwordFields &&
|
||||
!this.host.challenge.passwordlessUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Before the page is updated, try to extract the username from localstorage.
|
||||
hostUpdate() {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// After the page is updated, if everything is ready to go, do the autosubmit.
|
||||
hostUpdated() {
|
||||
if (this.isEnabled && this.canAutoSubmit) {
|
||||
this.submitButton?.click();
|
||||
}
|
||||
}
|
||||
|
||||
trackRememberMe() {
|
||||
if (!this.usernameField || this.usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
}
|
||||
|
||||
// When active, save current details and record every keystroke to the username.
|
||||
// When inactive, clear all fields and remove keystroke recorder.
|
||||
toggleRememberMe() {
|
||||
if (!this.rememberMeToggle || !this.rememberMeToggle.checked) {
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
localStorage?.removeItem("authentik-remember-me-session");
|
||||
this.username = undefined;
|
||||
this.usernameField?.removeEventListener("keyup", this.trackRememberMe);
|
||||
return;
|
||||
}
|
||||
if (!this.usernameField) {
|
||||
return;
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-user", this.usernameField.value);
|
||||
localStorage?.setItem("authentik-remember-me-session", this.localSession);
|
||||
this.usernameField.addEventListener("keyup", this.trackRememberMe);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.isEnabled
|
||||
? html` <label class="pf-c-switch remember-me-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
id="authentik-remember-me"
|
||||
@click=${this.toggleRememberMe}
|
||||
type="checkbox"
|
||||
?checked=${!!this.username}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`
|
||||
: nothing;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import "#elements/messages/MessageContainer";
|
||||
import "#flow/FlowExecutor";
|
||||
import "#flow/Flow";
|
||||
// Statically import some stages to speed up load speed
|
||||
import "#flow/stages/access_denied/AccessDeniedStage";
|
||||
// Import webauthn-related stages to prevent issues on safari
|
||||
@@ -15,5 +15,7 @@ import "#flow/stages/password/PasswordStage";
|
||||
// end of stage import
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
||||
await import(
|
||||
"@goauthentik/esbuild-plugin-live-reload/client"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user