mirror of
https://github.com/goauthentik/authentik
synced 2026-04-30 11:27:15 +02:00
Compare commits
2 Commits
mdx-sans-r
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf228794e | ||
|
|
d6c0ae21de |
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -64,7 +64,7 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
|
||||
uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
|
||||
@@ -77,6 +77,8 @@ export class FormFixture extends PageFixture {
|
||||
|
||||
/**
|
||||
* Search for a row containing the given text.
|
||||
*
|
||||
* @returns A locator for the row entry matching the query.
|
||||
*/
|
||||
public search = async (
|
||||
query: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
|
||||
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
export const GOOD_USERNAME = "test-admin@goauthentik.io";
|
||||
export const GOOD_PASSWORD = "test-runner";
|
||||
|
||||
@@ -11,6 +13,8 @@ export interface LoginInit {
|
||||
username?: string;
|
||||
password?: string;
|
||||
to?: URL | string;
|
||||
rememberMe?: boolean;
|
||||
page?: Page;
|
||||
}
|
||||
|
||||
export interface SessionFixtureInit extends PageFixtureInit {
|
||||
@@ -36,6 +40,10 @@ export class SessionFixture extends PageFixture {
|
||||
public $passwordStage = this.page.locator("ak-stage-password");
|
||||
public $passwordField = this.page.getByLabel("Password");
|
||||
|
||||
public $rememberMeCheckbox = this.page.getByRole("checkbox", {
|
||||
name: "Remember me on this device",
|
||||
});
|
||||
|
||||
/**
|
||||
* The button to submit the the login flow,
|
||||
* typically redirecting to the authenticated interface.
|
||||
@@ -66,19 +74,45 @@ export class SessionFixture extends PageFixture {
|
||||
/**
|
||||
* Log into the application.
|
||||
*/
|
||||
public async login({
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
}: LoginInit = {}) {
|
||||
public async login(
|
||||
{
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
rememberMe,
|
||||
}: LoginInit = {},
|
||||
page = this.page,
|
||||
): Promise<void> {
|
||||
this.logger.info("Logging in...");
|
||||
|
||||
const initialURL = new URL(this.page.url());
|
||||
const initialURL = new URL(page.url());
|
||||
|
||||
if (initialURL.pathname === SessionFixture.pathname) {
|
||||
this.logger.info("Skipping navigation because we're already in a authentication flow");
|
||||
} else {
|
||||
await this.page.goto(to.toString());
|
||||
await page.goto(to.toString());
|
||||
}
|
||||
|
||||
if (typeof rememberMe === "boolean") {
|
||||
const rememberMeCheckboxVisible = await this.$rememberMeCheckbox.isVisible();
|
||||
|
||||
if (rememberMeCheckboxVisible) {
|
||||
if (rememberMe) {
|
||||
await this.$rememberMeCheckbox.check();
|
||||
|
||||
await expect(
|
||||
this.$rememberMeCheckbox,
|
||||
"Remember me checkbox is checked",
|
||||
).toBeChecked();
|
||||
} else {
|
||||
await this.$rememberMeCheckbox.uncheck();
|
||||
|
||||
await expect(
|
||||
this.$rememberMeCheckbox,
|
||||
"Remember me checkbox is unchecked",
|
||||
).not.toBeChecked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.$usernameField.fill(username);
|
||||
@@ -102,7 +136,7 @@ export class SessionFixture extends PageFixture {
|
||||
|
||||
//#region Navigation
|
||||
|
||||
public async toLoginPage() {
|
||||
await this.page.goto(SessionFixture.pathname);
|
||||
public async toLoginPage(page: Page = this.page) {
|
||||
await page.goto(SessionFixture.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,30 @@ import "#elements/buttons/SpinnerButton/index";
|
||||
import "#elements/forms/HorizontalFormElement";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import { Form } from "#elements/forms/Form";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { FocusTarget } from "#elements/utils/focus";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@customElement("ak-user-password-form")
|
||||
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
public override submitLabel = msg("Set Password");
|
||||
public static shadowRootOptions: ShadowRootInit = {
|
||||
...Form.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public static override verboseName = msg("Password");
|
||||
public static override verboseNamePlural = msg("Passwords");
|
||||
public static override submittingVerb = msg("Setting");
|
||||
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
|
||||
@@ -23,6 +33,9 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
|
||||
//#region Properties
|
||||
|
||||
public override submitLabel = msg("Set Password");
|
||||
public override successMessage = msg("Successfully updated password.");
|
||||
|
||||
@property({ type: Number })
|
||||
public instancePk?: number;
|
||||
|
||||
@@ -30,13 +43,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
public label = msg("New Password");
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder = msg("New Password");
|
||||
public placeholder = msg("Type a new password...");
|
||||
|
||||
@property({ type: String })
|
||||
public username?: string;
|
||||
@property({ type: String, useDefault: true })
|
||||
public username: string | null = null;
|
||||
|
||||
@property({ type: String })
|
||||
public email?: string;
|
||||
@property({ type: String, useDefault: true })
|
||||
public email: string | null = null;
|
||||
|
||||
public override size = PFSize.Medium;
|
||||
|
||||
/**
|
||||
* The autocomplete attribute to use for the password field.
|
||||
@@ -50,17 +65,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
|
||||
//#endregion
|
||||
|
||||
public override getSuccessMessage(): string {
|
||||
return msg("Successfully updated password.");
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
}
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.focus();
|
||||
requestAnimationFrame(() => {
|
||||
this.focus();
|
||||
});
|
||||
}
|
||||
|
||||
protected override async send(data: UserPasswordSetRequest): Promise<void> {
|
||||
@@ -94,17 +107,26 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
/>`
|
||||
: nothing}
|
||||
|
||||
<ak-form-element-horizontal label=${this.label} required name="password">
|
||||
<ak-form-element-horizontal required name="password">
|
||||
${AKLabel(
|
||||
{
|
||||
slot: "label",
|
||||
className: "pf-c-form__group-label",
|
||||
htmlFor: "password",
|
||||
required: true,
|
||||
},
|
||||
this.label,
|
||||
)}
|
||||
<input
|
||||
autofocus
|
||||
${this.autofocusTarget.toRef()}
|
||||
id="password"
|
||||
type="password"
|
||||
value=""
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder || this.label)}
|
||||
aria-label=${this.label}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
placeholder=${ifPresent(this.placeholder || this.label)}
|
||||
autocomplete=${ifPresent(this.autocomplete)}
|
||||
/>
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
|
||||
140
web/src/common/storage.ts
Normal file
140
web/src/common/storage.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file Storage utilities.
|
||||
*/
|
||||
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
/**
|
||||
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
|
||||
*/
|
||||
export class StorageAccessor {
|
||||
constructor(
|
||||
/**
|
||||
* The key under which the value is stored in the storage backend.
|
||||
*/
|
||||
public readonly key: string,
|
||||
/**
|
||||
* The storage backend to use, e.g. `window.localStorage` or `window.sessionStorage`.
|
||||
*/
|
||||
protected readonly storage: Storage,
|
||||
protected logger = ConsoleLogger.prefix("storage-accessor"),
|
||||
) {
|
||||
if (typeof key !== "string") {
|
||||
throw new TypeError("Storage key must be a string");
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new TypeError("Storage key must be a non-empty string");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link StorageAccessor} for local storage.
|
||||
*
|
||||
* @param key The key under which the value is stored in localStorage.
|
||||
*/
|
||||
public static local = (key: string) => new StorageAccessor(key, self.localStorage);
|
||||
/**
|
||||
* Create a {@link StorageAccessor} for session storage.
|
||||
*
|
||||
* @param key The key under which the value is stored in sessionStorage.
|
||||
*/
|
||||
public static session = (key: string) => new StorageAccessor(key, self.sessionStorage);
|
||||
|
||||
/**
|
||||
* Read the value from storage.
|
||||
*
|
||||
* @param fallback An optional value to return if the key does not exist or an error occurs. Defaults to `null`.
|
||||
*
|
||||
* @returns The stored value, or `null` if the key does not exist or an error occurs.
|
||||
*/
|
||||
public read<T extends string>(fallback?: T): T | null {
|
||||
try {
|
||||
const value = this.storage.getItem(this.key);
|
||||
return value !== null ? (value as T) : (fallback ?? null);
|
||||
} catch (_error: unknown) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to storage.
|
||||
*
|
||||
* @param value The value to store.
|
||||
*
|
||||
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
|
||||
*/
|
||||
public write(value: string | null): boolean {
|
||||
if (!value) {
|
||||
if (this.read()) {
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.storage.setItem(this.key, value);
|
||||
return true;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from storage and parse it as JSON.
|
||||
*
|
||||
* @param fallback An optional value to return if the key does not exist, the value is not valid JSON, or an error occurs. Defaults to `null`.
|
||||
*
|
||||
* @returns The parsed value, or `null` if the key does not exist, the value is not valid JSON, or an error occurs.
|
||||
*/
|
||||
public readJSON<T>(fallback?: T): T | null {
|
||||
const value = this.read<string>();
|
||||
|
||||
if (value === null) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (_error: unknown) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to storage after stringifying it as JSON.
|
||||
*
|
||||
* @param value The value to store.
|
||||
*
|
||||
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
|
||||
*/
|
||||
public writeJSON(value: unknown): boolean {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
return this.write(stringified);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to write JSON value to storage", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the value from storage.
|
||||
*
|
||||
* @returns `true` if the value was successfully deleted, or `false` if an error occurred.
|
||||
*/
|
||||
public delete(): boolean {
|
||||
this.logger.debug("Deleting value from storage");
|
||||
|
||||
try {
|
||||
this.storage.removeItem(this.key);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to delete value from storage", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
|
||||
<a
|
||||
href="${globalAK().api.base}flows/-/default/invalidation/"
|
||||
class="pf-c-button pf-m-plain"
|
||||
aria-label=${msg("Sign out")}
|
||||
>
|
||||
<pf-tooltip position="top" content=${msg("Sign out")}>
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
|
||||
@@ -414,6 +414,13 @@ export class Form<T = Record<string, unknown>, D = T>
|
||||
|
||||
const { submittingVerb, verboseName } = this.constructor as typeof Form;
|
||||
|
||||
if (!verboseName) {
|
||||
return msg(str`${submittingVerb}...`, {
|
||||
id: "form.submitting.no-entity",
|
||||
desc: "The message shown while a form is being submitted, when no entity name is provided.",
|
||||
});
|
||||
}
|
||||
|
||||
return msg(str`${submittingVerb} ${verboseName}...`, {
|
||||
id: "form.submitting",
|
||||
desc: "The message shown while a form is being submitted.",
|
||||
@@ -615,6 +622,7 @@ export class Form<T = Record<string, unknown>, D = T>
|
||||
protected doSubmit = (event: SubmitEvent): void => {
|
||||
if (this.submitting) {
|
||||
this.logger.info("Skipping submit. Already submitting!");
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
@@ -4,6 +4,44 @@
|
||||
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
|
||||
export interface FocusErrorOptions extends ErrorOptions {
|
||||
target: Element | null;
|
||||
}
|
||||
|
||||
export class FocusAssertionError extends Error {
|
||||
public override name = "FocusAssertionError";
|
||||
public readonly target: Element | null;
|
||||
|
||||
constructor(message: string, { target, ...options }: FocusErrorOptions) {
|
||||
super(message, options);
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertFocusable(target: Element | null | undefined): asserts target is HTMLElement {
|
||||
if (!target) {
|
||||
throw new FocusAssertionError("Skipping focus, no target", { target: null });
|
||||
}
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new FocusAssertionError("Skipping focus, target is not an HTMLElement", { target });
|
||||
}
|
||||
|
||||
if (document.activeElement === target) {
|
||||
throw new FocusAssertionError("Target is already focused", { target });
|
||||
}
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
if (!visible) {
|
||||
throw new FocusAssertionError("Skipping focus, target is not visible", { target });
|
||||
}
|
||||
|
||||
if (typeof target.focus !== "function") {
|
||||
throw new FocusAssertionError("Skipping focus, target has no focus method", { target });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Recursively check if the target element or any of its children are active (i.e. "focused").
|
||||
*
|
||||
@@ -36,35 +74,17 @@ export function isActiveElement(
|
||||
* @category DOM
|
||||
*/
|
||||
export function isFocusable(target: Element | null | undefined): target is HTMLElement {
|
||||
if (!target) {
|
||||
console.debug("FocusTarget: Skipping focus, no target", target);
|
||||
try {
|
||||
assertFocusable(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof FocusAssertionError) {
|
||||
console.debug(error.message, error.target);
|
||||
} else {
|
||||
console.error("Unexpected error during focus assertion", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
console.debug("FocusTarget: Skipping focus, target is not an HTMLElement", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.activeElement === target) {
|
||||
console.debug("FocusTarget: Target is already focused", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
if (!visible) {
|
||||
console.debug("FocusTarget: Skipping focus, target is not visible", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof target.focus !== "function") {
|
||||
console.debug("FocusTarget: Skipping focus, target has no focus method", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isDefaultAvatar } from "#elements/utils/images";
|
||||
|
||||
import Styles from "#flow/FormStatic.css";
|
||||
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import { StageChallengeLike } from "#flow/types";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@@ -69,7 +70,9 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
|
||||
${flowInfo?.cancelUrl
|
||||
? html`
|
||||
<div slot="link">
|
||||
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
|
||||
<a href=${flowInfo.cancelUrl} @click=${RememberMeStorage.reset}
|
||||
>${msg("Not you?")}</a
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -121,9 +121,10 @@ export class InputPassword extends AKElement {
|
||||
|
||||
//#region Refs
|
||||
|
||||
inputRef: Ref<HTMLInputElement> = createRef();
|
||||
@property({ attribute: false, useDefault: true })
|
||||
public inputRef: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
|
||||
public toggleVisibilityRef = createRef<HTMLButtonElement>();
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
|
||||
@intersectionObserver()
|
||||
public visible = false;
|
||||
|
||||
protected autofocusTarget = new FocusTarget();
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
focus = this.autofocusTarget.focus;
|
||||
|
||||
#visibilityListener = () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AKLabel } from "#components/ak-label";
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
|
||||
import CaptchaDisplayController from "#flow/stages/identification/controllers/CaptchaDisplayController";
|
||||
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import RememberMeController from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
|
||||
import Styles from "#flow/stages/identification/styles.css";
|
||||
|
||||
@@ -30,6 +30,7 @@ import { match } from "ts-pattern";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
|
||||
import { createRef, ref } from "lit-html/directives/ref.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
@@ -45,8 +46,6 @@ type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" |
|
||||
|
||||
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
|
||||
|
||||
type EmptyString = string | null | undefined;
|
||||
|
||||
export const PasswordManagerPrefill: {
|
||||
password?: string;
|
||||
totp?: string;
|
||||
@@ -82,21 +81,26 @@ export class IdentificationStage extends BaseStage<
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
...RememberMe.styles,
|
||||
...RememberMeController.styles,
|
||||
Styles,
|
||||
];
|
||||
|
||||
/**
|
||||
* The ID of the input field.
|
||||
* The ID of the identifier input field, used for accessibility and focus management.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String, attribute: "input-id" })
|
||||
public inputID = "ak-identifier-input";
|
||||
|
||||
protected passwordFieldRef = createRef<HTMLInputElement>();
|
||||
|
||||
#form?: HTMLFormElement;
|
||||
|
||||
private rememberMe = new RememberMe(this);
|
||||
public defaultUserIdentification: string | null = null;
|
||||
|
||||
protected rememberMeController: RememberMeController | null = null;
|
||||
|
||||
#autoRedirect = new AutoRedirect(this);
|
||||
#captcha = new CaptchaDisplayController(this);
|
||||
#webauthn = new WebauthnController(this);
|
||||
@@ -109,15 +113,23 @@ export class IdentificationStage extends BaseStage<
|
||||
super();
|
||||
// We _define and instantiate_ these fields above, then _read_ them here, and that satisfies
|
||||
// the lint pass that there are no unused private fields.
|
||||
this.addController(this.rememberMe);
|
||||
this.addController(this.#autoRedirect);
|
||||
this.addController(this.#captcha);
|
||||
this.addController(this.#webauthn);
|
||||
}
|
||||
|
||||
#prepareRememberMeFrame = -1;
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("challenge") && this.challenge) {
|
||||
cancelAnimationFrame(this.#prepareRememberMeFrame);
|
||||
|
||||
this.#prepareRememberMeFrame = requestAnimationFrame(() => {
|
||||
this.prepareRememberMeController();
|
||||
});
|
||||
|
||||
this.#createHelperForm();
|
||||
}
|
||||
}
|
||||
@@ -127,10 +139,46 @@ export class IdentificationStage extends BaseStage<
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
cancelAnimationFrame(this.#prepareRememberMeFrame);
|
||||
}
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.focus();
|
||||
}
|
||||
|
||||
protected prepareRememberMeController(): void {
|
||||
if (!this.challenge) return;
|
||||
|
||||
const { enableRememberMe, pendingUserIdentifier = null } = this.challenge;
|
||||
|
||||
if (!enableRememberMe) {
|
||||
this.defaultUserIdentification = pendingUserIdentifier;
|
||||
|
||||
if (this.rememberMeController) {
|
||||
this.removeController(this.rememberMeController);
|
||||
this.rememberMeController = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.rememberMeController) {
|
||||
this.rememberMeController = new RememberMeController(this, {
|
||||
identificationFieldID: this.inputID,
|
||||
identificationFieldRef: this.autofocusTarget.reference,
|
||||
passwordFieldRef: this.passwordFieldRef,
|
||||
pendingUserIdentifier,
|
||||
});
|
||||
|
||||
this.addController(this.rememberMeController);
|
||||
}
|
||||
|
||||
this.defaultUserIdentification = this.rememberMeController.defaultUserIdentification;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Helper Form
|
||||
@@ -247,11 +295,11 @@ export class IdentificationStage extends BaseStage<
|
||||
id: string,
|
||||
type: string,
|
||||
label: string,
|
||||
username: EmptyString,
|
||||
initialUserIdentification: string | null,
|
||||
autocomplete: string,
|
||||
) {
|
||||
return html`<input
|
||||
${this.autofocusTarget.toRef()}
|
||||
${ref(this.autofocusTarget.reference)}
|
||||
id=${id}
|
||||
type=${type}
|
||||
name="uidField"
|
||||
@@ -260,56 +308,57 @@ export class IdentificationStage extends BaseStage<
|
||||
autocomplete=${autocomplete}
|
||||
spellcheck="false"
|
||||
class="pf-c-form-control"
|
||||
value=${username ?? ""}
|
||||
value=${initialUserIdentification ?? ""}
|
||||
required
|
||||
/>`;
|
||||
}
|
||||
|
||||
protected renderPasswordFields(challenge: IdentificationChallenge) {
|
||||
const { allowShowPassword } = challenge;
|
||||
return html`
|
||||
<ak-flow-input-password
|
||||
label=${msg("Password")}
|
||||
input-id="ak-stage-identification-password"
|
||||
class="pf-c-form__group"
|
||||
.errors=${challenge.responseErrors?.password}
|
||||
?allow-show-password=${allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill.password ?? ""}
|
||||
></ak-flow-input-password>
|
||||
`;
|
||||
return html`<ak-flow-input-password
|
||||
.inputRef=${this.passwordFieldRef}
|
||||
label=${msg("Password")}
|
||||
input-id="ak-stage-identification-password"
|
||||
class="pf-c-form__group"
|
||||
.errors=${challenge.responseErrors?.password}
|
||||
?allow-show-password=${allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill.password ?? ""}
|
||||
></ak-flow-input-password> `;
|
||||
}
|
||||
|
||||
protected renderInput(challenge: IdentificationChallenge) {
|
||||
const {
|
||||
flowDesignation,
|
||||
passwordFields,
|
||||
passwordlessUrl,
|
||||
pendingUserIdentifier,
|
||||
primaryAction,
|
||||
userFields,
|
||||
} = challenge;
|
||||
const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } =
|
||||
challenge;
|
||||
|
||||
const fields = (userFields || []).sort();
|
||||
if (fields.length === 0) {
|
||||
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
|
||||
}
|
||||
|
||||
const { inputID, rememberMe } = this;
|
||||
const {
|
||||
inputID,
|
||||
defaultUserIdentification: initialUserIdentification,
|
||||
rememberMeController,
|
||||
} = this;
|
||||
|
||||
const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery;
|
||||
const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text";
|
||||
const label = OR_LIST_FORMATTERS.format(fields.map((f) => UI_FIELDS[f]));
|
||||
const username = rememberMe.username ?? pendingUserIdentifier;
|
||||
|
||||
// When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill
|
||||
const autocomplete: AutoFill = this.#webauthn.live ? "username webauthn" : "username";
|
||||
|
||||
console.debug(
|
||||
"Rendering identification stage with fields:",
|
||||
fields,
|
||||
initialUserIdentification,
|
||||
);
|
||||
// prettier-ignore
|
||||
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: inputID }, label)}
|
||||
${this.renderUidField(inputID, type, label, username, autocomplete)}
|
||||
${rememberMe.render()}
|
||||
${this.renderUidField(inputID, type, label, initialUserIdentification, autocomplete)}
|
||||
${rememberMeController?.renderToggleInput() ?? null}
|
||||
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
|
||||
</div>
|
||||
${passwordFields ? this.renderPasswordFields(challenge) : nothing}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { StorageAccessor } from "#common/storage";
|
||||
import { getCookie } from "#common/utils";
|
||||
|
||||
import { ReactiveElementHost } from "#elements/types";
|
||||
|
||||
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, ReactiveController } from "lit";
|
||||
import { createRef, Ref } from "lit-html/directives/ref.js";
|
||||
|
||||
export class RememberMeStorage {
|
||||
static readonly user = StorageAccessor.local("authentik-remember-me-user");
|
||||
static readonly session = StorageAccessor.local("authentik-remember-me-session");
|
||||
static reset = () => {
|
||||
this.user.delete();
|
||||
this.session.delete();
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionID() {
|
||||
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
|
||||
}
|
||||
|
||||
export interface RememberMeControllerInit {
|
||||
pendingUserIdentifier: string | null;
|
||||
identificationFieldRef: Ref<HTMLInputElement>;
|
||||
passwordFieldRef: Ref<HTMLInputElement> | null;
|
||||
identificationFieldID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the user's `username` "on this device."
|
||||
@@ -24,7 +48,7 @@ type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
* came back to this view after reaching the identity proof phase, indicating they pressed the "not
|
||||
* you?" link, at which point it begins again to record the username as it is typed in.
|
||||
*/
|
||||
export class RememberMe implements ReactiveController {
|
||||
export class RememberMeController implements ReactiveController {
|
||||
static readonly styles = [
|
||||
css`
|
||||
.remember-me-switch {
|
||||
@@ -35,121 +59,178 @@ export class RememberMe implements ReactiveController {
|
||||
`,
|
||||
];
|
||||
|
||||
public username?: string;
|
||||
//#region Lifecycle
|
||||
|
||||
#trackRememberMe = () => {
|
||||
if (!this.#usernameField || this.#usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.#usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
};
|
||||
public readonly identificationFieldRef: Ref<HTMLInputElement>;
|
||||
public readonly passwordFieldRef: Ref<HTMLInputElement> | null;
|
||||
public readonly defaultChecked: boolean;
|
||||
public readonly defaultUserIdentification: string | null;
|
||||
public readonly identificationFieldID: string;
|
||||
|
||||
// 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);
|
||||
};
|
||||
protected logger = ConsoleLogger.prefix("controller/remember-me");
|
||||
protected autoSubmitAttempts = 0;
|
||||
protected currentSessionID = readSessionID();
|
||||
|
||||
constructor(private host: RememberMeHost) {}
|
||||
constructor(
|
||||
protected host: ReactiveElementHost<IdentificationStage>,
|
||||
{
|
||||
identificationFieldRef,
|
||||
passwordFieldRef,
|
||||
identificationFieldID,
|
||||
}: RememberMeControllerInit,
|
||||
) {
|
||||
this.identificationFieldRef = identificationFieldRef;
|
||||
this.passwordFieldRef = passwordFieldRef || null;
|
||||
this.identificationFieldID = identificationFieldID;
|
||||
|
||||
// Record a stable token that we can use between requests to track if we've
|
||||
// been here before. If we can't, clear out the username.
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
const persistedSessionID = RememberMeStorage.session.read();
|
||||
|
||||
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 #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.
|
||||
public hostUpdate() {
|
||||
if (!this.#isEnabled) {
|
||||
return;
|
||||
if (persistedSessionID && persistedSessionID !== this.currentSessionID) {
|
||||
this.logger.debug("Session ID mismatch, clearing remembered username");
|
||||
RememberMeStorage.user.delete();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const persistedUserIdentifier = RememberMeStorage.user.read();
|
||||
|
||||
this.defaultUserIdentification =
|
||||
persistedUserIdentifier || this.host.challenge?.pendingUserIdentifier || null;
|
||||
|
||||
this.defaultChecked = !!persistedUserIdentifier;
|
||||
}
|
||||
|
||||
// After the page is updated, if everything is ready to go, do the autosubmit.
|
||||
public hostUpdated() {
|
||||
if (this.#isEnabled && this.#canAutoSubmit) {
|
||||
this.#submitButton?.click();
|
||||
if (this.canAutoSubmit() && this.autoSubmitAttempts === 0) {
|
||||
this.autoSubmitAttempts++;
|
||||
this.host.submitForm?.();
|
||||
}
|
||||
}
|
||||
|
||||
public 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;
|
||||
//#region Event Listeners
|
||||
|
||||
#writeFrameID = -1;
|
||||
|
||||
public inputListener = (event: InputEvent) => {
|
||||
cancelAnimationFrame(this.#writeFrameID);
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
|
||||
this.#writeFrameID = requestAnimationFrame(() => {
|
||||
RememberMeStorage.user.write(value);
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public API
|
||||
|
||||
/**
|
||||
* Toggle the "remember me" feature on or off.
|
||||
*
|
||||
* When toggled on, the current username is saved to localStorage and will be automatically
|
||||
* submitted on future visits. Additionally, every keystroke in the username field will update
|
||||
* the stored username.
|
||||
*
|
||||
* When toggled off, any stored username is cleared from localStorage, and the keystroke listener
|
||||
* is removed to stop updating the stored username.
|
||||
*/
|
||||
public toggleChangeListener = (event: Event) => {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
const { usernameField, passwordField } = this;
|
||||
|
||||
if (!checkbox.checked) {
|
||||
this.logger.debug("Disabling remember me");
|
||||
|
||||
RememberMeStorage.reset();
|
||||
|
||||
if (usernameField) {
|
||||
usernameField.removeEventListener("input", this.inputListener);
|
||||
usernameField.focus();
|
||||
usernameField.select();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usernameField) {
|
||||
this.logger.warn("Cannot enable remember me: no username field found");
|
||||
return;
|
||||
}
|
||||
|
||||
const focusTarget = passwordField && usernameField?.value ? passwordField : usernameField;
|
||||
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
focusTarget.select();
|
||||
}
|
||||
|
||||
this.logger.debug("Enabling remember me for user");
|
||||
|
||||
RememberMeStorage.user.write(usernameField.value);
|
||||
RememberMeStorage.session.write(this.currentSessionID);
|
||||
|
||||
usernameField.addEventListener("input", this.inputListener, {
|
||||
passive: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the "remember me" feature can be automatically submitted, which requires:
|
||||
*
|
||||
* - An active challenge.
|
||||
* - A stored username from a previous session.
|
||||
* - The identifier input field to be present in the DOM.
|
||||
* - No password fields or passwordless URL, indicating we can skip directly to the next step.
|
||||
*/
|
||||
public canAutoSubmit(): boolean {
|
||||
const { challenge } = this.host;
|
||||
|
||||
if (!challenge) return false;
|
||||
if (!challenge.enableRememberMe) return false;
|
||||
|
||||
if (challenge.passwordFields) return false;
|
||||
if (challenge.passwordlessUrl) return false;
|
||||
|
||||
if (!this.defaultChecked) return false;
|
||||
return !!this.usernameField?.value;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
protected readonly checkboxRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected get usernameField() {
|
||||
return this.identificationFieldRef.value || null;
|
||||
}
|
||||
|
||||
protected get passwordField() {
|
||||
return this.passwordFieldRef?.value || null;
|
||||
}
|
||||
|
||||
protected get checkboxToggle() {
|
||||
return this.checkboxRef.value || null;
|
||||
}
|
||||
public renderToggleInput = () => {
|
||||
return html`<label
|
||||
class="pf-c-switch remember-me-switch"
|
||||
for="authentik-remember-me"
|
||||
aria-description=${msg(
|
||||
"When enabled, your username will be remembered on this device for future logins.",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
id="authentik-remember-me"
|
||||
@change=${this.toggleChangeListener}
|
||||
?checked=${this.defaultChecked}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`;
|
||||
};
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export default RememberMe;
|
||||
export default RememberMeController;
|
||||
|
||||
@@ -179,7 +179,7 @@ test.describe("Groups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
|
||||
test("Edit group from view page", async ({ form, pointer, page }, testInfo) => {
|
||||
const groupName = groupNames.get(testInfo.testId)!;
|
||||
|
||||
const { fill, search } = form;
|
||||
|
||||
@@ -17,11 +17,7 @@ test.describe("Provider Wizard", () => {
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({
|
||||
to: "/if/admin/#/core/providers",
|
||||
});
|
||||
});
|
||||
await test.step("Authenticate", async () => session.login());
|
||||
|
||||
await test.step("Navigate to provider wizard", async () => {
|
||||
await expect(dialog, "Dialog is initially closed").toBeHidden();
|
||||
|
||||
119
web/test/browser/session-lifecycle.test.ts
Normal file
119
web/test/browser/session-lifecycle.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { FormFixture } from "#e2e/fixtures/FormFixture";
|
||||
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
|
||||
import { GOOD_USERNAME, SessionFixture } from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
|
||||
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
|
||||
|
||||
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
|
||||
|
||||
const readStoredUserIdentifier = (page: Page) =>
|
||||
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
|
||||
|
||||
test.describe("Session Lifecycle", () => {
|
||||
test.beforeAll(
|
||||
'Ensure "Enable Remember me on this device" is on for the default identification stage',
|
||||
async ({ browser }, { title: testName }) => {
|
||||
if (Date.now()) return;
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const navigator = new NavigatorFixture(page, testName);
|
||||
const form = new FormFixture(page, testName);
|
||||
const session = new SessionFixture({ page, testName, navigator });
|
||||
|
||||
await test.step("Authenticate", async () =>
|
||||
session.login({
|
||||
to: "/if/admin/#/flow/stages",
|
||||
page,
|
||||
}));
|
||||
|
||||
const $stage = await test.step("Find stage via search", () =>
|
||||
form.search(IDENTIFICATION_STAGE_NAME, page));
|
||||
|
||||
await $stage.getByRole("button", { name: "Edit Stage" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Edit Identification Stage" });
|
||||
await expect(dialog, "Edit modal opens after clicking edit").toBeVisible();
|
||||
|
||||
await form.setInputCheck(`Enable "Remember me on this device"`, true, dialog);
|
||||
await dialog.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect(dialog, "Edit modal closes after save").toBeHidden();
|
||||
|
||||
await context.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ session, page }) => {
|
||||
await session.toLoginPage();
|
||||
|
||||
await page.evaluate(
|
||||
([userKey, sessionKey]) => {
|
||||
localStorage.removeItem(userKey);
|
||||
localStorage.removeItem(sessionKey);
|
||||
},
|
||||
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await session.$identificationStage.waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
test("Remember me persists username", async ({ navigator, session, page }) => {
|
||||
await test.step("Verify identification stage", async () => {
|
||||
await expect(
|
||||
session.$rememberMeCheckbox,
|
||||
"Remember me checkbox is visible",
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
session.$rememberMeCheckbox,
|
||||
"Remember me checkbox is not checked by default",
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
await test.step("Identify with remember-me enabled", async () => {
|
||||
await session.login(
|
||||
{
|
||||
rememberMe: true,
|
||||
to: "if/user/#/library",
|
||||
},
|
||||
page,
|
||||
);
|
||||
|
||||
const storedUserIdentifier = await readStoredUserIdentifier(page);
|
||||
|
||||
expect(
|
||||
storedUserIdentifier,
|
||||
"username persists to localStorage when remember-me is checked",
|
||||
).toBe(GOOD_USERNAME);
|
||||
});
|
||||
|
||||
await test.step("Sign out and verify username is remembered", async () => {
|
||||
const signOutLink = page.getByRole("link", { name: "Sign out" });
|
||||
|
||||
await expect(signOutLink, "Sign out link is visible").toBeVisible();
|
||||
|
||||
await signOutLink.click();
|
||||
|
||||
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
|
||||
|
||||
const notYouLink = page.getByRole("link", { name: "Not you?" });
|
||||
|
||||
await expect(notYouLink, "Not you? link is visible after sign out").toBeVisible();
|
||||
|
||||
await notYouLink.click();
|
||||
|
||||
await expect(
|
||||
session.$identificationStage,
|
||||
"Identification stage is visible after clicking not you link",
|
||||
).toBeVisible();
|
||||
|
||||
const storedUserIdentifier = await readStoredUserIdentifier(page);
|
||||
|
||||
expect(storedUserIdentifier, "Removed after clicking not you link").toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user