mirror of
https://github.com/goauthentik/authentik
synced 2026-05-14 10:56:52 +02:00
Compare commits
8 Commits
tests/conf
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0f13299b | ||
|
|
f4bc6ea895 | ||
|
|
336f2d309e | ||
|
|
b2b32e60e3 | ||
|
|
0a5e117663 | ||
|
|
a24948259c | ||
|
|
5b0fa6d071 | ||
|
|
246165ae70 |
2
web/.gitignore
vendored
2
web/.gitignore
vendored
@@ -25,6 +25,8 @@ lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
90
web/e2e/elements/proxy.ts
Normal file
90
web/e2e/elements/proxy.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { expect, Locator } from "@playwright/test";
|
||||
import { kebabCase } from "change-case";
|
||||
|
||||
export type LocatorMatchers = ReturnType<typeof expect<Locator>>;
|
||||
|
||||
export interface LocatorProxy extends Pick<Locator, keyof Locator> {
|
||||
$: Locator;
|
||||
expect: LocatorMatchers;
|
||||
}
|
||||
|
||||
// Type helpers to extract the shape of the proxy
|
||||
export type DeepLocatorProxy<T> =
|
||||
Disposable & T extends Record<string, any>
|
||||
? T extends HTMLElement
|
||||
? LocatorProxy
|
||||
: {
|
||||
[K in keyof T]: DeepLocatorProxy<T[K]>;
|
||||
}
|
||||
: LocatorProxy;
|
||||
|
||||
export function createLocatorProxy<T extends Record<string, any>>(
|
||||
ctx: LocatorContext,
|
||||
initialPathPrefix: string[] = [],
|
||||
dataAttribute: string = "test-id",
|
||||
): DeepLocatorProxy<T> {
|
||||
dataAttribute = kebabCase(dataAttribute);
|
||||
|
||||
function createProxy(path: string[] = initialPathPrefix): any {
|
||||
const proxyCache = new Map<string, LocatorProxy>();
|
||||
|
||||
return new Proxy({} as any, {
|
||||
get(_, property: string) {
|
||||
// Build the current path
|
||||
const currentPath = [...path, property];
|
||||
|
||||
// Convert the path to kebab-case and join with hyphens
|
||||
const selectorValue = currentPath.map((segment) => kebabCase(segment)).join("-");
|
||||
const selector = `[data-${dataAttribute}="${selectorValue}"]`;
|
||||
|
||||
// Create a locator for the current selector
|
||||
const locator = ctx.locator(selector);
|
||||
|
||||
if (proxyCache.has(selector)) {
|
||||
ConsoleLogger.debug(`Using cached locator for ${selector}`);
|
||||
return proxyCache.get(selector)!;
|
||||
}
|
||||
|
||||
// Return a new proxy that also behaves like a Locator
|
||||
// This allows us to either continue chaining or use Locator methods
|
||||
const nextProxy = new Proxy(locator, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === "string") {
|
||||
// The user is likely trying to access a property on the page.
|
||||
if (prop === "$") {
|
||||
return target as any;
|
||||
}
|
||||
|
||||
if (prop === "expect") {
|
||||
return expect(target);
|
||||
}
|
||||
}
|
||||
|
||||
// If the property exists on the Locator, use it
|
||||
if (prop in target) {
|
||||
const value = (target as any)[prop];
|
||||
// Bind methods to the locator instance
|
||||
if (typeof value === "function") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// Otherwise, continue building the path
|
||||
|
||||
return createProxy(currentPath)[prop];
|
||||
},
|
||||
});
|
||||
|
||||
proxyCache.set(selector, nextProxy as LocatorProxy);
|
||||
|
||||
return nextProxy;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createProxy() as DeepLocatorProxy<T>;
|
||||
}
|
||||
175
web/e2e/fixtures/FormFixture.ts
Normal file
175
web/e2e/fixtures/FormFixture.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
export class FormFixture extends PageFixture {
|
||||
static fixtureName = "Form";
|
||||
|
||||
//#region Selector Methods
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Field Methods
|
||||
|
||||
/**
|
||||
* Set the value of a text input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public fill = async (
|
||||
fieldName: string,
|
||||
value: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent
|
||||
.getByRole("textbox", {
|
||||
name: fieldName,
|
||||
})
|
||||
.or(
|
||||
parent.getByRole("spinbutton", {
|
||||
name: fieldName,
|
||||
}),
|
||||
)
|
||||
.first();
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
await control.fill(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a radio or checkbox input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public setInputCheck = async (
|
||||
fieldName: string,
|
||||
value: boolean = true,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent.locator("ak-switch-input", {
|
||||
hasText: fieldName,
|
||||
});
|
||||
|
||||
await control.scrollIntoViewIfNeeded();
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
const currentChecked = await control
|
||||
.getAttribute("checked")
|
||||
.then((value) => value !== null);
|
||||
|
||||
if (currentChecked === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await control.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a radio or checkbox input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param pattern the value to set.
|
||||
*/
|
||||
public setRadio = async (
|
||||
groupName: string,
|
||||
fieldName: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const group = parent.getByRole("group", { name: groupName });
|
||||
|
||||
await expect(group, `Field "${groupName}" should be visible`).toBeVisible();
|
||||
const control = parent.getByRole("radio", { name: fieldName });
|
||||
|
||||
await control.setChecked(true, {
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a search select input.
|
||||
*
|
||||
* @param fieldLabel The name of the search select element.
|
||||
* @param pattern The text to match against the search select entry.
|
||||
*/
|
||||
public selectSearchValue = async (
|
||||
fieldLabel: string,
|
||||
pattern: string | RegExp,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent.getByRole("textbox", { name: fieldLabel });
|
||||
|
||||
await expect(
|
||||
control,
|
||||
`Search select control (${fieldLabel}) should be visible`,
|
||||
).toBeVisible();
|
||||
|
||||
const fieldName = await control.getAttribute("name");
|
||||
|
||||
if (!fieldName) {
|
||||
throw new Error(`Unable to find name attribute on search select (${fieldLabel})`);
|
||||
}
|
||||
|
||||
// Find the search select input control and activate it.
|
||||
await control.click();
|
||||
|
||||
const button = this.page
|
||||
// ---
|
||||
.locator(`div[data-managed-for*="${fieldName}"] button`, {
|
||||
hasText: pattern,
|
||||
});
|
||||
|
||||
if (!button) {
|
||||
throw new Error(
|
||||
`Unable to find an ak-search-select entry matching ${fieldLabel}:${pattern.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
await button.click();
|
||||
await this.page.keyboard.press("Tab");
|
||||
await control.blur();
|
||||
};
|
||||
|
||||
public setFormGroup = async (
|
||||
pattern: string | RegExp,
|
||||
value: boolean = true,
|
||||
parent: LocatorContext = this.page,
|
||||
) => {
|
||||
const control = parent
|
||||
.locator("ak-form-group", {
|
||||
hasText: pattern,
|
||||
})
|
||||
.first();
|
||||
|
||||
const currentOpen = await control.getAttribute("open").then((value) => value !== null);
|
||||
|
||||
if (currentOpen === value) {
|
||||
this.logger.debug(`Form group ${pattern} is already ${value ? "open" : "closed"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Toggling form group ${pattern} to ${value ? "open" : "closed"}`);
|
||||
|
||||
await control.click();
|
||||
|
||||
if (value) {
|
||||
await expect(control).toHaveAttribute("open");
|
||||
} else {
|
||||
await expect(control).not.toHaveAttribute("open");
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor(page: Page, testName: string) {
|
||||
super({ page, testName });
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
30
web/e2e/fixtures/PageFixture.ts
Normal file
30
web/e2e/fixtures/PageFixture.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ConsoleLogger, FixtureLogger } from "#logger/node";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export interface PageFixtureOptions {
|
||||
page: Page;
|
||||
testName: string;
|
||||
}
|
||||
|
||||
export abstract class PageFixture {
|
||||
/**
|
||||
* The name of the fixture.
|
||||
*
|
||||
* Used for logging.
|
||||
*/
|
||||
static fixtureName: string;
|
||||
|
||||
protected readonly logger: FixtureLogger;
|
||||
protected readonly page: Page;
|
||||
protected readonly testName: string;
|
||||
|
||||
constructor({ page, testName }: PageFixtureOptions) {
|
||||
this.page = page;
|
||||
this.testName = testName;
|
||||
|
||||
const Constructor = this.constructor as typeof PageFixture;
|
||||
|
||||
this.logger = ConsoleLogger.fixture(Constructor.fixtureName, this.testName);
|
||||
}
|
||||
}
|
||||
42
web/e2e/fixtures/PointerFixture.ts
Normal file
42
web/e2e/fixtures/PointerFixture.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export type GetByRoleParameters = Parameters<Page["getByRole"]>;
|
||||
export type ARIARole = GetByRoleParameters[0];
|
||||
export type ARIAOptions = GetByRoleParameters[1];
|
||||
|
||||
export type ClickByName = (name: string) => Promise<void>;
|
||||
export type ClickByRole = (
|
||||
role: ARIARole,
|
||||
options?: ARIAOptions,
|
||||
context?: LocatorContext,
|
||||
) => Promise<void>;
|
||||
|
||||
export class PointerFixture extends PageFixture {
|
||||
public static fixtureName = "Pointer";
|
||||
|
||||
public click = (
|
||||
name: string,
|
||||
optionsOrRole?: ARIAOptions | ARIARole,
|
||||
context: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
if (typeof optionsOrRole === "string") {
|
||||
return context.getByRole(optionsOrRole, { name }).click();
|
||||
}
|
||||
|
||||
const options = {
|
||||
...optionsOrRole,
|
||||
name,
|
||||
};
|
||||
|
||||
return (
|
||||
context
|
||||
// ---
|
||||
.getByRole("button", options)
|
||||
.or(context.getByRole("link", options))
|
||||
.click()
|
||||
);
|
||||
};
|
||||
}
|
||||
102
web/e2e/fixtures/SessionFixture.ts
Normal file
102
web/e2e/fixtures/SessionFixture.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const GOOD_USERNAME = "test-admin@goauthentik.io";
|
||||
export const GOOD_PASSWORD = "test-runner";
|
||||
|
||||
export const BAD_USERNAME = "bad-username@bad-login.io";
|
||||
export const BAD_PASSWORD = "-this-is-a-bad-password-";
|
||||
|
||||
export interface LoginInit {
|
||||
username?: string;
|
||||
password?: string;
|
||||
to?: URL | string;
|
||||
}
|
||||
|
||||
export class SessionFixture extends PageFixture {
|
||||
static fixtureName = "Session";
|
||||
|
||||
public static readonly pathname = "/if/flow/default-authentication-flow/";
|
||||
|
||||
//#region Selectors
|
||||
|
||||
public $identificationStage = this.page.locator("ak-stage-identification");
|
||||
|
||||
/**
|
||||
* The username field on the login page.
|
||||
*/
|
||||
public $usernameField = this.page.getByLabel("Username");
|
||||
|
||||
public $passwordStage = this.page.locator("ak-stage-password");
|
||||
public $passwordField = this.page.getByLabel("Password");
|
||||
|
||||
/**
|
||||
* The button to submit the the login flow,
|
||||
* typically redirecting to the authenticated interface.
|
||||
*/
|
||||
public $submitButton = this.page.locator('button[type="submit"]');
|
||||
|
||||
/**
|
||||
* A possible authentication failure message.
|
||||
*/
|
||||
public $authFailureMessage = this.page.locator(".pf-m-error");
|
||||
|
||||
//#endregion
|
||||
|
||||
constructor(page: Page, testName: string) {
|
||||
super({ page, testName });
|
||||
}
|
||||
|
||||
//#region Specific interactions
|
||||
|
||||
public checkAuthenticated = async (): Promise<boolean> => {
|
||||
// TODO: Check if the user is authenticated via API
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Log into the application.
|
||||
*/
|
||||
public async login({
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
}: LoginInit = {}) {
|
||||
this.logger.info("Logging in...");
|
||||
|
||||
const initialURL = new URL(this.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 this.$usernameField.fill(username);
|
||||
|
||||
const passwordFieldVisible = await this.$passwordField.isVisible();
|
||||
|
||||
if (!passwordFieldVisible) {
|
||||
await this.$submitButton.click();
|
||||
|
||||
await this.$passwordField.waitFor({ state: "visible" });
|
||||
}
|
||||
|
||||
await this.$passwordField.fill(password);
|
||||
|
||||
await this.$submitButton.click();
|
||||
|
||||
const expectedPathname = typeof to === "string" ? to : to.pathname;
|
||||
|
||||
await this.page.waitForURL(`**${expectedPathname}`);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Navigation
|
||||
|
||||
public async toLoginPage() {
|
||||
await this.page.goto(SessionFixture.pathname);
|
||||
}
|
||||
}
|
||||
56
web/e2e/index.ts
Normal file
56
web/e2e/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { createLocatorProxy, DeepLocatorProxy } from "#e2e/elements/proxy";
|
||||
import { FormFixture } from "#e2e/fixtures/FormFixture";
|
||||
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
|
||||
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
|
||||
import { createOUIDNameEngine } from "#e2e/selectors/ouid";
|
||||
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
|
||||
type TestIDLocatorProxy = DeepLocatorProxy<TestIDSelectorMap>;
|
||||
|
||||
interface E2EFixturesTestScope {
|
||||
/**
|
||||
* A proxy to retrieve elements by test ID.
|
||||
*
|
||||
* ```ts
|
||||
* const $button = $.button;
|
||||
* ```
|
||||
*/
|
||||
$: TestIDLocatorProxy;
|
||||
session: SessionFixture;
|
||||
pointer: PointerFixture;
|
||||
form: FormFixture;
|
||||
}
|
||||
|
||||
interface E2EWorkerScope {
|
||||
selectorRegistration: void;
|
||||
}
|
||||
|
||||
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
|
||||
selectorRegistration: [
|
||||
async ({ playwright }, use) => {
|
||||
await playwright.selectors.register("ouid", createOUIDNameEngine);
|
||||
await use();
|
||||
},
|
||||
{ auto: true, scope: "worker" },
|
||||
],
|
||||
|
||||
$: async ({ page }, use) => {
|
||||
await use(createLocatorProxy<TestIDSelectorMap>(page));
|
||||
},
|
||||
|
||||
session: async ({ page }, use, { title }) => {
|
||||
await use(new SessionFixture(page, title));
|
||||
},
|
||||
|
||||
form: async ({ page }, use, { title }) => {
|
||||
await use(new FormFixture(page, title));
|
||||
},
|
||||
|
||||
pointer: async ({ page }, use, { title }) => {
|
||||
await use(new PointerFixture({ page, testName: title }));
|
||||
},
|
||||
});
|
||||
44
web/e2e/selectors/ouid.ts
Normal file
44
web/e2e/selectors/ouid.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
type SelectorRoot = Document | ShadowRoot;
|
||||
|
||||
export function createOUIDNameEngine() {
|
||||
const attributeName = "data-ouid-component-name";
|
||||
|
||||
console.log("Creating OUID selector engine!!");
|
||||
return {
|
||||
// Returns all elements matching given selector in the root's subtree.
|
||||
queryAll(scope: SelectorRoot, componentName: string) {
|
||||
const result: Element[] = [];
|
||||
|
||||
const match = (element: Element) => {
|
||||
const name = element.getAttribute(attributeName);
|
||||
|
||||
if (name === componentName) {
|
||||
result.push(element);
|
||||
}
|
||||
};
|
||||
|
||||
const query = (root: Element | ShadowRoot | Document) => {
|
||||
const shadows: ShadowRoot[] = [];
|
||||
|
||||
if ((root as Element).shadowRoot) {
|
||||
shadows.push((root as Element).shadowRoot!);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll("*")) {
|
||||
match(element);
|
||||
|
||||
if (element.shadowRoot) {
|
||||
shadows.push(element.shadowRoot);
|
||||
}
|
||||
}
|
||||
|
||||
shadows.forEach(query);
|
||||
};
|
||||
|
||||
query(scope);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
13
web/e2e/selectors/types.ts
Normal file
13
web/e2e/selectors/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
|
||||
export type LocatorContext = Pick<
|
||||
Locator,
|
||||
| "locator"
|
||||
| "getByRole"
|
||||
| "getByTestId"
|
||||
| "getByText"
|
||||
| "getByLabel"
|
||||
| "getByAltText"
|
||||
| "getByTitle"
|
||||
| "getByPlaceholder"
|
||||
>;
|
||||
60
web/e2e/utils/generators.ts
Normal file
60
web/e2e/utils/generators.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import {
|
||||
adjectives,
|
||||
colors,
|
||||
Config as NameConfig,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator";
|
||||
|
||||
/**
|
||||
* Given a dictionary of words, slice the dictionary to only include words that start with the given letter.
|
||||
*/
|
||||
export function alliterate(dictionary: string[], letter: string): string[] {
|
||||
let firstIndex = 0;
|
||||
|
||||
for (let i = 0; i < dictionary.length; i++) {
|
||||
if (dictionary[i][0] === letter) {
|
||||
firstIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lastIndex = firstIndex;
|
||||
|
||||
for (let i = firstIndex; i < dictionary.length; i++) {
|
||||
if (dictionary[i][0] !== letter) {
|
||||
lastIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary.slice(firstIndex, lastIndex);
|
||||
}
|
||||
|
||||
export function createRandomName({
|
||||
seed = IDGenerator.randomID(),
|
||||
...config
|
||||
}: Partial<NameConfig> = {}) {
|
||||
const randomLetterIndex =
|
||||
typeof seed === "number"
|
||||
? seed
|
||||
: Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const letter = adjectives[randomLetterIndex % adjectives.length][0];
|
||||
|
||||
const availableAdjectives = alliterate(adjectives, letter);
|
||||
|
||||
const availableColors = alliterate(colors, letter);
|
||||
|
||||
const name = uniqueNamesGenerator({
|
||||
dictionaries: [availableAdjectives, availableAdjectives, availableColors],
|
||||
style: "capital",
|
||||
separator: " ",
|
||||
length: 3,
|
||||
seed,
|
||||
...config,
|
||||
});
|
||||
|
||||
return name;
|
||||
}
|
||||
102
web/logger/node.js
Normal file
102
web/logger/node.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Application logger.
|
||||
*
|
||||
* @import { LoggerOptions, Logger, Level, ChildLoggerOptions } from "pino"
|
||||
* @import { PrettyOptions } from "pino-pretty"
|
||||
*/
|
||||
|
||||
import { pino } from "pino";
|
||||
|
||||
//#region Constants
|
||||
|
||||
/**
|
||||
* Default options for creating a Pino logger.
|
||||
*
|
||||
* @category Logger
|
||||
* @satisfies {LoggerOptions<never, false>}
|
||||
*/
|
||||
export const DEFAULT_PINO_LOGGER_OPTIONS = {
|
||||
enabled: true,
|
||||
level: "info",
|
||||
transport: {
|
||||
target: "./transport.js",
|
||||
options: /** @satisfies {PrettyOptions} */ ({
|
||||
colorize: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Functions
|
||||
|
||||
/**
|
||||
* Read the log level from the environment.
|
||||
* @return {Level}
|
||||
*/
|
||||
export function readLogLevel() {
|
||||
return process.env.AK_LOG_LEVEL || DEFAULT_PINO_LOGGER_OPTIONS.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Logger} FixtureLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* @this {Logger}
|
||||
* @param {string} fixtureName
|
||||
* @param {string} [testName]
|
||||
* @param {ChildLoggerOptions} [options]
|
||||
* @returns {FixtureLogger}
|
||||
*/
|
||||
function createFixtureLogger(fixtureName, testName, options) {
|
||||
return this.child(
|
||||
{ name: fixtureName },
|
||||
{
|
||||
msgPrefix: `[${testName}] `,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomLoggerMethods
|
||||
* @property {typeof createFixtureLogger} fixture
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Logger & CustomLoggerMethods} ConsoleLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* A singleton logger instance for Node.js.
|
||||
*
|
||||
* ```js
|
||||
* import { ConsoleLogger } from "#logger/node";
|
||||
*
|
||||
* ConsoleLogger.info("Hello, world!");
|
||||
* ```
|
||||
*
|
||||
* @runtime node
|
||||
* @type {ConsoleLogger}
|
||||
*/
|
||||
export const ConsoleLogger = Object.assign(
|
||||
pino({
|
||||
...DEFAULT_PINO_LOGGER_OPTIONS,
|
||||
level: readLogLevel(),
|
||||
}),
|
||||
{ fixture: createFixtureLogger },
|
||||
);
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<ConsoleLogger['child']>} ChildConsoleLogger
|
||||
*/
|
||||
|
||||
//#region Aliases
|
||||
|
||||
export const info = ConsoleLogger.info.bind(ConsoleLogger);
|
||||
export const debug = ConsoleLogger.debug.bind(ConsoleLogger);
|
||||
export const warn = ConsoleLogger.warn.bind(ConsoleLogger);
|
||||
export const error = ConsoleLogger.error.bind(ConsoleLogger);
|
||||
|
||||
//#endregion
|
||||
22
web/logger/transport.js
Normal file
22
web/logger/transport.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file Pretty transport for Pino
|
||||
*
|
||||
* @import { PrettyOptions } from "pino-pretty"
|
||||
*/
|
||||
|
||||
import PinoPretty from "pino-pretty";
|
||||
|
||||
/**
|
||||
* @param {PrettyOptions} options
|
||||
*/
|
||||
function prettyTransporter(options) {
|
||||
const pretty = PinoPretty({
|
||||
...options,
|
||||
ignore: "pid,hostname",
|
||||
translateTime: "SYS:HH:MM:ss",
|
||||
});
|
||||
|
||||
return pretty;
|
||||
}
|
||||
|
||||
export default prettyTransporter;
|
||||
1759
web/package-lock.json
generated
1759
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,8 +24,8 @@
|
||||
"pseudolocalize": "node ./scripts/pseudolocalize.mjs",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "wireit",
|
||||
"test": "wireit",
|
||||
"test:e2e": "wireit",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:watch": "wireit",
|
||||
"test:watch": "wireit",
|
||||
"tsc": "wireit",
|
||||
@@ -69,6 +69,9 @@
|
||||
"#flow/*": "./src/flow/*.js",
|
||||
"#locales/*": "./src/locales/*.js",
|
||||
"#stories/*": "./src/stories/*.js",
|
||||
"#tests/*": "./tests/*.js",
|
||||
"#e2e": "./e2e/index.ts",
|
||||
"#e2e/*": "./e2e/*.ts",
|
||||
"#*/browser": {
|
||||
"types": "./out/*/browser.d.ts",
|
||||
"import": "./*/browser.js"
|
||||
@@ -113,6 +116,7 @@
|
||||
"@openlayers-elements/maps": "^0.4.0",
|
||||
"@patternfly/elements": "^4.1.0",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@sentry/browser": "^10.0.0",
|
||||
"@spotlightjs/spotlight": "^3.0.1",
|
||||
"@storybook/addon-docs": "^9.1.0",
|
||||
@@ -128,6 +132,7 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@wdio/browser-runner": "^9.18.4",
|
||||
"@wdio/cli": "9.15",
|
||||
"@wdio/spec-reporter": "^9.15.0",
|
||||
@@ -163,6 +168,9 @@
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^11.9.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "^1.54.1",
|
||||
"prettier": "^3.6.2",
|
||||
"pseudolocale": "^2.1.0",
|
||||
"rapidoc": "^9.3.8",
|
||||
@@ -183,7 +191,10 @@
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vite": "^7.0.6",
|
||||
"vitest": "^3.2.4",
|
||||
"webcomponent-qr-code": "^1.3.0",
|
||||
"wireit": "^0.14.12",
|
||||
"yaml": "^2.8.0"
|
||||
@@ -267,7 +278,7 @@
|
||||
"command": "lit-analyzer src"
|
||||
},
|
||||
"lint:types:tests": {
|
||||
"command": "tsc --noEmit -p ./tests"
|
||||
"command": "tsc --noEmit -p tsconfig.test.json"
|
||||
},
|
||||
"lint:types": {
|
||||
"command": "tsc -p .",
|
||||
@@ -316,7 +327,7 @@
|
||||
],
|
||||
"env": {
|
||||
"CI": "true",
|
||||
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test:e2e:watch": {
|
||||
@@ -325,7 +336,7 @@
|
||||
"build"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test:watch": {
|
||||
|
||||
94
web/playwright.config.js
Normal file
94
web/playwright.config.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file Playwright configuration.
|
||||
*
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*
|
||||
* @import { LogFn, Logger } from "pino"
|
||||
*/
|
||||
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const CI = !!process.env.CI;
|
||||
|
||||
/**
|
||||
* @type {Map<string, Logger>}
|
||||
*/
|
||||
const LoggerCache = new Map();
|
||||
|
||||
const baseURL = process.env.AK_TEST_RUNNER_PAGE_URL ?? "http://localhost:9000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./test/browser",
|
||||
fullyParallel: true,
|
||||
forbidOnly: CI,
|
||||
retries: CI ? 2 : 0,
|
||||
workers: CI ? 1 : undefined,
|
||||
reporter: CI
|
||||
? "github"
|
||||
: [
|
||||
// ---
|
||||
["list", { printSteps: true }],
|
||||
["html", { open: "never" }],
|
||||
],
|
||||
use: {
|
||||
testIdAttribute: "data-test-id",
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
launchOptions: {
|
||||
logger: {
|
||||
isEnabled() {
|
||||
return true;
|
||||
},
|
||||
log: (name, severity, message, args) => {
|
||||
let logger = LoggerCache.get(name);
|
||||
|
||||
if (!logger) {
|
||||
logger = ConsoleLogger.child({
|
||||
name: `Playwright ${name.toUpperCase()}`,
|
||||
});
|
||||
LoggerCache.set(name, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {LogFn}
|
||||
*/
|
||||
let log;
|
||||
|
||||
switch (severity) {
|
||||
case "verbose":
|
||||
log = logger.debug;
|
||||
break;
|
||||
case "warning":
|
||||
log = logger.warn;
|
||||
break;
|
||||
case "error":
|
||||
log = logger.error;
|
||||
break;
|
||||
default:
|
||||
log = logger.info;
|
||||
break;
|
||||
}
|
||||
|
||||
if (name === "api") {
|
||||
log = logger.debug;
|
||||
}
|
||||
|
||||
log.call(logger, message.toString(), args);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import { groupBy } from "#common/utils";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { IDGenerator } from "#packages/core/id";
|
||||
|
||||
import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/api";
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
@@ -38,37 +42,48 @@ export class AkProviderInput extends AKElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ type: String })
|
||||
name!: string;
|
||||
//#region Properties
|
||||
|
||||
@property({ type: String })
|
||||
label = "";
|
||||
public name!: string;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
@property({ type: Number })
|
||||
value?: number;
|
||||
public value?: number;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
public required = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
blankable = false;
|
||||
public blankable = false;
|
||||
|
||||
@property({ type: String })
|
||||
help = "";
|
||||
public help?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
}
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID = IDGenerator.elementID().toString();
|
||||
|
||||
selected(item: Provider) {
|
||||
return this.value !== undefined && this.value === item.pk;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
#selected = (item: Provider) => {
|
||||
return typeof this.value === "number" && this.value === item.pk;
|
||||
};
|
||||
|
||||
render() {
|
||||
return html` <ak-form-element-horizontal label=${this.label} name=${this.name}>
|
||||
return html` <ak-form-element-horizontal name=${this.name}>
|
||||
<div slot="label" class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)}
|
||||
</div>
|
||||
|
||||
<ak-search-select
|
||||
.selected=${this.selected}
|
||||
.fieldID=${this.fieldID}
|
||||
.selected=${this.#selected}
|
||||
.fetchObjects=${fetch}
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
|
||||
@@ -135,7 +135,8 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
|
||||
name="group"
|
||||
value=${ifDefined(app.group)}
|
||||
label=${msg("Group")}
|
||||
.errorMessages=${errors.group ?? []}
|
||||
placeholder=${msg("e.g. Collaboration, Communication, Internal, etc.")}
|
||||
.errorMessages=${errors.group}
|
||||
help=${msg(
|
||||
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
|
||||
)}
|
||||
@@ -147,7 +148,7 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
|
||||
name="policyEngineMode"
|
||||
.options=${policyEngineModes}
|
||||
.value=${app.policyEngineMode}
|
||||
.errorMessages=${errors.policyEngineMode ?? []}
|
||||
.errorMessages=${errors.policyEngineMode}
|
||||
></ak-radio-input>
|
||||
<ak-form-group label=${msg("UI Settings")}>
|
||||
<div class="pf-c-form">
|
||||
|
||||
@@ -50,13 +50,12 @@ export class CoreGroupSearch extends CustomListenerElement(AKElement) {
|
||||
search!: SearchSelect<Group>;
|
||||
|
||||
@property({ type: String })
|
||||
name: string | null | undefined;
|
||||
public name?: string | null;
|
||||
|
||||
selectedGroup?: Group;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
|
||||
}
|
||||
|
||||
@@ -83,9 +82,9 @@ export class CoreGroupSearch extends CustomListenerElement(AKElement) {
|
||||
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
selected(group: Group) {
|
||||
selected = (group: Group) => {
|
||||
return this.group === group.pk;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
|
||||
@@ -40,7 +40,13 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
search!: SearchSelect<CertificateKeyPair>;
|
||||
|
||||
@property({ type: String })
|
||||
name: string | null | undefined;
|
||||
public name?: string | null;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string | undefined;
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder?: string | undefined;
|
||||
|
||||
/**
|
||||
* Set to `true` to allow certificates without private key to show up. When set to `false`,
|
||||
@@ -48,7 +54,7 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "nokey" })
|
||||
noKey = false;
|
||||
public noKey = false;
|
||||
|
||||
/**
|
||||
* Set this to true if, should there be only one certificate available, you want the system to
|
||||
@@ -57,16 +63,12 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "singleton" })
|
||||
singleton = false;
|
||||
public singleton = false;
|
||||
|
||||
selectedKeypair?: CertificateKeyPair;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
this.fetchObjects = this.fetchObjects.bind(this);
|
||||
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
|
||||
}
|
||||
/**
|
||||
* @todo Document this.
|
||||
*/
|
||||
public selectedKeypair?: CertificateKeyPair;
|
||||
|
||||
get value() {
|
||||
return this.selectedKeypair ? renderValue(this.selectedKeypair) : null;
|
||||
@@ -85,13 +87,13 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchUpdate(ev: CustomEvent) {
|
||||
handleSearchUpdate = (ev: CustomEvent) => {
|
||||
ev.stopPropagation();
|
||||
this.selectedKeypair = ev.detail.value;
|
||||
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
}
|
||||
};
|
||||
|
||||
async fetchObjects(query?: string): Promise<CertificateKeyPair[]> {
|
||||
fetchObjects = async (query?: string): Promise<CertificateKeyPair[]> => {
|
||||
const args: CryptoCertificatekeypairsListRequest = {
|
||||
ordering: "name",
|
||||
hasKey: !this.noKey,
|
||||
@@ -104,19 +106,21 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
args,
|
||||
);
|
||||
return certificates.results;
|
||||
}
|
||||
};
|
||||
|
||||
selected(item: CertificateKeyPair, items: CertificateKeyPair[]) {
|
||||
selected = (item: CertificateKeyPair, items: CertificateKeyPair[]) => {
|
||||
return (
|
||||
(this.singleton && !this.certificate && items.length === 1) ||
|
||||
(!!this.certificate && this.certificate === item.pk)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<ak-search-select
|
||||
name=${ifDefined(this.name ?? undefined)}
|
||||
label=${ifDefined(this.label ?? undefined)}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
.fetchObjects=${this.fetchObjects}
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
|
||||
@@ -3,6 +3,7 @@ import "#elements/forms/SearchSelect/index";
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import type { HorizontalFormElement } from "#elements/forms/HorizontalFormElement";
|
||||
import { SearchSelect } from "#elements/forms/SearchSelect/index";
|
||||
import { CustomListenerElement } from "#elements/utils/eventEmitter";
|
||||
|
||||
@@ -11,6 +12,7 @@ import { RenderFlowOption } from "#admin/flows/utils";
|
||||
import type { Flow, FlowsInstancesListRequest } from "@goauthentik/api";
|
||||
import { FlowsApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { property, query } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
@@ -33,17 +35,17 @@ export function getFlowValue(flow: Flow | undefined): string | undefined {
|
||||
* A wrapper around SearchSelect that understands the basic semantics of querying about Flows. This
|
||||
* code eliminates the long blocks of unreadable invocation that were embedded in every provider, as well as in
|
||||
* sources, brands, and applications.
|
||||
*
|
||||
*/
|
||||
export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement) {
|
||||
//#region Properties
|
||||
|
||||
export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement) {
|
||||
/**
|
||||
* The type of flow we're looking for.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
flowType?: FlowsInstancesListDesignationEnum;
|
||||
public flowType?: FlowsInstancesListDesignationEnum;
|
||||
|
||||
/**
|
||||
* The id of the current flow, if any. For stages where the flow is already defined.
|
||||
@@ -51,7 +53,7 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
currentFlow?: string | undefined;
|
||||
public currentFlow?: string;
|
||||
|
||||
/**
|
||||
* If true, it is not valid to leave the flow blank.
|
||||
@@ -59,10 +61,7 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
required?: boolean = false;
|
||||
|
||||
@query("ak-search-select")
|
||||
search!: SearchSelect<T>;
|
||||
public required?: boolean;
|
||||
|
||||
/**
|
||||
* When specified and the object instance does not have a flow selected, auto-select the flow with the given slug.
|
||||
@@ -70,66 +69,118 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
defaultFlowSlug?: string;
|
||||
public defaultFlowSlug?: string;
|
||||
|
||||
@property({ type: String })
|
||||
name: string | null | undefined;
|
||||
public name?: string;
|
||||
|
||||
selectedFlow?: T;
|
||||
/**
|
||||
* The label of the input, for forms.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
/**
|
||||
* The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
* native <input> object's `placeholder` field.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public placeholder = msg("Select a flow...");
|
||||
|
||||
@query("ak-search-select")
|
||||
protected search!: SearchSelect<T>;
|
||||
|
||||
protected selectedFlow?: T;
|
||||
|
||||
get value() {
|
||||
return this.selectedFlow ? getFlowValue(this.selectedFlow) : null;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.fetchObjects = this.fetchObjects.bind(this);
|
||||
this.selected = this.selected.bind(this);
|
||||
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
protected searchUpdateListener = (event: CustomEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.selectedFlow = event.detail.value;
|
||||
|
||||
handleSearchUpdate(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this.selectedFlow = ev.detail.value;
|
||||
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
}
|
||||
};
|
||||
|
||||
async fetchObjects(query?: string): Promise<Flow[]> {
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* Fetch the objects from the API.
|
||||
*
|
||||
* @param query The search query, if any.
|
||||
*/
|
||||
protected fetchObjects = (query?: string): Promise<Flow[]> => {
|
||||
const args: FlowsInstancesListRequest = {
|
||||
ordering: "slug",
|
||||
designation: this.flowType,
|
||||
...(query !== undefined ? { search: query } : {}),
|
||||
...(query ? { search: query } : {}),
|
||||
};
|
||||
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args);
|
||||
return flows.results;
|
||||
}
|
||||
|
||||
/* This is the most commonly overridden method of this class. About half of the Flow Searches
|
||||
* use this method, but several have more complex needs, such as relating to the brand, or just
|
||||
* returning false.
|
||||
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args).then((flows) => flows.results);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if the flow matches the current state of the search.
|
||||
*
|
||||
* @param flow The flow to compare against.
|
||||
*/
|
||||
selected(flow: Flow): boolean {
|
||||
let selected = this.currentFlow === flow.pk;
|
||||
if (!this.currentFlow && this.defaultFlowSlug && flow.slug === this.defaultFlowSlug) {
|
||||
selected = true;
|
||||
protected match = (flow: Flow): boolean => {
|
||||
if (this.currentFlow) {
|
||||
return this.currentFlow === flow.pk;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
return !!(this.defaultFlowSlug && flow.slug === this.defaultFlowSlug);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the most commonly overridden method of this class.
|
||||
*
|
||||
* About half of the Flow Searches use this method, but several have more complex needs,
|
||||
* such as relating to the brand, or just returning false.
|
||||
*
|
||||
* @param flow The flow to compare against.
|
||||
* @abstract
|
||||
*/
|
||||
protected selected = (flow: Flow): boolean => {
|
||||
return this.match(flow);
|
||||
};
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const horizontalContainer = this.closest("ak-form-element-horizontal[name]");
|
||||
|
||||
const horizontalContainer = this.closest<HorizontalFormElement>(
|
||||
"ak-form-element-horizontal[name]",
|
||||
);
|
||||
|
||||
if (!horizontalContainer) {
|
||||
throw new Error("This search can only be used in a named ak-form-element-horizontal");
|
||||
}
|
||||
|
||||
const name = horizontalContainer.getAttribute("name");
|
||||
const myName = this.getAttribute("name");
|
||||
|
||||
if (name !== null && name !== myName) {
|
||||
this.setAttribute("name", name);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
|
||||
public override render() {
|
||||
return html`
|
||||
<ak-search-select
|
||||
.fetchObjects=${this.fetchObjects}
|
||||
@@ -137,13 +188,15 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
|
||||
.renderElement=${renderElement}
|
||||
.renderDescription=${renderDescription}
|
||||
.value=${getFlowValue}
|
||||
name=${ifDefined(this.name ?? undefined)}
|
||||
@ak-change=${this.handleSearchUpdate}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
label=${ifDefined(this.label)}
|
||||
name=${ifDefined(this.name)}
|
||||
@ak-change=${this.searchUpdateListener}
|
||||
?blankable=${!this.required}
|
||||
>
|
||||
</ak-search-select>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default FlowSearch;
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FlowSearch from "./FlowSearch.js";
|
||||
import { FlowSearch } from "./FlowSearch.js";
|
||||
|
||||
import type { Flow } from "@goauthentik/api";
|
||||
|
||||
@@ -19,16 +19,11 @@ export class AkBrandedFlowSearch<T extends Flow> extends FlowSearch<T> {
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: false, type: String })
|
||||
brandFlow?: string;
|
||||
public brandFlow?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
}
|
||||
|
||||
selected(flow: Flow): boolean {
|
||||
return super.selected(flow) || flow.pk === this.brandFlow;
|
||||
}
|
||||
protected override selected = (flow: Flow): boolean => {
|
||||
return this.match(flow) || flow.pk === this.brandFlow;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class AkFlowSearchNoDefault<T extends Flow> extends FlowSearch<T> {
|
||||
.renderElement=${renderElement}
|
||||
.renderDescription=${renderDescription}
|
||||
.value=${getFlowValue}
|
||||
@ak-change=${this.handleSearchUpdate}
|
||||
@ak-change=${this.searchUpdateListener}
|
||||
?blankable=${!this.required}
|
||||
>
|
||||
</ak-search-select>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FlowSearch from "./FlowSearch.js";
|
||||
import { FlowSearch } from "./FlowSearch.js";
|
||||
|
||||
import type { Flow } from "@goauthentik/api";
|
||||
|
||||
@@ -18,5 +18,3 @@ declare global {
|
||||
"ak-flow-search": AkFlowSearch<Flow>;
|
||||
}
|
||||
}
|
||||
|
||||
export default AkFlowSearch;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FlowSearch from "./FlowSearch.js";
|
||||
import { FlowSearch } from "./FlowSearch.js";
|
||||
|
||||
import type { Flow } from "@goauthentik/api";
|
||||
|
||||
@@ -18,9 +18,8 @@ export class AkSourceFlowSearch<T extends Flow> extends FlowSearch<T> {
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
|
||||
@property({ type: String })
|
||||
fallback: string | undefined;
|
||||
public fallback?: string;
|
||||
|
||||
/**
|
||||
* The primary key of the Source (not the Flow). Mostly the instancePk itself, used to affirm
|
||||
@@ -29,21 +28,16 @@ export class AkSourceFlowSearch<T extends Flow> extends FlowSearch<T> {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
instanceId: string | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
}
|
||||
public instanceId?: string;
|
||||
|
||||
// If there's no instance or no currentFlowId for it and the flow resembles the fallback,
|
||||
// otherwise defer to the parent class.
|
||||
selected(flow: Flow): boolean {
|
||||
protected override selected = (flow: Flow): boolean => {
|
||||
return (
|
||||
(!this.instanceId && !this.currentFlow && flow.slug === this.fallback) ||
|
||||
super.selected(flow)
|
||||
this.match(flow)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function renderForm(
|
||||
placeholder=${msg("Provider name")}
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
.errorMessages=${errors?.name}
|
||||
required
|
||||
help=${msg("Method's display Name.")}
|
||||
></ak-text-input>
|
||||
@@ -87,7 +87,7 @@ export function renderForm(
|
||||
label=${msg("Bind flow")}
|
||||
required
|
||||
name="authorizationFlow"
|
||||
.errorMessages=${errors?.authorizationFlow ?? []}
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
>
|
||||
<ak-branded-flow-search
|
||||
label=${msg("Bind flow")}
|
||||
@@ -111,7 +111,7 @@ export function renderForm(
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
.brandFlow=${brand?.flowInvalidation}
|
||||
defaultFlowSlug="default-invalidation-flow"
|
||||
.errorMessages=${errors?.invalidationFlow ?? []}
|
||||
.errorMessages=${errors?.invalidationFlow}
|
||||
required
|
||||
></ak-branded-flow-search>
|
||||
<p class="pf-c-form__helper-text">${msg("Flow used for unbinding users.")}</p>
|
||||
@@ -127,7 +127,7 @@ export function renderForm(
|
||||
required
|
||||
value="${provider?.baseDn ?? "DC=ldap,DC=goauthentik,DC=io"}"
|
||||
input-hint="code"
|
||||
.errorMessages=${errors?.baseDn ?? []}
|
||||
.errorMessages=${errors?.baseDn}
|
||||
help=${msg(
|
||||
"LDAP DN under which bind requests and search requests can be made.",
|
||||
)}
|
||||
@@ -137,9 +137,11 @@ export function renderForm(
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Certificate")}
|
||||
name="certificate"
|
||||
.errorMessages=${errors?.certificate ?? []}
|
||||
.errorMessages=${errors?.certificate}
|
||||
>
|
||||
<ak-crypto-certificate-search
|
||||
label=${msg("Certificate")}
|
||||
placeholder=${msg("Select a certificate...")}
|
||||
certificate=${ifDefined(provider?.certificate ?? nothing)}
|
||||
name="certificate"
|
||||
>
|
||||
@@ -151,7 +153,7 @@ export function renderForm(
|
||||
label=${msg("TLS Server name")}
|
||||
name="tlsServerName"
|
||||
value="${provider?.tlsServerName ?? ""}"
|
||||
.errorMessages=${errors?.tlsServerName ?? []}
|
||||
.errorMessages=${errors?.tlsServerName}
|
||||
help=${tlsServerNameHelp}
|
||||
input-hint="code"
|
||||
></ak-text-input>
|
||||
@@ -161,7 +163,7 @@ export function renderForm(
|
||||
required
|
||||
name="uidStartNumber"
|
||||
value="${provider?.uidStartNumber ?? 2000}"
|
||||
.errorMessages=${errors?.uidStartNumber ?? []}
|
||||
.errorMessages=${errors?.uidStartNumber}
|
||||
help=${uidStartNumberHelp}
|
||||
></ak-number-input>
|
||||
|
||||
@@ -170,7 +172,7 @@ export function renderForm(
|
||||
required
|
||||
name="gidStartNumber"
|
||||
value="${provider?.gidStartNumber ?? 4000}"
|
||||
.errorMessages=${errors?.gidStartNumber ?? []}
|
||||
.errorMessages=${errors?.gidStartNumber}
|
||||
help=${gidStartNumberHelp}
|
||||
></ak-number-input>
|
||||
</div>
|
||||
|
||||
@@ -124,8 +124,10 @@ export function renderForm(
|
||||
) {
|
||||
return html` <ak-text-input
|
||||
name="name"
|
||||
placeholder=${msg("Provider name")}
|
||||
label=${msg("Name")}
|
||||
value=${ifDefined(provider?.name)}
|
||||
.errorMessages=${errors?.name}
|
||||
required
|
||||
></ak-text-input>
|
||||
|
||||
@@ -135,8 +137,11 @@ export function renderForm(
|
||||
required
|
||||
>
|
||||
<ak-flow-search
|
||||
label=${msg("Authorization flow")}
|
||||
placeholder=${msg("Select an authorization flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authorization}
|
||||
.currentFlow=${provider?.authorizationFlow}
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
required
|
||||
></ak-flow-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
@@ -162,6 +167,7 @@ export function renderForm(
|
||||
value="${provider?.clientId ?? randomString(40, ascii_letters + digits)}"
|
||||
required
|
||||
input-hint="code"
|
||||
.errorMessages=${errors?.clientId}
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-hidden-text-input
|
||||
@@ -174,7 +180,6 @@ export function renderForm(
|
||||
>
|
||||
</ak-hidden-text-input>
|
||||
<ak-form-element-horizontal
|
||||
flow-direction="row"
|
||||
label=${msg("Redirect URIs/Origins (RegEx)")}
|
||||
name="redirectUris"
|
||||
>
|
||||
@@ -197,6 +202,8 @@ export function renderForm(
|
||||
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
|
||||
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->
|
||||
<ak-crypto-certificate-search
|
||||
label=${msg("Signing Key")}
|
||||
placeholder=${msg("Select a signing key...")}
|
||||
certificate=${ifDefined(provider?.signingKey ?? undefined)}
|
||||
singleton
|
||||
></ak-crypto-certificate-search>
|
||||
@@ -205,6 +212,8 @@ export function renderForm(
|
||||
<ak-form-element-horizontal label=${msg("Encryption Key")} name="encryptionKey">
|
||||
<!-- NOTE: 'null' cast to 'undefined' on encryptionKey to satisfy Lit requirements -->
|
||||
<ak-crypto-certificate-search
|
||||
label=${msg("Encryption Key")}
|
||||
placeholder=${msg("Select an encryption key...")}
|
||||
certificate=${ifDefined(provider?.encryptionKey ?? undefined)}
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">${msg("Key used to encrypt the tokens.")}</p>
|
||||
@@ -219,6 +228,8 @@ export function renderForm(
|
||||
label=${msg("Authentication flow")}
|
||||
>
|
||||
<ak-flow-search
|
||||
label=${msg("Authentication flow")}
|
||||
placeholder=${msg("Select an authentication flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authentication}
|
||||
.currentFlow=${provider?.authenticationFlow}
|
||||
></ak-flow-search>
|
||||
@@ -234,6 +245,8 @@ export function renderForm(
|
||||
required
|
||||
>
|
||||
<ak-flow-search
|
||||
label=${msg("Invalidation flow")}
|
||||
placeholder=${msg("Select an invalidation flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
defaultFlowSlug="default-provider-invalidation-flow"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
RbacPermissionsAssignedByUsersListModelEnum,
|
||||
User,
|
||||
} from "@goauthentik/api";
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import MDProviderOAuth2 from "~docs/add-secure-apps/providers/oauth2/index.mdx";
|
||||
|
||||
@@ -267,12 +268,16 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
<div class="pf-c-card__body">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("providerInfo")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("OpenID Configuration URL")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("providerInfo")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -280,12 +285,16 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("issuer")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("OpenID Configuration Issuer")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("issuer")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -294,12 +303,16 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
</div>
|
||||
<hr class="pf-c-divider" />
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("authorize")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("Authorize URL")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("authorize")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -307,10 +320,14 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("token")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text">${msg("Token URL")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("token")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -318,12 +335,16 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("userInfo")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("Userinfo URL")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("userInfo")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -331,10 +352,14 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("logout")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text">${msg("Logout URL")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("logout")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -342,10 +367,14 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("jwks")}"
|
||||
>
|
||||
<span class="pf-c-form__label-text">${msg("JWKS URL")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("jwks")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -391,9 +420,12 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
${renderDescriptionList(
|
||||
[
|
||||
[
|
||||
msg("Preview for user"),
|
||||
html`<label for="${IDGenerator.elementID("preview-user")}"
|
||||
>${msg("Preview for user")}</label
|
||||
>`,
|
||||
html`
|
||||
<ak-search-select
|
||||
id="${IDGenerator.elementID("preview-user")}"
|
||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||
const args: CoreUsersListRequest = {
|
||||
ordering: "username",
|
||||
|
||||
@@ -88,7 +88,7 @@ function renderProxySettings(provider: Partial<ProxyProvider>, errors?: Validati
|
||||
label=${msg("External host")}
|
||||
value="${ifDefined(provider?.externalHost)}"
|
||||
required
|
||||
.errorMessages=${errors?.externalHost ?? []}
|
||||
.errorMessages=${errors?.externalHost}
|
||||
help=${msg(
|
||||
"The external URL you'll access the application at. Include any non-standard port.",
|
||||
)}
|
||||
@@ -99,7 +99,7 @@ function renderProxySettings(provider: Partial<ProxyProvider>, errors?: Validati
|
||||
label=${msg("Internal host")}
|
||||
value="${ifDefined(provider?.internalHost)}"
|
||||
required
|
||||
.errorMessages=${errors?.internalHost ?? []}
|
||||
.errorMessages=${errors?.internalHost}
|
||||
help=${msg("Upstream host that the requests are forwarded to.")}
|
||||
input-hint="code"
|
||||
></ak-text-input>
|
||||
@@ -124,7 +124,7 @@ function renderForwardSingleSettings(provider: Partial<ProxyProvider>, errors?:
|
||||
label=${msg("External host")}
|
||||
value="${ifDefined(provider?.externalHost)}"
|
||||
required
|
||||
.errorMessages=${errors?.externalHost ?? []}
|
||||
.errorMessages=${errors?.externalHost}
|
||||
help=${msg(
|
||||
"The external URL you'll access the application at. Include any non-standard port.",
|
||||
)}
|
||||
@@ -154,7 +154,7 @@ function renderForwardDomainSettings(provider: Partial<ProxyProvider>, errors?:
|
||||
label=${msg("Authentication URL")}
|
||||
value="${provider?.externalHost ?? window.location.origin}"
|
||||
required
|
||||
.errorMessages=${errors?.externalHost ?? []}
|
||||
.errorMessages=${errors?.externalHost}
|
||||
help=${msg(
|
||||
"The external URL you'll authenticate at. The authentik core server should be reachable under this URL.",
|
||||
)}
|
||||
@@ -165,7 +165,7 @@ function renderForwardDomainSettings(provider: Partial<ProxyProvider>, errors?:
|
||||
name="cookieDomain"
|
||||
value="${ifDefined(provider?.cookieDomain)}"
|
||||
required
|
||||
.errorMessages=${errors?.cookieDomain ?? []}
|
||||
.errorMessages=${errors?.cookieDomain}
|
||||
help=${msg(
|
||||
"Set this to the domain you wish the authentication to be valid for. Must be a parent domain of the URL above. If you're running applications as app1.domain.tld, app2.domain.tld, set this to 'domain.tld'.",
|
||||
)}
|
||||
@@ -196,7 +196,7 @@ export function renderForm(
|
||||
name="name"
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
.errorMessages=${errors?.name}
|
||||
required
|
||||
></ak-text-input>
|
||||
|
||||
@@ -224,7 +224,7 @@ export function renderForm(
|
||||
label=${msg("Token validity")}
|
||||
name="accessTokenValidity"
|
||||
value="${provider?.accessTokenValidity ?? "hours=24"}"
|
||||
.errorMessages=${errors?.accessTokenValidity ?? []}
|
||||
.errorMessages=${errors?.accessTokenValidity}
|
||||
required
|
||||
.help=${msg("Configure how long tokens are valid for.")}
|
||||
input-hint="code"
|
||||
|
||||
@@ -45,8 +45,9 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="name"
|
||||
label=${msg("Name")}
|
||||
placeholder=${msg("Provider name")}
|
||||
value=${ifDefined(provider?.name)}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
.errorMessages=${errors?.name}
|
||||
required
|
||||
>
|
||||
</ak-text-input>
|
||||
@@ -55,9 +56,11 @@ export function renderForm(
|
||||
label=${msg("Authentication flow")}
|
||||
required
|
||||
name="authorizationFlow"
|
||||
.errorMessages=${errors?.authorizationFlow ?? []}
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
>
|
||||
<ak-branded-flow-search
|
||||
label=${msg("Authentication flow")}
|
||||
placeholder=${msg("Select an authentication flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authentication}
|
||||
.currentFlow=${provider?.authorizationFlow}
|
||||
.brandFlow=${brand?.flowAuthentication}
|
||||
@@ -79,7 +82,7 @@ export function renderForm(
|
||||
<ak-hidden-text-input
|
||||
name="sharedSecret"
|
||||
label=${msg("Shared secret")}
|
||||
.errorMessages=${errors?.sharedSecret ?? []}
|
||||
.errorMessages=${errors?.sharedSecret}
|
||||
value=${provider?.sharedSecret ?? randomString(128, ascii_letters + digits)}
|
||||
required
|
||||
input-hint="code"
|
||||
@@ -88,7 +91,7 @@ export function renderForm(
|
||||
name="clientNetworks"
|
||||
label=${msg("Client Networks")}
|
||||
value=${provider?.clientNetworks ?? "0.0.0.0/0, ::/0"}
|
||||
.errorMessages=${errors?.clientNetworks ?? []}
|
||||
.errorMessages=${errors?.clientNetworks}
|
||||
required
|
||||
help=${clientNetworksHelp}
|
||||
input-hint="code"
|
||||
@@ -118,7 +121,7 @@ export function renderForm(
|
||||
placeholder=${msg("Select an invalidation flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
.errorMessages=${errors?.invalidationFlow ?? []}
|
||||
.errorMessages=${errors?.invalidationFlow}
|
||||
defaultFlowSlug="default-invalidation-flow"
|
||||
required
|
||||
></ak-flow-search>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { digestAlgorithmOptions, signatureAlgorithmOptions } from "./SAMLProvide
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { RadioOption } from "#elements/forms/Radio";
|
||||
|
||||
import {
|
||||
FlowsInstancesListDesignationEnum,
|
||||
PropertymappingsApi,
|
||||
@@ -26,7 +28,7 @@ import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const serviceProviderBindingOptions = [
|
||||
const serviceProviderBindingOptions: RadioOption<SpBindingEnum>[] = [
|
||||
{
|
||||
label: msg("Redirect"),
|
||||
value: SpBindingEnum.Redirect,
|
||||
@@ -38,11 +40,11 @@ const serviceProviderBindingOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
function renderHasSigningKp(provider?: Partial<SAMLProvider>) {
|
||||
function renderHasSigningKp(provider: Partial<SAMLProvider>) {
|
||||
return html` <ak-switch-input
|
||||
name="signAssertion"
|
||||
label=${msg("Sign assertions")}
|
||||
?checked=${provider?.signAssertion ?? true}
|
||||
?checked=${provider.signAssertion ?? true}
|
||||
help=${msg("When enabled, the assertion element of the SAML response will be signed.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
@@ -50,7 +52,7 @@ function renderHasSigningKp(provider?: Partial<SAMLProvider>) {
|
||||
<ak-switch-input
|
||||
name="signResponse"
|
||||
label=${msg("Sign responses")}
|
||||
?checked=${provider?.signResponse ?? false}
|
||||
?checked=${provider.signResponse ?? false}
|
||||
help=${msg("When enabled, the SAML response will be signed.")}
|
||||
>
|
||||
</ak-switch-input>`;
|
||||
@@ -64,10 +66,10 @@ export function renderForm(
|
||||
) {
|
||||
return html` <ak-text-input
|
||||
name="name"
|
||||
value=${ifDefined(provider?.name)}
|
||||
value=${ifDefined(provider.name)}
|
||||
label=${msg("Name")}
|
||||
required
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
.errorMessages=${errors?.name}
|
||||
></ak-text-input>
|
||||
<ak-form-element-horizontal
|
||||
name="authorizationFlow"
|
||||
@@ -76,9 +78,9 @@ export function renderForm(
|
||||
>
|
||||
<ak-flow-search
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authorization}
|
||||
.currentFlow=${provider?.authorizationFlow}
|
||||
.currentFlow=${provider.authorizationFlow}
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
required
|
||||
.errorMessages=${errors?.authorizationFlow ?? []}
|
||||
></ak-flow-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Flow used when authorizing this provider.")}
|
||||
@@ -90,16 +92,16 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="acsUrl"
|
||||
label=${msg("ACS URL")}
|
||||
value="${ifDefined(provider?.acsUrl)}"
|
||||
value="${ifDefined(provider.acsUrl)}"
|
||||
required
|
||||
.errorMessages=${errors?.acsUrl ?? []}
|
||||
.errorMessages=${errors?.acsUrl}
|
||||
></ak-text-input>
|
||||
<ak-text-input
|
||||
label=${msg("Issuer")}
|
||||
name="issuer"
|
||||
value="${provider?.issuer || "authentik"}"
|
||||
value="${provider.issuer || "authentik"}"
|
||||
required
|
||||
.errorMessages=${errors?.issuer ?? []}
|
||||
.errorMessages=${errors?.issuer}
|
||||
help=${msg("Also known as EntityID.")}
|
||||
></ak-text-input>
|
||||
<ak-radio-input
|
||||
@@ -107,7 +109,7 @@ export function renderForm(
|
||||
name="spBinding"
|
||||
required
|
||||
.options=${serviceProviderBindingOptions}
|
||||
.value=${provider?.spBinding}
|
||||
.value=${provider.spBinding}
|
||||
help=${msg(
|
||||
"Determines how authentik sends the response back to the Service Provider.",
|
||||
)}
|
||||
@@ -116,8 +118,8 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="audience"
|
||||
label=${msg("Audience")}
|
||||
value="${ifDefined(provider?.audience)}"
|
||||
.errorMessages=${errors?.audience ?? []}
|
||||
value="${ifDefined(provider.audience)}"
|
||||
.errorMessages=${errors?.audience}
|
||||
></ak-text-input>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
@@ -130,7 +132,7 @@ export function renderForm(
|
||||
>
|
||||
<ak-flow-search
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authentication}
|
||||
.currentFlow=${provider?.authenticationFlow}
|
||||
.currentFlow=${provider.authenticationFlow}
|
||||
></ak-flow-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
@@ -145,7 +147,7 @@ export function renderForm(
|
||||
>
|
||||
<ak-flow-search
|
||||
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
.currentFlow=${provider.invalidationFlow}
|
||||
defaultFlowSlug="default-provider-invalidation-flow"
|
||||
required
|
||||
></ak-flow-search>
|
||||
@@ -160,7 +162,7 @@ export function renderForm(
|
||||
<div class="pf-c-form">
|
||||
<ak-form-element-horizontal label=${msg("Signing Certificate")} name="signingKp">
|
||||
<ak-crypto-certificate-search
|
||||
.certificate=${provider?.signingKp}
|
||||
.certificate=${provider.signingKp}
|
||||
@input=${setHasSigningKp}
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
@@ -176,7 +178,7 @@ export function renderForm(
|
||||
name="verificationKp"
|
||||
>
|
||||
<ak-crypto-certificate-search
|
||||
.certificate=${provider?.verificationKp}
|
||||
.certificate=${provider.verificationKp}
|
||||
nokey
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
@@ -190,7 +192,7 @@ export function renderForm(
|
||||
name="encryptionKp"
|
||||
>
|
||||
<ak-crypto-certificate-search
|
||||
.certificate=${provider?.encryptionKp}
|
||||
.certificate=${provider.encryptionKp}
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("When selected, assertions will be encrypted using this keypair.")}
|
||||
@@ -202,7 +204,7 @@ export function renderForm(
|
||||
>
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${propertyMappingsProvider}
|
||||
.selector=${propertyMappingsSelector(provider?.propertyMappings)}
|
||||
.selector=${propertyMappingsSelector(provider.propertyMappings)}
|
||||
available-label=${msg("Available User Property Mappings")}
|
||||
selected-label=${msg("Selected User Property Mappings")}
|
||||
></ak-dual-select-dynamic-selected>
|
||||
@@ -212,6 +214,7 @@ export function renderForm(
|
||||
name="nameIdMapping"
|
||||
>
|
||||
<ak-search-select
|
||||
required
|
||||
.fetchObjects=${async (query?: string): Promise<SAMLPropertyMapping[]> => {
|
||||
const args: PropertymappingsProviderSamlListRequest = {
|
||||
ordering: "saml_name",
|
||||
@@ -231,7 +234,7 @@ export function renderForm(
|
||||
return item?.pk;
|
||||
}}
|
||||
.selected=${(item: SAMLPropertyMapping): boolean => {
|
||||
return provider?.nameIdMapping === item.pk;
|
||||
return provider.nameIdMapping === item.pk;
|
||||
}}
|
||||
blankable
|
||||
>
|
||||
@@ -247,6 +250,7 @@ export function renderForm(
|
||||
name="authnContextClassRefMapping"
|
||||
>
|
||||
<ak-search-select
|
||||
required
|
||||
.fetchObjects=${async (query?: string): Promise<SAMLPropertyMapping[]> => {
|
||||
const args: PropertymappingsProviderSamlListRequest = {
|
||||
ordering: "saml_name",
|
||||
@@ -266,7 +270,7 @@ export function renderForm(
|
||||
return item?.pk;
|
||||
}}
|
||||
.selected=${(item: SAMLPropertyMapping): boolean => {
|
||||
return provider?.authnContextClassRefMapping === item.pk;
|
||||
return provider.authnContextClassRefMapping === item.pk;
|
||||
}}
|
||||
blankable
|
||||
>
|
||||
@@ -281,35 +285,35 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="assertionValidNotBefore"
|
||||
label=${msg("Assertion valid not before")}
|
||||
value="${provider?.assertionValidNotBefore || "minutes=-5"}"
|
||||
value="${provider.assertionValidNotBefore || "minutes=-5"}"
|
||||
required
|
||||
.errorMessages=${errors?.assertionValidNotBefore ?? []}
|
||||
.errorMessages=${errors?.assertionValidNotBefore}
|
||||
help=${msg("Configure the maximum allowed time drift for an assertion.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="assertionValidNotOnOrAfter"
|
||||
label=${msg("Assertion valid not on or after")}
|
||||
value="${provider?.assertionValidNotOnOrAfter || "minutes=5"}"
|
||||
value="${provider.assertionValidNotOnOrAfter || "minutes=5"}"
|
||||
required
|
||||
.errorMessages=${errors?.assertionValidNotBefore ?? []}
|
||||
.errorMessages=${errors?.assertionValidNotBefore}
|
||||
help=${msg("Assertion not valid on or after current time + this value.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="sessionValidNotOnOrAfter"
|
||||
label=${msg("Session valid not on or after")}
|
||||
value="${provider?.sessionValidNotOnOrAfter || "minutes=86400"}"
|
||||
value="${provider.sessionValidNotOnOrAfter || "minutes=86400"}"
|
||||
required
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter ?? []}
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter}
|
||||
help=${msg("Session not valid on or after current time + this value.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="defaultRelayState"
|
||||
label=${msg("Default relay state")}
|
||||
value="${provider?.defaultRelayState || ""}"
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter ?? []}
|
||||
value="${provider.defaultRelayState || ""}"
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter}
|
||||
help=${msg(
|
||||
"When using IDP-initiated logins, the relay state will be set to this value.",
|
||||
)}
|
||||
@@ -319,7 +323,7 @@ export function renderForm(
|
||||
name="digestAlgorithm"
|
||||
label=${msg("Digest algorithm")}
|
||||
.options=${digestAlgorithmOptions}
|
||||
.value=${provider?.digestAlgorithm}
|
||||
.value=${provider.digestAlgorithm}
|
||||
required
|
||||
>
|
||||
</ak-radio-input>
|
||||
@@ -328,7 +332,7 @@ export function renderForm(
|
||||
name="signatureAlgorithm"
|
||||
label=${msg("Signature algorithm")}
|
||||
.options=${signatureAlgorithmOptions}
|
||||
.value=${provider?.signatureAlgorithm}
|
||||
.value=${provider.signatureAlgorithm}
|
||||
required
|
||||
>
|
||||
</ak-radio-input>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
|
||||
name="name"
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
.errorMessages=${errors?.name}
|
||||
required
|
||||
help=${msg("Method's display Name.")}
|
||||
></ak-text-input>
|
||||
@@ -38,7 +38,7 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
|
||||
name="url"
|
||||
label=${msg("URL")}
|
||||
value="${provider?.url ?? ""}"
|
||||
.errorMessages=${errors?.url ?? []}
|
||||
.errorMessages=${errors?.url}
|
||||
required
|
||||
help=${msg("SCIM base url, usually ends in /v2.")}
|
||||
input-hint="code"
|
||||
@@ -55,7 +55,7 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
|
||||
name="token"
|
||||
label=${msg("Token")}
|
||||
value="${provider?.token ?? ""}"
|
||||
.errorMessages=${errors?.token ?? []}
|
||||
.errorMessages=${errors?.token}
|
||||
required
|
||||
help=${msg(
|
||||
"Token to authenticate with. Currently only bearer authentication is supported.",
|
||||
|
||||
@@ -171,6 +171,10 @@ export function pluckErrorDetail(errorLike: unknown, fallback?: string): string
|
||||
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
|
||||
);
|
||||
|
||||
if (errorLike && typeof errorLike === "string") {
|
||||
return errorLike;
|
||||
}
|
||||
|
||||
if (!errorLike || typeof errorLike !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@
|
||||
--ak-navbar--height: 7rem;
|
||||
}
|
||||
|
||||
.pf-c-form__group {
|
||||
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: minmax(max-content, 9.375rem);
|
||||
column-gap: var(--pf-global--spacer--md);
|
||||
}
|
||||
|
||||
.pf-c-form__group-label {
|
||||
user-select: none;
|
||||
padding-top: var(--pf-c-form--m-horizontal__group-label--md--PaddingTop);
|
||||
}
|
||||
|
||||
.pf-c-form__label[aria-required] .pf-c-form__label-text::after {
|
||||
content: "*";
|
||||
user-select: none;
|
||||
margin-left: var(--pf-c-form__label-required--MarginLeft);
|
||||
font-size: var(--pf-c-form__label-required--FontSize);
|
||||
color: var(--pf-c-form__label-required--Color);
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
|
||||
@@ -292,7 +292,7 @@ export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "au
|
||||
* @todo Can this be handled with a Lit Mixin?
|
||||
*/
|
||||
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
|
||||
const element = document.body.querySelector<T>("[data-ak-interface-root]");
|
||||
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
|
||||
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { map } from "lit/directives/map.js";
|
||||
|
||||
export type DescriptionDesc = string | TemplateResult | undefined | typeof nothing;
|
||||
export type DescriptionPair = [string, DescriptionDesc];
|
||||
export type DescriptionRecord = { term: string; desc: DescriptionDesc };
|
||||
export type DescriptionPair = [
|
||||
term: SlottedTemplateResult,
|
||||
desc: SlottedTemplateResult | undefined,
|
||||
];
|
||||
export type DescriptionRecord = { term: string; desc: SlottedTemplateResult | undefined };
|
||||
|
||||
interface DescriptionConfig {
|
||||
horizontal?: boolean;
|
||||
|
||||
@@ -4,11 +4,13 @@ import { SlottedTemplateResult } from "../elements/types";
|
||||
|
||||
import { AKElement, type AKElementProps } from "#elements/Base";
|
||||
|
||||
import { ErrorProp } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
export interface HorizontalLightComponentProps<T> extends AKElementProps {
|
||||
name: string;
|
||||
@@ -18,7 +20,7 @@ export interface HorizontalLightComponentProps<T> extends AKElementProps {
|
||||
bighelp?: SlottedTemplateResult | SlottedTemplateResult[];
|
||||
hidden?: boolean;
|
||||
invalid?: boolean;
|
||||
errorMessages?: string[];
|
||||
errorMessages?: ErrorProp[];
|
||||
value?: T;
|
||||
inputHint?: string;
|
||||
}
|
||||
@@ -38,13 +40,15 @@ export abstract class HorizontalLightComponent<T>
|
||||
return this;
|
||||
}
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* The name attribute for the form element
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
name!: string;
|
||||
public name!: string;
|
||||
|
||||
/**
|
||||
* The label for the input control
|
||||
@@ -52,14 +56,14 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
label?: string;
|
||||
public label?: string;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
required = false;
|
||||
public required = false;
|
||||
|
||||
/**
|
||||
* Help text to display below the form element. Optional
|
||||
@@ -67,41 +71,40 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
help = "";
|
||||
public help = "";
|
||||
|
||||
/**
|
||||
* Extended help content. Optional. Expects to be a TemplateResult
|
||||
* @property
|
||||
*/
|
||||
@property({ type: Object })
|
||||
bighelp?: TemplateResult | TemplateResult[];
|
||||
public bighelp?: TemplateResult | TemplateResult[];
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
hidden = false;
|
||||
public hidden = false;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
invalid = false;
|
||||
public invalid = false;
|
||||
|
||||
/**
|
||||
* @property
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
errorMessages: string[] = [];
|
||||
public errorMessages?: ErrorProp[];
|
||||
|
||||
/**
|
||||
* @attribute
|
||||
* @property
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
value?: T;
|
||||
public value?: T;
|
||||
|
||||
/**
|
||||
* Input hint.
|
||||
@@ -110,14 +113,24 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "input-hint" })
|
||||
inputHint?: string;
|
||||
|
||||
protected renderControl() {
|
||||
throw new Error("Must be implemented in a subclass");
|
||||
}
|
||||
public inputHint?: string;
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID = IDGenerator.elementID().toString();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
/**
|
||||
* Render the control element, e.g. an input, textarea, select, etc.
|
||||
*/
|
||||
protected abstract renderControl(): SlottedTemplateResult;
|
||||
|
||||
protected renderHelp(): SlottedTemplateResult | SlottedTemplateResult[] {
|
||||
const bigHelp: SlottedTemplateResult[] = Array.isArray(this.bighelp)
|
||||
? this.bighelp
|
||||
@@ -131,15 +144,20 @@ export abstract class HorizontalLightComponent<T>
|
||||
|
||||
render() {
|
||||
return html`<ak-form-element-horizontal
|
||||
fieldID=${this.fieldID}
|
||||
label=${ifDefined(this.label)}
|
||||
.fieldID=${this.fieldID}
|
||||
?required=${this.required}
|
||||
?hidden=${this.hidden}
|
||||
name=${this.name}
|
||||
.errorMessages=${this.errorMessages}
|
||||
?invalid=${this.invalid}
|
||||
>
|
||||
<div slot="label" class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)}
|
||||
</div>
|
||||
|
||||
${this.renderControl()} ${this.renderHelp()}
|
||||
</ak-form-element-horizontal> `;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
45
web/src/components/ak-field-errors.ts
Normal file
45
web/src/components/ak-field-errors.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { pluckErrorDetail } from "#common/errors/network";
|
||||
|
||||
import { LitFC } from "#elements/types";
|
||||
|
||||
import { ErrorDetail, ValidationError } from "@goauthentik/api";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
/**
|
||||
* An error originating from a form field.
|
||||
*/
|
||||
export type FieldErrorTuple = [fieldName: string, detail: string];
|
||||
|
||||
export type ErrorProp = string | Error | ErrorDetail | ValidationError | FieldErrorTuple;
|
||||
|
||||
export interface AKFormErrorsProps {
|
||||
errors?: ErrorProp[];
|
||||
}
|
||||
|
||||
function renderError(detail: string) {
|
||||
if (!detail) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
<span class="pf-c-form__helper-text-icon">
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> </span
|
||||
>${detail}
|
||||
</p>`;
|
||||
}
|
||||
|
||||
export const AKFormErrors: LitFC<AKFormErrorsProps> = ({ errors } = {}) => {
|
||||
if (!errors?.length) return nothing;
|
||||
|
||||
return errors.flatMap((error) => {
|
||||
if (Array.isArray(error) && error.length === 2) {
|
||||
const [fieldName, detail] = error;
|
||||
|
||||
return renderError(msg(str`${fieldName}: ${detail}`));
|
||||
}
|
||||
|
||||
return renderError(pluckErrorDetail(error));
|
||||
});
|
||||
};
|
||||
@@ -1,67 +1,32 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { HorizontalLightComponent } from "#components/HorizontalLightComponent";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@customElement("ak-file-input")
|
||||
export class AkFileInput extends AKElement {
|
||||
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
|
||||
// we're not actually using that and, for the meantime, we need the form handlers to be able to
|
||||
// find the children of this component.
|
||||
//
|
||||
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
|
||||
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
|
||||
// general.
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
export class AkFileInput extends HorizontalLightComponent<string> {
|
||||
#inputRef = createRef<HTMLInputElement>();
|
||||
|
||||
get files(): Iterable<File> {
|
||||
return this.#inputRef.value?.files || [];
|
||||
}
|
||||
|
||||
@property({ type: String })
|
||||
name!: string;
|
||||
|
||||
@property({ type: String })
|
||||
label = "";
|
||||
|
||||
/*
|
||||
* The message to show next to the "current icon".
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
current = msg("Currently set to:");
|
||||
|
||||
@property({ type: String })
|
||||
value = "";
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@property({ type: String })
|
||||
help = "";
|
||||
|
||||
@query('input[type="file"]')
|
||||
input!: HTMLInputElement;
|
||||
|
||||
get files() {
|
||||
return this.input.files;
|
||||
#inputListener(ev: InputEvent) {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
render() {
|
||||
const currentMsg =
|
||||
this.value && this.current
|
||||
? html` <p class="pf-c-form__helper-text">${this.current} ${this.value}</p> `
|
||||
: nothing;
|
||||
|
||||
return html`<ak-form-element-horizontal
|
||||
?required="${this.required}"
|
||||
label=${this.label}
|
||||
name=${this.name}
|
||||
>
|
||||
<input type="file" value="" class="pf-c-form-control" />
|
||||
${currentMsg}
|
||||
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
|
||||
</ak-form-element-horizontal>`;
|
||||
public override renderControl() {
|
||||
return html` <input
|
||||
${ref(this.#inputRef)}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
type="file"
|
||||
@input=${this.#inputListener}
|
||||
value=${ifDefined(this.value)}
|
||||
class="pf-c-form-control"
|
||||
?required=${ifDefined(this.required)}
|
||||
/>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
type BaseProps = HorizontalLightComponentProps<string> &
|
||||
Pick<VisibilityToggleProps, "showMessage" | "hideMessage">;
|
||||
Pick<VisibilityToggleProps, "hideContentLabel" | "revealContentLabel">;
|
||||
|
||||
export interface AkHiddenTextInputProps extends BaseProps {
|
||||
revealed: boolean;
|
||||
@@ -53,10 +53,13 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public value = "";
|
||||
|
||||
/**
|
||||
* Whether the input value is visible.
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@@ -64,7 +67,7 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
public revealed = false;
|
||||
|
||||
/**
|
||||
* Text for when the input has no set value
|
||||
* Placeholder text when no value is set.
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
@@ -73,16 +76,7 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
public placeholder?: string;
|
||||
|
||||
/**
|
||||
* Text for when the input has no set value
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
/**
|
||||
* Specify kind of help the browser should try to provide
|
||||
* Specify kind of help the browser should try to provide.
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
@@ -95,29 +89,37 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "show-message" })
|
||||
public showMessage = msg("Show field content");
|
||||
public revealContentLabel = msg("Show field content");
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "hide-message" })
|
||||
public hideMessage = msg("Hide field content");
|
||||
public hideContentLabel = msg("Hide field content");
|
||||
|
||||
@query("#main > input")
|
||||
protected inputField!: T;
|
||||
/**
|
||||
* A listener for the input event.
|
||||
*/
|
||||
protected inputListener = (event: InputEvent) => {
|
||||
this.value = (event.target as T).value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the input field.
|
||||
*
|
||||
* TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content LightDOM so the inner components actually inherit styling, the normal `css` options aren't available. Embedding styles is bad styling, and we'll fix it in the next style refresh.
|
||||
*/
|
||||
protected renderInputField() {
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
|
||||
// in the LightDom so the inner components actually inherit styling, the normal `css` options
|
||||
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
|
||||
// refresh.
|
||||
protected renderInputField(setValue: InputListener, code: boolean) {
|
||||
return html` <input
|
||||
part="input"
|
||||
id=${ifDefined(this.fieldID)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
type=${this.revealed ? "text" : "password"}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
@input=${setValue}
|
||||
@input=${this.inputListener}
|
||||
value=${ifDefined(this.value)}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
class="${classMap({
|
||||
@@ -130,19 +132,14 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
}
|
||||
|
||||
protected override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
const setValue: InputListener = (ev) => {
|
||||
this.value = (ev.target as T).value;
|
||||
};
|
||||
|
||||
return html` <div style="display: flex; gap: 0.25rem">
|
||||
${this.renderInputField(setValue, code)}
|
||||
${this.renderInputField()}
|
||||
<ak-visibility-toggle
|
||||
part="toggle"
|
||||
style="flex: 0 0 auto; align-self: flex-start"
|
||||
?open=${this.revealed}
|
||||
show-message=${this.showMessage}
|
||||
hide-message=${this.hideMessage}
|
||||
show-message=${this.revealContentLabel}
|
||||
hide-message=${this.hideContentLabel}
|
||||
@click=${() => (this.revealed = !this.revealed)}
|
||||
></ak-visibility-toggle>
|
||||
</div>`;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
AkHiddenTextInput,
|
||||
type AkHiddenTextInputProps,
|
||||
InputListener,
|
||||
} from "./ak-hidden-text-input.js";
|
||||
import { AkHiddenTextInput, type AkHiddenTextInputProps } from "./ak-hidden-text-input.js";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -48,43 +44,44 @@ export class AkHiddenTextAreaInput
|
||||
extends AkHiddenTextInput<HTMLTextAreaElement>
|
||||
implements AkHiddenTextAreaInputProps
|
||||
{
|
||||
/* These are mostly just forwarded to the textarea component. */
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Number })
|
||||
rows?: number = 4;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Number })
|
||||
cols?: number;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
* Number of visible text lines (rows)
|
||||
*
|
||||
* You want `resize=true` so that the resize value is visible in the component tag, activating
|
||||
* the CSS associated with these values.
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Number })
|
||||
public rows?: number = 4;
|
||||
|
||||
/**
|
||||
* Nummber of visible character width (cols)
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Number })
|
||||
public cols?: number;
|
||||
|
||||
/**
|
||||
* You want `resize=true` so that the resize value is visible in the component tag, activating the CSS associated with these values.
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
resize?: "none" | "both" | "horizontal" | "vertical" = "vertical";
|
||||
public resize?: "none" | "both" | "horizontal" | "vertical" = "vertical";
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String })
|
||||
wrap?: "soft" | "hard" | "off" = "soft";
|
||||
public wrap?: "soft" | "hard" | "off" = "soft";
|
||||
|
||||
@query("#main > textarea")
|
||||
protected inputField!: HTMLTextAreaElement;
|
||||
//#endregion
|
||||
|
||||
get displayValue() {
|
||||
get #visibleValue() {
|
||||
const value = this.value ?? "";
|
||||
if (this.revealed) {
|
||||
return value;
|
||||
@@ -96,18 +93,18 @@ export class AkHiddenTextAreaInput
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
|
||||
// in the LightDom so the inner components actually inherit styling, the normal `css` options
|
||||
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
|
||||
// refresh.
|
||||
protected override renderInputField(setValue: InputListener, code: boolean) {
|
||||
//#region Rendering
|
||||
|
||||
protected override renderInputField() {
|
||||
const wrap = this.revealed ? this.wrap : "soft";
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
return html`
|
||||
<textarea
|
||||
style="flex: 1 1 auto; min-width: 0;"
|
||||
part="textarea"
|
||||
@input=${setValue}
|
||||
@input=${this}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
rows=${ifDefined(this.rows)}
|
||||
@@ -120,10 +117,12 @@ export class AkHiddenTextAreaInput
|
||||
spellcheck=${code ? "false" : "true"}
|
||||
?required=${this.required}
|
||||
>
|
||||
${this.displayValue}</textarea
|
||||
${this.#visibleValue}</textarea
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
27
web/src/components/ak-label.ts
Normal file
27
web/src/components/ak-label.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { LitFC } from "#elements/types";
|
||||
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
import type { LabelHTMLAttributes } from "react";
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
export interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const AKLabel: LitFC<FormLabelProps> = (
|
||||
{ required, htmlFor, ...labelAttributes } = {},
|
||||
children,
|
||||
) => {
|
||||
if (!children) return nothing;
|
||||
|
||||
return html`<label
|
||||
class="pf-c-form__label"
|
||||
for=${ifDefined(htmlFor)}
|
||||
?aria-required=${required}
|
||||
${spread(labelAttributes)}
|
||||
>
|
||||
<span class="pf-c-form__label-text">${children}</span>
|
||||
</label>`;
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
|
||||
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
|
||||
import { kebabCase } from "change-case";
|
||||
|
||||
import { html } from "lit";
|
||||
@@ -56,7 +54,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
|
||||
// Do not stop propagation of this event; it must be sent up the tree so that a parent
|
||||
// component, such as a custom forms manager, may receive it.
|
||||
protected handleTouch(ev: Event) {
|
||||
#touchListener = (ev: Event) => {
|
||||
this.value = this.input.value = slugify(this.input.value);
|
||||
|
||||
// Reset 'touched' status if the slug & target have been reset
|
||||
@@ -68,10 +66,9 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
|
||||
this.#touched = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@bound
|
||||
protected slugify(ev: Event) {
|
||||
#slugify = (ev: Event) => {
|
||||
if (!(ev && ev.target && ev.target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
@@ -114,18 +111,18 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public override disconnectedCallback() {
|
||||
if (this.#origin) {
|
||||
this.#origin.removeEventListener("input", this.slugify);
|
||||
}
|
||||
this.#origin?.removeEventListener("input", this.#slugify);
|
||||
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public override renderControl() {
|
||||
return html`<input
|
||||
@input=${(ev: Event) => this.handleTouch(ev)}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${this.#touchListener}
|
||||
type="text"
|
||||
value=${ifDefined(this.value)}
|
||||
class="pf-c-form-control"
|
||||
@@ -143,7 +140,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
this.#origin = rootNode.querySelector(this.source);
|
||||
}
|
||||
if (this.#origin) {
|
||||
this.#origin.addEventListener("input", this.slugify);
|
||||
this.#origin.addEventListener("input", this.#slugify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export class AkSwitchInput extends AKElement {
|
||||
const helpText = this.help.trim();
|
||||
|
||||
return html` <ak-form-element-horizontal name=${this.name} ?required=${this.required}>
|
||||
<div slot="label" class="pf-c-form__group-label"></div>
|
||||
|
||||
<label class="pf-c-switch" for="${this.#fieldID}">
|
||||
<input
|
||||
id="${this.#fieldID}"
|
||||
|
||||
@@ -8,26 +8,26 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
@customElement("ak-text-input")
|
||||
export class AkTextInput extends HorizontalLightComponent<string> {
|
||||
@property({ type: String, reflect: true })
|
||||
value = "";
|
||||
public value = "";
|
||||
|
||||
@property({ type: String })
|
||||
autocomplete?: string;
|
||||
public autocomplete?: AutoFill;
|
||||
|
||||
@property({ type: String })
|
||||
placeholder?: string;
|
||||
public placeholder?: string;
|
||||
|
||||
renderControl() {
|
||||
const setValue = (ev: InputEvent) => {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
};
|
||||
#inputListener(ev: InputEvent) {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
public override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
return html` <input
|
||||
type="text"
|
||||
role="textbox"
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${setValue}
|
||||
@input=${this.#inputListener}
|
||||
value=${ifDefined(this.value)}
|
||||
class="${classMap({
|
||||
"pf-c-form-control": true,
|
||||
|
||||
@@ -9,15 +9,18 @@ export class AkTextareaInput extends HorizontalLightComponent<string> {
|
||||
@property({ type: String, reflect: true })
|
||||
public value = "";
|
||||
|
||||
#inputListener = (ev: InputEvent) => {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
public override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
const setValue = (ev: InputEvent) => {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
// Prevent the leading spaces added by Prettier's whitespace algo
|
||||
// prettier-ignore
|
||||
return html`<textarea
|
||||
@input=${setValue}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${this.#inputListener}
|
||||
class="pf-c-form-control"
|
||||
?required=${this.required}
|
||||
name=${this.name}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class AkToggleGroup extends CustomEmitterElement(AKElement) {
|
||||
`,
|
||||
];
|
||||
|
||||
/*
|
||||
/**
|
||||
* The value (causes highlighting, value is returned)
|
||||
*
|
||||
* @attr
|
||||
|
||||
@@ -10,8 +10,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
export interface VisibilityToggleProps {
|
||||
open: boolean;
|
||||
disabled: boolean;
|
||||
showMessage: string;
|
||||
hideMessage: string;
|
||||
revealContentLabel: string;
|
||||
hideContentLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,19 +48,19 @@ export class VisibilityToggle extends AKElement implements VisibilityToggleProps
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "show-message" })
|
||||
showMessage = msg("Show field content");
|
||||
revealContentLabel = msg("Show field content");
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "hide-message" })
|
||||
hideMessage = msg("Hide field content");
|
||||
hideContentLabel = msg("Hide field content");
|
||||
|
||||
render() {
|
||||
const [label, icon] = this.open
|
||||
? [this.hideMessage, "fa-eye"]
|
||||
: [this.showMessage, "fa-eye-slash"];
|
||||
? [this.hideContentLabel, "fa-eye"]
|
||||
: [this.revealContentLabel, "fa-eye-slash"];
|
||||
|
||||
const onClick = (ev: PointerEvent) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -49,11 +49,11 @@ A text-input field with a visibility control, so you can show/hide sensitive fie
|
||||
options: ["text", "code"],
|
||||
description: "Input type hint for styling and behavior",
|
||||
},
|
||||
showMessage: {
|
||||
revealContentLabel: {
|
||||
control: "text",
|
||||
description: "Custom message for show action",
|
||||
},
|
||||
hideMessage: {
|
||||
hideContentLabel: {
|
||||
control: "text",
|
||||
description: "Custom message for hide action",
|
||||
},
|
||||
@@ -78,8 +78,8 @@ const Template: Story = {
|
||||
placeholder=${ifDefined(args.placeholder)}
|
||||
?required=${args.required}
|
||||
input-hint=${ifDefined(args.inputHint)}
|
||||
show-message=${ifDefined(args.showMessage)}
|
||||
hide-message=${ifDefined(args.hideMessage)}
|
||||
show-message=${ifDefined(args.revealContentLabel)}
|
||||
hide-message=${ifDefined(args.hideContentLabel)}
|
||||
></ak-hidden-text-input>
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -52,11 +52,11 @@ A textarea input field with a visibility control, so you can show/hide sensitive
|
||||
options: ["text", "code"],
|
||||
description: "Input type hint for styling and behavior",
|
||||
},
|
||||
showMessage: {
|
||||
revealContentLabel: {
|
||||
control: "text",
|
||||
description: "Custom message for show action",
|
||||
},
|
||||
hideMessage: {
|
||||
hideContentLabel: {
|
||||
control: "text",
|
||||
description: "Custom message for hide action",
|
||||
},
|
||||
@@ -104,8 +104,8 @@ const Template: Story = {
|
||||
wrap=${ifDefined(args.wrap)}
|
||||
?required=${args.required}
|
||||
input-hint=${ifDefined(args.inputHint)}
|
||||
show-message=${ifDefined(args.showMessage)}
|
||||
hide-message=${ifDefined(args.hideMessage)}
|
||||
show-message=${ifDefined(args.revealContentLabel)}
|
||||
hide-message=${ifDefined(args.hideContentLabel)}
|
||||
></ak-hidden-textarea-input>
|
||||
`,
|
||||
};
|
||||
@@ -134,8 +134,8 @@ kPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAE=
|
||||
inputHint: "code",
|
||||
rows: 15,
|
||||
resize: "vertical",
|
||||
showMessage: "Show certificate content",
|
||||
hideMessage: "Hide certificate content",
|
||||
revealContentLabel: "Show certificate content",
|
||||
hideContentLabel: "Hide certificate content",
|
||||
autocomplete: "off",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const metadata: Meta<VisibilityToggleProps> = {
|
||||
# Visibility Toggle Component
|
||||
|
||||
A straightforward two-state iconic button for toggling the visibility of sensitive content such as passwords, private keys, or other secret information.
|
||||
|
||||
|
||||
- Use for sensitive content that users might want to temporarily reveal
|
||||
- There are default hide/show messages for screen readers, but they can be overridden
|
||||
- Clients always handle the state
|
||||
@@ -33,12 +33,12 @@ A straightforward two-state iconic button for toggling the visibility of sensiti
|
||||
control: "boolean",
|
||||
description: "Whether the toggle is in the 'show' state (true) or 'hide' state (false)",
|
||||
},
|
||||
showMessage: {
|
||||
revealContentLabel: {
|
||||
control: "text",
|
||||
description:
|
||||
'Message for screen readers when in hide state (default: "Show field content")',
|
||||
},
|
||||
hideMessage: {
|
||||
hideContentLabel: {
|
||||
control: "text",
|
||||
description:
|
||||
'Message for screen readers when in show state (default: "Hide field content")',
|
||||
@@ -57,14 +57,14 @@ type Story = StoryObj<VisibilityToggle>;
|
||||
const Template: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
showMessage: "Show field content",
|
||||
hideMessage: "Hide field content",
|
||||
revealContentLabel: "Show field content",
|
||||
hideContentLabel: "Hide field content",
|
||||
},
|
||||
render: (args) => html`
|
||||
<ak-visibility-toggle
|
||||
?open=${args.open}
|
||||
show-message=${ifDefined(args.showMessage)}
|
||||
hide-message=${ifDefined(args.hideMessage)}
|
||||
show-message=${ifDefined(args.revealContentLabel)}
|
||||
hide-message=${ifDefined(args.hideContentLabel)}
|
||||
@click=${(e: Event) => {
|
||||
const target = e.target as VisibilityToggle;
|
||||
target.open = !target.open;
|
||||
@@ -78,8 +78,8 @@ const Template: Story = {
|
||||
// Password field integration example
|
||||
export const PasswordFieldExample: Story = {
|
||||
args: {
|
||||
showMessage: "Reveal password",
|
||||
hideMessage: "Conceal password",
|
||||
revealContentLabel: "Reveal password",
|
||||
hideContentLabel: "Conceal password",
|
||||
},
|
||||
render: () => {
|
||||
let isVisible = false;
|
||||
|
||||
@@ -28,6 +28,6 @@ export abstract class Interface extends AKElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
|
||||
this.dataset.testId = "interface-root";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ type ContentValue = SlottedTemplateResult | undefined;
|
||||
*/
|
||||
export function akLoadingOverlay(
|
||||
properties: ILoadingOverlay = {},
|
||||
content: ILoadingOverlayContent = {},
|
||||
content: string | ILoadingOverlayContent = {},
|
||||
) {
|
||||
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
|
||||
// slot-name.
|
||||
|
||||
@@ -46,7 +46,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
/* The array of key/value pairs this pane is currently showing */
|
||||
@property({ type: Array })
|
||||
readonly options: DualSelectPair[] = [];
|
||||
public readonly options?: DualSelectPair[];
|
||||
|
||||
/**
|
||||
* A set (set being easy for lookups) of keys with all the pairs selected,
|
||||
@@ -54,7 +54,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
* can be marked and their clicks ignored.
|
||||
*/
|
||||
@property({ type: Object })
|
||||
readonly selected: Set<string> = new Set();
|
||||
public readonly selected: Set<string> = new Set();
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -75,11 +75,17 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
//#region Refs
|
||||
|
||||
protected listRef = createRef<HTMLDivElement>();
|
||||
#listRef = createRef<HTMLDivElement>();
|
||||
|
||||
#scrollAnimationFrame = -1;
|
||||
|
||||
#scrollIntoView = (): void => {
|
||||
this.#listRef.value?.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
connectedCallback() {
|
||||
public overrideconnectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
for (const [attr, value] of hostAttributes) {
|
||||
@@ -89,9 +95,11 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("options")) {
|
||||
this.listRef.value?.scrollTo(0, 0);
|
||||
protected override updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("options") && this.options?.length) {
|
||||
cancelAnimationFrame(this.#scrollAnimationFrame);
|
||||
|
||||
this.#scrollAnimationFrame = requestAnimationFrame(this.#scrollIntoView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +126,9 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
this.toMove.add(key);
|
||||
}
|
||||
|
||||
this.dispatchCustomEvent(
|
||||
DualSelectEventType.MoveChanged,
|
||||
Array.from(this.toMove.values()).sort(),
|
||||
);
|
||||
const moved = [...this.toMove].sort();
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.MoveChanged, moved);
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.Move);
|
||||
|
||||
@@ -145,7 +152,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div ${ref(this.listRef)} class="pf-c-dual-list-selector__menu">
|
||||
<div ${ref(this.#listRef)} class="pf-c-dual-list-selector__menu">
|
||||
<ul class="pf-c-dual-list-selector__list">
|
||||
${map(this.options, ([key, label]) => {
|
||||
const selected = classMap({
|
||||
|
||||
@@ -100,9 +100,11 @@ export const globalVariables = css`
|
||||
--pf-c-dual-list-selector__list-item-row--BackgroundColor: var(
|
||||
--ak-dark-background-light-ish
|
||||
);
|
||||
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
|
||||
--ak-dark-background-lighter;
|
||||
|
||||
--pf-c-dual-list-selector__list-item-row--focus-within--BackgroundColor: var(
|
||||
--ak-dark-background-darker
|
||||
);
|
||||
|
||||
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
|
||||
--pf-global--BackgroundColor--400
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { groupOptions, isVisibleInScrollRegion } from "./utils.js";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
import type { GroupedOptions, SelectGroup, SelectOption, SelectOptions } from "#elements/types";
|
||||
import { randomId } from "#elements/utils/randomId";
|
||||
|
||||
@@ -16,7 +15,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export interface IListSelect {
|
||||
options: SelectOptions;
|
||||
value?: string;
|
||||
value?: string | null;
|
||||
emptyOption?: string;
|
||||
}
|
||||
|
||||
@@ -68,21 +67,23 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
`,
|
||||
];
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* See the search options type, described in the `./types` file, for the relevant types.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
set options(options: SelectOptions) {
|
||||
this._options = groupOptions(options);
|
||||
public set options(options: SelectOptions) {
|
||||
this.#options = groupOptions(options);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this._options;
|
||||
public get options() {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
_options!: GroupedOptions;
|
||||
#options!: GroupedOptions;
|
||||
|
||||
/**
|
||||
* The current value of the menu.
|
||||
@@ -90,7 +91,7 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
value?: string;
|
||||
public value?: string | null = null;
|
||||
|
||||
/**
|
||||
* The string representation that means an empty option. If not present, no empty option is
|
||||
@@ -99,36 +100,57 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
* @prop
|
||||
*/
|
||||
@property()
|
||||
emptyOption?: string;
|
||||
public emptyOption?: string;
|
||||
|
||||
// We have two different states that we're tracking in this component: the `value`, which is the
|
||||
// element that is currently selected according to the client, and the `index`, which is the
|
||||
// element that is being tracked for keyboard interaction. On a click, the index points to the
|
||||
// value element; on Keydown.Enter, the value becomes whatever the index points to.
|
||||
// value element; on Keyup.Enter, the value becomes whatever the index points to.
|
||||
@state()
|
||||
indexOfFocusedItem = 0;
|
||||
protected indexOfFocusedItem = 0;
|
||||
|
||||
@query("#ak-list-select-list")
|
||||
ul!: HTMLUListElement;
|
||||
protected ul!: HTMLUListElement;
|
||||
|
||||
//#endregion
|
||||
|
||||
get json(): string {
|
||||
return this.value ?? "";
|
||||
}
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.addEventListener("focus", this.onFocus);
|
||||
this.addEventListener("blur", this.onBlur);
|
||||
}
|
||||
//#region Lifecycle
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.addEventListener("focus", this.#focusListener);
|
||||
this.addEventListener("blur", this.#blurListener);
|
||||
|
||||
this.setAttribute("data-ouia-component-type", "ak-menu-select");
|
||||
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.highlightFocusedItem();
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.removeEventListener("focus", this.#focusListener);
|
||||
this.removeEventListener("blur", this.#blurListener);
|
||||
}
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValueMap<this>) {
|
||||
super.updated(changed);
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
public get hasFocus() {
|
||||
return this.renderRoot.contains(document.activeElement) || document.activeElement === this;
|
||||
}
|
||||
@@ -171,30 +193,29 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
currentElement.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
|
||||
@bound
|
||||
onFocus() {
|
||||
//#region Event Listeners
|
||||
|
||||
#focusListener = () => {
|
||||
// Allow the event to propagate.
|
||||
this.currentElement?.focus();
|
||||
this.addEventListener("keydown", this.onKeydown);
|
||||
}
|
||||
this.addEventListener("keyup", this.#delegateKey);
|
||||
};
|
||||
|
||||
@bound
|
||||
onBlur() {
|
||||
#blurListener = () => {
|
||||
// Allow the event to propagate.
|
||||
this.removeEventListener("keydown", this.onKeydown);
|
||||
this.removeEventListener("keyup", this.#delegateKey);
|
||||
this.indexOfFocusedItem = 0;
|
||||
}
|
||||
};
|
||||
|
||||
@bound
|
||||
onClick(value: string | undefined) {
|
||||
#clickListener = (value: string | null) => {
|
||||
// let the click through, but include the change event.
|
||||
this.value = value;
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
}
|
||||
|
||||
@bound
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
||||
};
|
||||
|
||||
#delegateKey = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
const lastItem = this.displayedElements.length - 1;
|
||||
const current = this.indexOfFocusedItem;
|
||||
@@ -208,8 +229,9 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
|
||||
const setValueAndDispatch = () => {
|
||||
event.preventDefault();
|
||||
this.value = this.currentElement?.getAttribute("value") ?? undefined;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
this.value = this.currentElement?.getAttribute("value");
|
||||
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
||||
};
|
||||
|
||||
const pageBy = (direction: number) => {
|
||||
@@ -229,17 +251,9 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
.with({ key: "End" }, () => updateIndex(lastItem))
|
||||
.with({ key: " " }, () => setValueAndDispatch())
|
||||
.with({ key: "Enter" }, () => setValueAndDispatch());
|
||||
}
|
||||
};
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValueMap<this>) {
|
||||
super.updated(changed);
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
//#region Render
|
||||
|
||||
private renderEmptyMenuItem() {
|
||||
return html`<li role="option" class="ak-select-item" part="ak-list-select-option">
|
||||
@@ -247,7 +261,7 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
class="pf-c-dropdown__menu-item"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
@click=${() => this.onClick(undefined)}
|
||||
@click=${() => this.#clickListener(null)}
|
||||
part="ak-list-select-button"
|
||||
>
|
||||
${this.emptyOption}
|
||||
@@ -268,7 +282,7 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
class="pf-c-dropdown__menu-item pf-m-description"
|
||||
value="${value}"
|
||||
tabindex="0"
|
||||
@click=${() => this.onClick(value)}
|
||||
@click=${() => this.#clickListener(value)}
|
||||
part="ak-list-select-button"
|
||||
>
|
||||
<div class="pf-c-dropdown__menu-item-main" part="ak-list-select-label">
|
||||
@@ -316,13 +330,15 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
tabindex="0"
|
||||
part="ak-list-select"
|
||||
>
|
||||
${this.emptyOption === undefined ? nothing : this.renderEmptyMenuItem()}
|
||||
${this._options.grouped
|
||||
? this.renderMenuGroups(this._options.options)
|
||||
: this.renderMenuItems(this._options.options)}
|
||||
${this.emptyOption ? this.renderEmptyMenuItem() : nothing}
|
||||
${this.#options.grouped
|
||||
? this.renderMenuGroups(this.#options.options)
|
||||
: this.renderMenuItems(this.#options.options)}
|
||||
</ul>
|
||||
</div> `;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -51,13 +51,13 @@ export class ModalOrchestrationController implements ReactiveController {
|
||||
#knownModals: ModalElement[] = [];
|
||||
|
||||
public hostConnected() {
|
||||
window.addEventListener("keyup", this.handleKeyup);
|
||||
window.addEventListener("keyup", this.#keyupListener);
|
||||
window.addEventListener("ak-modal-show", this.#addModal);
|
||||
window.addEventListener("ak-modal-hide", this.closeModal);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
window.removeEventListener("keyup", this.handleKeyup);
|
||||
window.removeEventListener("keyup", this.#keyupListener);
|
||||
window.removeEventListener("ak-modal-show", this.#addModal);
|
||||
window.removeEventListener("ak-modal-hide", this.closeModal);
|
||||
}
|
||||
@@ -108,8 +108,16 @@ export class ModalOrchestrationController implements ReactiveController {
|
||||
this.#knownModals = knownModals;
|
||||
};
|
||||
|
||||
handleKeyup = ({ key }: KeyboardEvent) => {
|
||||
#keyupListener = ({ key, defaultPrevented }: KeyboardEvent) => {
|
||||
// The latter handles Firefox 37 and earlier.
|
||||
if (key !== "Escape" && key !== "Esc") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow an event listener within the modal to prevent
|
||||
// our default behavior of closing the modal.
|
||||
if (defaultPrevented) return;
|
||||
|
||||
if (key === "Escape" || key === "Esc") {
|
||||
this.#removeTopmostModal();
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { ErrorDetail } from "@goauthentik/api";
|
||||
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
/**
|
||||
* This is used in two places outside of Flow, and in both cases is used primarily to
|
||||
* display content, not take input. It displays the TOTP QR code, and the static
|
||||
* recovery tokens. But it's used a lot in Flow.
|
||||
*/
|
||||
|
||||
@customElement("ak-form-element")
|
||||
export class FormElement extends AKElement {
|
||||
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl];
|
||||
|
||||
@property()
|
||||
label?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
set errors(value: ErrorDetail[] | undefined) {
|
||||
this._errors = value;
|
||||
const hasError = (value || []).length > 0;
|
||||
this.querySelectorAll("input").forEach((input) => {
|
||||
input.setAttribute("aria-invalid", hasError.toString());
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_errors?: ErrorDetail[];
|
||||
|
||||
updated(): void {
|
||||
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach((input) => {
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text">${this.label}</span>
|
||||
${this.required
|
||||
? html`<span class="pf-c-form__label-required" aria-hidden="true">*</span>`
|
||||
: html``}
|
||||
</label>
|
||||
<slot></slot>
|
||||
${(this._errors || []).map((error) => {
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error">
|
||||
<span class="pf-c-form__helper-text-icon">
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> </span
|
||||
>${error.string}
|
||||
</p>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-form-element": FormElement;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { css, CSSResult, html, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@@ -20,15 +20,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
*/
|
||||
@customElement("ak-form-group")
|
||||
export class AKFormGroup extends AKElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public open = false;
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public label = msg("Details");
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public description?: string;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
PFBase,
|
||||
PFForm,
|
||||
@@ -46,27 +37,6 @@ export class AKFormGroup extends AKElement {
|
||||
}
|
||||
|
||||
details {
|
||||
&::details-content {
|
||||
height: 0;
|
||||
overflow: clip;
|
||||
transition-behavior: normal, allow-discrete;
|
||||
transition-duration: var(--pf-global--TransitionDuration);
|
||||
transition-timing-function: var(--pf-global--TimingFunction);
|
||||
transition-property: height, content-visibility;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition-duration: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (interpolate-size: allow-keywords) {
|
||||
interpolate-size: allow-keywords;
|
||||
|
||||
&[open]::details-content {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&::details-content {
|
||||
padding-inline-start: var(
|
||||
--pf-c-form__field-group--GridTemplateColumns--toggle
|
||||
@@ -102,12 +72,39 @@ export class AKFormGroup extends AKElement {
|
||||
`,
|
||||
];
|
||||
|
||||
formRef = createRef<HTMLFormElement>();
|
||||
//region Properties
|
||||
|
||||
scrollAnimationFrame = -1;
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public open = false;
|
||||
|
||||
scrollIntoView = (): void => {
|
||||
this.formRef.value?.scrollIntoView({
|
||||
@property({ type: String, reflect: true })
|
||||
public label = msg("Details");
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public description?: string;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>): void {
|
||||
const previousOpen = changedProperties.get("open");
|
||||
|
||||
if (typeof previousOpen !== "boolean") return;
|
||||
|
||||
if (this.open && this.open !== previousOpen) {
|
||||
cancelAnimationFrame(this.#scrollAnimationFrame);
|
||||
|
||||
this.#scrollAnimationFrame = requestAnimationFrame(this.#scrollIntoView);
|
||||
}
|
||||
}
|
||||
|
||||
#detailsRef = createRef<HTMLDetailsElement>();
|
||||
|
||||
#scrollAnimationFrame = -1;
|
||||
|
||||
#scrollIntoView = (): void => {
|
||||
this.#detailsRef.value?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
@@ -117,19 +114,16 @@ export class AKFormGroup extends AKElement {
|
||||
*/
|
||||
public toggle = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
cancelAnimationFrame(this.scrollAnimationFrame);
|
||||
|
||||
this.open = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.scrollAnimationFrame = requestAnimationFrame(this.scrollIntoView);
|
||||
}
|
||||
};
|
||||
|
||||
//#region Render
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<details
|
||||
${ref(this.formRef)}
|
||||
${ref(this.#detailsRef)}
|
||||
?open=${this.open}
|
||||
?aria-expanded="${this.open}"
|
||||
role="group"
|
||||
@@ -167,6 +161,8 @@ export class AKFormGroup extends AKElement {
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { isControlElement } from "#elements/AkControlElement";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { AKFormGroup } from "#elements/forms/FormGroup";
|
||||
import { isNameableElement } from "#elements/utils/inputs";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { AKFormErrors, ErrorProp } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
@@ -30,22 +33,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
* being very few unique uses.
|
||||
*/
|
||||
|
||||
const isAkControl = (el: unknown): boolean =>
|
||||
el instanceof HTMLElement &&
|
||||
"dataset" in el &&
|
||||
el.dataset instanceof DOMStringMap &&
|
||||
"akControl" in el.dataset;
|
||||
|
||||
const nameables = new Set([
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"ak-codemirror",
|
||||
"ak-chip-group",
|
||||
"ak-search-select",
|
||||
"ak-radio",
|
||||
]);
|
||||
|
||||
@customElement("ak-form-element-horizontal")
|
||||
export class HorizontalFormElement extends AKElement {
|
||||
static styles: CSSResult[] = [
|
||||
@@ -59,41 +46,40 @@ export class HorizontalFormElement extends AKElement {
|
||||
var(--pf-c-form--m-horizontal__group-label--md--GridColumnWidth)
|
||||
var(--pf-c-form--m-horizontal__group-control--md--GridColumnWidth);
|
||||
}
|
||||
|
||||
.pf-c-form__group-label {
|
||||
padding-top: var(--pf-c-form--m-horizontal__group-label--md--PaddingTop);
|
||||
}
|
||||
|
||||
.pf-c-form__label[aria-required] .pf-c-form__label-text::after {
|
||||
content: "*";
|
||||
user-select: none;
|
||||
margin-left: var(--pf-c-form__label-required--MarginLeft);
|
||||
font-size: var(--pf-c-form__label-required--FontSize);
|
||||
color: var(--pf-c-form__label-required--Color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
public fieldID?: string;
|
||||
|
||||
/**
|
||||
* The label for the input control
|
||||
* @property
|
||||
* @attribute
|
||||
* @deprecated Labels cannot associate with inputs across DOM roots. Use the slotted `label` element instead.
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label = "";
|
||||
public label?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public required = false;
|
||||
public required?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public errorMessages: string[] | string[][] = [];
|
||||
public errorMessages?: ErrorProp[];
|
||||
|
||||
_invalid = false;
|
||||
#invalid = false;
|
||||
|
||||
/* If this property changes, we want to make sure the parent control is "opened" so
|
||||
* that users can see the change.[1]
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
set invalid(v: boolean) {
|
||||
this._invalid = v;
|
||||
this.#invalid = v;
|
||||
// check if we're in a form group, and expand that form group
|
||||
const parent = this.parentElement?.parentElement;
|
||||
|
||||
@@ -102,80 +88,64 @@ export class HorizontalFormElement extends AKElement {
|
||||
}
|
||||
}
|
||||
get invalid(): boolean {
|
||||
return this._invalid;
|
||||
return this.#invalid;
|
||||
}
|
||||
|
||||
@property({ type: String })
|
||||
public name = "";
|
||||
public name?: string;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
attribute: "flow-direction",
|
||||
})
|
||||
public flowDirection: "row" | "column" = "column";
|
||||
//#endregion
|
||||
|
||||
firstUpdated(): void {
|
||||
//#region Lifecycle
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.updated();
|
||||
}
|
||||
|
||||
updated(): void {
|
||||
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach((input) => {
|
||||
input.focus();
|
||||
});
|
||||
this.querySelectorAll("*").forEach((input) => {
|
||||
if (isAkControl(input) && !input.getAttribute("name")) {
|
||||
input.setAttribute("name", this.name);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Ensure that all inputs have a name attribute.
|
||||
*
|
||||
* TODO: Swap with `HTMLElement.prototype.attachInternals`.
|
||||
*/
|
||||
public override updated(): void {
|
||||
// If we don't have a name, we can't do anything.
|
||||
if (!this.name) return;
|
||||
|
||||
if (nameables.has(input.tagName.toLowerCase())) {
|
||||
input.setAttribute("name", this.name);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
for (const element of this.querySelectorAll("*")) {
|
||||
// Is this element capable of being named?
|
||||
if (!isControlElement(element) && !isNameableElement(element)) continue;
|
||||
// And does the element already match the name?
|
||||
if (element.getAttribute("name") === this.name) continue;
|
||||
|
||||
element.setAttribute("name", this.name);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
render(): TemplateResult {
|
||||
this.updated();
|
||||
return html`<div
|
||||
class="pf-c-form__group"
|
||||
role="group"
|
||||
aria-label="${this.label}"
|
||||
data-flow-direction="${this.flowDirection}"
|
||||
>
|
||||
<div class="pf-c-form__group-label">
|
||||
<label
|
||||
id="group-label"
|
||||
class="pf-c-form__label"
|
||||
?aria-required=${this.required}
|
||||
for="${ifDefined(this.fieldID)}"
|
||||
>
|
||||
<span class="pf-c-form__label-text">${this.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
return html`<div class="pf-c-form__group" role="group">
|
||||
${this.label
|
||||
? html`<div class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)}
|
||||
</div>`
|
||||
: nothing}
|
||||
<slot name="label"></slot>
|
||||
|
||||
<div class="pf-c-form__group-control">
|
||||
<slot class="pf-c-form__horizontal-group"></slot>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
${this.errorMessages.map((message) => {
|
||||
if (message instanceof Object) {
|
||||
return html`${Object.entries(message).map(([field, errMsg]) => {
|
||||
return html`<p
|
||||
class="pf-c-form__helper-text pf-m-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
${msg(str`${field}: ${errMsg}`)}
|
||||
</p>`;
|
||||
})}`;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
${message}
|
||||
</p>`;
|
||||
})}
|
||||
${AKFormErrors({ errors: this.errorMessages })}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -22,82 +22,140 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
type Group<T> = [string, T[]];
|
||||
|
||||
export interface ISearchSelectBase<T> {
|
||||
blankable: boolean;
|
||||
blankable?: boolean;
|
||||
query?: string;
|
||||
objects?: T[];
|
||||
selectedObject?: T;
|
||||
selectedObject: T | null;
|
||||
name?: string;
|
||||
placeholder: string;
|
||||
placeholder?: string;
|
||||
emptyOption: string;
|
||||
}
|
||||
|
||||
export class SearchSelectBase<T> extends AkControlElement<string> implements ISearchSelectBase<T> {
|
||||
export abstract class SearchSelectBase<T>
|
||||
extends AkControlElement<string>
|
||||
implements ISearchSelectBase<T>
|
||||
{
|
||||
static styles = [PFBase];
|
||||
|
||||
// A function which takes the query state object (accepting that it may be empty) and returns a
|
||||
// new collection of objects.
|
||||
fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
//#region Properties
|
||||
|
||||
// A function passed to this object that extracts a string representation of items of the
|
||||
// collection under search.
|
||||
renderElement!: (element: T) => string;
|
||||
/**
|
||||
* A function which takes the query state object (accepting that it may be empty)
|
||||
* and returns a
|
||||
* new collection of objects.
|
||||
*/
|
||||
public abstract fetchObjects: (query?: string) => Promise<T[]>;
|
||||
|
||||
// A function passed to this object that extracts an HTML representation of additional
|
||||
// information for items of the collection under search.
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
/**
|
||||
* A function passed to this object that extracts a string representation of items of the
|
||||
* collection under search.
|
||||
*/
|
||||
public abstract renderElement: (element: T) => string;
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
value!: (element: T | undefined) => string;
|
||||
/**
|
||||
* A function passed to this object that extracts an HTML representation of additional
|
||||
* information for items of the collection under search.
|
||||
*/
|
||||
public abstract renderDescription?: (element: T) => string | TemplateResult;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
// fetching the data; sets an initial default value.
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
/**
|
||||
* A function which returns the currently selected object's primary key, used for serialization
|
||||
* into forms.
|
||||
*/
|
||||
public abstract value: (element: T | null) => string;
|
||||
|
||||
// A function passed to this object (or using the default below) that groups objects in the
|
||||
// collection under search into categories.
|
||||
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => {
|
||||
return "";
|
||||
});
|
||||
/**
|
||||
* A function passed to this object that determines an object in the collection under search
|
||||
* should be automatically selected. Only used when the search itself is responsible for
|
||||
* fetching the data; sets an initial default value.
|
||||
*/
|
||||
public abstract selected?: (element: T, elements: T[]) => boolean;
|
||||
|
||||
/**
|
||||
* A function passed to this object (or using the default below) that groups objects in the
|
||||
* collection under search into categories.
|
||||
*/
|
||||
public groupBy: (items: T[]) => [string, T[]][] = (items) => {
|
||||
return groupBy(items, () => "");
|
||||
};
|
||||
|
||||
// Whether or not the dropdown component can be left blank
|
||||
/**
|
||||
* Whether or not the dropdown component can be left blank
|
||||
* @property
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
blankable = false;
|
||||
public blankable?: boolean;
|
||||
|
||||
// An initial string to filter the search contents, and the value of the input which further
|
||||
// serves to restrict the search
|
||||
@property()
|
||||
query?: string;
|
||||
/**
|
||||
* An initial string to filter the search contents,
|
||||
* and the value of the input which further serves to restrict the search.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String })
|
||||
public query?: string;
|
||||
|
||||
// The objects currently available under search
|
||||
@property({ attribute: false })
|
||||
objects?: T[];
|
||||
public objects?: T[];
|
||||
|
||||
// The currently selected object
|
||||
/**
|
||||
* The currently selected object.
|
||||
* @property
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
selectedObject?: T;
|
||||
public selectedObject: T | null = null;
|
||||
|
||||
// Used to inform the form of the name of the object
|
||||
/**
|
||||
* Used to inform the form of the name of the object
|
||||
* @property
|
||||
*/
|
||||
@property()
|
||||
name?: string;
|
||||
public name?: string;
|
||||
|
||||
// The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
// native <input> object's `placeholder` field.
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID?: string;
|
||||
|
||||
/**
|
||||
* Used to inform the form of the input label.
|
||||
* @property
|
||||
*/
|
||||
@property()
|
||||
placeholder: string = msg("Select an object.");
|
||||
public label?: string;
|
||||
|
||||
// A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
// Only used if `blankable` above is true.
|
||||
@property()
|
||||
emptyOption = "---------";
|
||||
/**
|
||||
* The textual placeholder for the search's <input> object, if currently empty.
|
||||
*
|
||||
* Used as the native <input> object's `placeholder` field.
|
||||
* @property
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public placeholder?: string = msg("Select an object.");
|
||||
|
||||
isFetchingData = false;
|
||||
/**
|
||||
* A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
* Only used if `blankable` above is true.
|
||||
*
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String })
|
||||
public emptyOption = "---------";
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
#loading = false;
|
||||
|
||||
@state()
|
||||
error?: APIError;
|
||||
protected error?: APIError;
|
||||
|
||||
//#endregion
|
||||
|
||||
public toForm(): string {
|
||||
if (!this.objects) {
|
||||
@@ -110,7 +168,7 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
return this.toForm();
|
||||
}
|
||||
|
||||
protected dispatchChangeEvent(value: T | undefined) {
|
||||
protected dispatchChangeEvent(value: T | null) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-change", {
|
||||
composed: true,
|
||||
@@ -121,26 +179,27 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
}
|
||||
|
||||
public async updateData() {
|
||||
if (this.isFetchingData) {
|
||||
if (this.#loading) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.isFetchingData = true;
|
||||
|
||||
this.#loading = true;
|
||||
this.dispatchEvent(new Event("loading"));
|
||||
|
||||
return this.fetchObjects(this.query)
|
||||
.then((nextObjects) => {
|
||||
nextObjects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, nextObjects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
});
|
||||
const selectedObject = nextObjects.find((obj) => this.selected?.(obj, nextObjects));
|
||||
|
||||
if (selectedObject) {
|
||||
this.selectedObject = selectedObject;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
|
||||
this.objects = nextObjects;
|
||||
this.isFetchingData = false;
|
||||
this.#loading = false;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
this.isFetchingData = false;
|
||||
this.#loading = false;
|
||||
this.objects = undefined;
|
||||
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
@@ -163,10 +222,11 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
this.removeEventListener(EVENT_REFRESH, this.updateData);
|
||||
}
|
||||
|
||||
private onSearch(event: InputEvent) {
|
||||
#searchListener = (event: InputEvent) => {
|
||||
const value = (event.target as SearchSelectView).rawValue;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
|
||||
if (!value) {
|
||||
this.selectedObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,19 +234,23 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onSelect(event: InputEvent) {
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
this.dispatchChangeEvent(undefined);
|
||||
|
||||
if (!value) {
|
||||
this.selectedObject = null;
|
||||
this.dispatchChangeEvent(null);
|
||||
|
||||
return;
|
||||
}
|
||||
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
|
||||
const selected = this.objects?.find((obj) => this.value(obj) === value) || null;
|
||||
|
||||
if (!selected) {
|
||||
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
|
||||
}
|
||||
|
||||
this.selectedObject = selected;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
@@ -255,24 +319,26 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
||||
|
||||
return html`<ak-search-select-view
|
||||
managed
|
||||
.fieldID=${this.fieldID}
|
||||
.options=${options}
|
||||
value=${ifDefined(value)}
|
||||
?blankable=${this.blankable}
|
||||
label=${ifDefined(this.label)}
|
||||
name=${ifDefined(this.name)}
|
||||
placeholder=${this.placeholder}
|
||||
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
|
||||
@input=${this.onSearch}
|
||||
@input=${this.#searchListener}
|
||||
@change=${this.onSelect}
|
||||
></ak-search-select-view> `;
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValues<this>) {
|
||||
if (!this.isFetchingData && changed.has("objects")) {
|
||||
if (!this.#loading && changed.has("objects")) {
|
||||
this.dispatchEvent(new Event("ready"));
|
||||
}
|
||||
// It is not safe for automated tests to interact with this component while it is fetching
|
||||
// data.
|
||||
if (!this.isFetchingData) {
|
||||
if (!this.#loading) {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ISearchSelectApi<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => string;
|
||||
value: (element: T | null) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy?: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
@@ -47,18 +47,26 @@ export interface ISearchSelectEz<T> extends ISearchSelectBase<T> {
|
||||
export class SearchSelectEz<T> extends SearchSelectBase<T> implements ISearchSelectEz<T> {
|
||||
static styles = [...SearchSelectBase.styles];
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
config!: ISearchSelectApi<T>;
|
||||
public fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
public renderElement!: (element: T) => string;
|
||||
public renderDescription?: ((element: T) => string | TemplateResult) | undefined;
|
||||
public value!: (element: T | null) => string;
|
||||
public selected?: ((element: T, elements: T[]) => boolean) | undefined;
|
||||
|
||||
connectedCallback() {
|
||||
@property({ type: Object, attribute: false })
|
||||
public config!: ISearchSelectApi<T>;
|
||||
|
||||
public override connectedCallback() {
|
||||
this.fetchObjects = this.config.fetchObjects;
|
||||
this.renderElement = this.config.renderElement;
|
||||
this.renderDescription = this.config.renderDescription;
|
||||
this.value = this.config.value;
|
||||
this.selected = this.config.selected;
|
||||
if (this.config.groupBy !== undefined) {
|
||||
|
||||
if (this.config.groupBy) {
|
||||
this.groupBy = this.config.groupBy;
|
||||
}
|
||||
|
||||
super.connectedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from
|
||||
|
||||
import { ListSelect } from "#elements/ak-list-select/ak-list-select";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
import type { GroupedOptions, SelectOption, SelectOptions } from "#elements/types";
|
||||
import { randomId } from "#elements/utils/randomId";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, PropertyValues } from "lit";
|
||||
import { 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, Ref } from "lit/directives/ref.js";
|
||||
@@ -70,7 +69,9 @@ export interface ISearchSelectView {
|
||||
*/
|
||||
@customElement("ak-search-select-view")
|
||||
export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
static styles = [PFBase, PFForm, PFFormControl, PFSelect];
|
||||
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl, PFSelect];
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* The options collection. The simplest variant is just [key, label, optional<description>]. See
|
||||
@@ -79,16 +80,16 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
set options(options: SelectOptions) {
|
||||
this._options = groupOptions(options);
|
||||
this.flatOptions = optionsToFlat(this._options);
|
||||
public set options(options: SelectOptions) {
|
||||
this.#options = groupOptions(options);
|
||||
this.#flatOptions = optionsToFlat(this.#options);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this._options;
|
||||
public get options() {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
_options!: GroupedOptions;
|
||||
#options!: GroupedOptions;
|
||||
|
||||
/**
|
||||
* The current value. Must be one of the keys in the options group above.
|
||||
@@ -96,7 +97,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
value?: string;
|
||||
public value?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the dropdown is open
|
||||
@@ -104,7 +105,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
public open = false;
|
||||
|
||||
/**
|
||||
* If set to true, this object MAY return undefined in no value is passed in and none is set
|
||||
@@ -113,7 +114,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
blankable = false;
|
||||
public blankable = false;
|
||||
|
||||
/**
|
||||
* If not managed, make the matcher case-sensitive during interaction. If managed,
|
||||
@@ -122,15 +123,23 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "case-sensitive" })
|
||||
caseSensitive = false;
|
||||
public caseSensitive = false;
|
||||
|
||||
/**
|
||||
* The name of the input, for forms
|
||||
* The name of the input, for forms.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
name?: string;
|
||||
public name?: string;
|
||||
|
||||
/**
|
||||
* The label of the input, for forms.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
/**
|
||||
* The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
@@ -139,7 +148,14 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
placeholder: string = msg("Select an object.");
|
||||
public placeholder: string = msg("Select an object.");
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID?: string;
|
||||
|
||||
/**
|
||||
* If true, the component only sends an input message up to a parent component. If false, the
|
||||
@@ -149,7 +165,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
*@attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
managed = false;
|
||||
public managed = false;
|
||||
|
||||
/**
|
||||
* A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
@@ -158,36 +174,50 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
emptyOption = "---------";
|
||||
public emptyOption = "---------";
|
||||
|
||||
// Handle the behavior of the drop-down when the :host scrolls off the page.
|
||||
scrollHandler?: () => void;
|
||||
//#endregion
|
||||
|
||||
// observer: IntersectionObserver;
|
||||
//#region State
|
||||
|
||||
@state()
|
||||
displayValue = "";
|
||||
protected displayValue = "";
|
||||
|
||||
// Tracks when the inputRef is populated, so we can safely reschedule the
|
||||
// render of the dropdown with respect to it.
|
||||
@state()
|
||||
inputRefIsAvailable = false;
|
||||
protected inputRefIsAvailable = false;
|
||||
|
||||
/**
|
||||
* Permanent identity with the portal so focus events can be checked.
|
||||
*/
|
||||
menuRef: Ref<ListSelect> = createRef();
|
||||
#menuRef: Ref<ListSelect> = createRef();
|
||||
|
||||
/**
|
||||
* Permanent identify for the input object, so the floating portal can find where to anchor
|
||||
* itself.
|
||||
*/
|
||||
inputRef: Ref<HTMLInputElement> = createRef();
|
||||
#inputRef: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
/**
|
||||
* Maps a value from the portal to labels to be put into the <input> field>
|
||||
* Maps a value from the portal to labels to be put into the <input> field>
|
||||
*/
|
||||
flatOptions: [string, SelectOption][] = [];
|
||||
#flatOptions: [label: string, option: SelectOption][] = [];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override updated() {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
public override firstUpdated() {
|
||||
// Route around Lit's scheduling algorithm complaining about re-renders
|
||||
window.setTimeout(() => {
|
||||
this.inputRefIsAvailable = Boolean(this.#inputRef?.value);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -203,73 +233,90 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@bound
|
||||
onClick(_ev: Event) {
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#clickListener = (_ev: Event) => {
|
||||
this.open = !this.open;
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
this.#inputRef.value?.focus();
|
||||
};
|
||||
|
||||
setFromMatchList(value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
const probableValue = this.flatOptions.find((option) => option[0] === this.value);
|
||||
if (probableValue && this.inputRef.value) {
|
||||
this.inputRef.value.value = probableValue[1][1];
|
||||
setFromMatchList(value?: string) {
|
||||
if (!value) return;
|
||||
|
||||
const probableValue = this.#flatOptions.find(([label]) => label === this.value);
|
||||
|
||||
if (probableValue && this.#inputRef.value) {
|
||||
this.#inputRef.value.value = probableValue[1][1];
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
if (event.code === "Escape") {
|
||||
#searchKeyupListener = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.open = false;
|
||||
}
|
||||
if (event.code === "ArrowDown" || event.code === "ArrowUp") {
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
this.#menuRef.value?.currentElement?.focus();
|
||||
this.open = true;
|
||||
}
|
||||
if (event.code === "Tab" && this.open) {
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
this.menuRef.value?.currentElement?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@bound
|
||||
onListBlur(event: FocusEvent) {
|
||||
#searchKeydownListener = (event: KeyboardEvent) => {
|
||||
if (!this.open) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
|
||||
this.#menuRef.value?.currentElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
#blurListener = (event: FocusEvent) => {
|
||||
// If we lost focus but the menu got it, don't do anything;
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | undefined;
|
||||
if (
|
||||
relatedTarget &&
|
||||
(this.contains(relatedTarget) ||
|
||||
this.renderRoot.contains(relatedTarget) ||
|
||||
this.menuRef.value?.contains(relatedTarget) ||
|
||||
this.menuRef.value?.renderRoot.contains(relatedTarget))
|
||||
this.#menuRef.value?.contains(relatedTarget) ||
|
||||
this.#menuRef.value?.renderRoot.contains(relatedTarget))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.open = false;
|
||||
if (this.value === undefined) {
|
||||
if (this.inputRef.value) {
|
||||
this.inputRef.value.value = "";
|
||||
if (!this.value) {
|
||||
if (this.#inputRef.value) {
|
||||
this.#inputRef.value.value = "";
|
||||
}
|
||||
this.setValue(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setValue(newValue: string | undefined) {
|
||||
this.value = newValue;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
findValueForInput() {
|
||||
const value = this.inputRef.value?.value;
|
||||
const value = this.#inputRef.value?.value;
|
||||
if (value === undefined || value.trim() === "") {
|
||||
this.setValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchesFound = findFlatOptions(this.flatOptions, value);
|
||||
const matchesFound = findFlatOptions(this.#flatOptions, value);
|
||||
if (matchesFound.length > 0) {
|
||||
const newValue = matchesFound[0][0];
|
||||
if (newValue === value) {
|
||||
@@ -281,47 +328,52 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
onInput(_ev: InputEvent) {
|
||||
#inputListener = (_ev: InputEvent) => {
|
||||
if (!this.managed) {
|
||||
this.findValueForInput();
|
||||
this.requestUpdate();
|
||||
}
|
||||
this.open = true;
|
||||
}
|
||||
};
|
||||
|
||||
@bound
|
||||
onListKeydown(event: KeyboardEvent) {
|
||||
#listKeyupListener = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
|
||||
this.open = false;
|
||||
this.inputRef.value?.focus();
|
||||
this.#inputRef.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
#listKeydownListener = (event: KeyboardEvent) => {
|
||||
if (event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
onListChange(event: InputEvent) {
|
||||
this.#inputRef.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
#changeListener = (event: InputEvent) => {
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
if (value !== undefined) {
|
||||
if (value) {
|
||||
const newDisplayValue = this.findDisplayForValue(value);
|
||||
if (this.inputRef.value) {
|
||||
this.inputRef.value.value = newDisplayValue ?? "";
|
||||
if (this.#inputRef.value) {
|
||||
this.#inputRef.value.value = newDisplayValue ?? "";
|
||||
}
|
||||
} else if (this.inputRef.value) {
|
||||
this.inputRef.value.value = "";
|
||||
} else if (this.#inputRef.value) {
|
||||
this.#inputRef.value.value = "";
|
||||
}
|
||||
this.open = false;
|
||||
this.setValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
findDisplayForValue(value: string) {
|
||||
const newDisplayValue = this.flatOptions.find((option) => option[0] === value);
|
||||
const newDisplayValue = this.#flatOptions.find((option) => option[0] === value);
|
||||
return newDisplayValue ? newDisplayValue[1][1] : undefined;
|
||||
}
|
||||
|
||||
@@ -340,15 +392,17 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.inputRef.value?.value ?? "";
|
||||
return this.#inputRef.value?.value ?? "";
|
||||
}
|
||||
|
||||
get managedOptions() {
|
||||
return this.managed
|
||||
? this._options
|
||||
: findOptionsSubset(this._options, this.rawValue, this.caseSensitive);
|
||||
? this.#options
|
||||
: findOptionsSubset(this.#options, this.rawValue, this.caseSensitive);
|
||||
}
|
||||
|
||||
//#region Render
|
||||
|
||||
public override render() {
|
||||
const emptyOption = this.blankable ? this.emptyOption : undefined;
|
||||
const open = this.open;
|
||||
@@ -357,17 +411,22 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
<div class="pf-c-select__toggle pf-m-typeahead" part="ak-search-select-toggle">
|
||||
<div class="pf-c-select__toggle-wrapper" part="ak-search-select-wrapper">
|
||||
<input
|
||||
?required=${!this.blankable}
|
||||
part="ak-search-select-toggle-typeahead"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control pf-c-select__toggle-typeahead"
|
||||
type="text"
|
||||
${ref(this.inputRef)}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
${ref(this.#inputRef)}
|
||||
placeholder=${this.placeholder}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
name=${ifDefined(this.name)}
|
||||
spellcheck="false"
|
||||
@input=${this.onInput}
|
||||
@click=${this.onClick}
|
||||
@blur=${this.onListBlur}
|
||||
@keydown=${this.onKeydown}
|
||||
@input=${this.#inputListener}
|
||||
@click=${this.#clickListener}
|
||||
@blur=${this.#blurListener}
|
||||
@keyup=${this.#searchKeyupListener}
|
||||
@keydown=${this.#searchKeydownListener}
|
||||
value=${this.displayValue}
|
||||
/>
|
||||
</div>
|
||||
@@ -377,34 +436,26 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
? html`
|
||||
<ak-portal
|
||||
name=${ifDefined(this.name)}
|
||||
.anchor=${this.inputRef.value}
|
||||
.anchor=${this.#inputRef.value}
|
||||
?open=${open}
|
||||
>
|
||||
<ak-list-select
|
||||
id="menu-${this.getAttribute("data-ouia-component-id")}"
|
||||
${ref(this.menuRef)}
|
||||
${ref(this.#menuRef)}
|
||||
.options=${this.managedOptions}
|
||||
value=${ifDefined(this.value)}
|
||||
@change=${this.onListChange}
|
||||
@blur=${this.onListBlur}
|
||||
@change=${this.#changeListener}
|
||||
@blur=${this.#blurListener}
|
||||
emptyOption=${ifDefined(emptyOption)}
|
||||
@keydown=${this.onListKeydown}
|
||||
@keydown=${this.#listKeydownListener}
|
||||
@keyup=${this.#listKeyupListener}
|
||||
></ak-list-select>
|
||||
</ak-portal>
|
||||
`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
public override updated() {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
public override firstUpdated() {
|
||||
// Route around Lit's scheduling algorithm complaining about re-renders
|
||||
window.setTimeout(() => {
|
||||
this.inputRefIsAvailable = Boolean(this.inputRef?.value);
|
||||
}, 0);
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => string;
|
||||
value: (element: T | null) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
@@ -44,44 +44,28 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
* consequence of the user typing or when selecting from the list.
|
||||
*
|
||||
*/
|
||||
|
||||
@customElement("ak-search-select")
|
||||
export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelect<T> {
|
||||
static styles = [...SearchSelectBase.styles];
|
||||
|
||||
// A function which takes the query state object (accepting that it may be empty) and returns a
|
||||
// new collection of objects.
|
||||
@property({ attribute: false })
|
||||
fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
public fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
|
||||
// A function passed to this object that extracts a string representation of items of the
|
||||
// collection under search.
|
||||
@property({ attribute: false })
|
||||
renderElement!: (element: T) => string;
|
||||
public renderElement!: (element: T) => string;
|
||||
|
||||
// A function passed to this object that extracts an HTML representation of additional
|
||||
// information for items of the collection under search.
|
||||
@property({ attribute: false })
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
public renderDescription?: (element: T) => string | TemplateResult;
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
@property({ attribute: false })
|
||||
value!: (element: T | undefined) => string;
|
||||
public value!: (element: T | null) => string;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
// fetching the data; sets an initial default value.
|
||||
@property({ attribute: false })
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
public selected?: (element: T, elements: T[]) => boolean;
|
||||
|
||||
// A function passed to this object (or using the default below) that groups objects in the
|
||||
// collection under search into categories.
|
||||
@property({ attribute: false })
|
||||
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => {
|
||||
return "";
|
||||
});
|
||||
public groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => "");
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export const GroupedAndEz = () => {
|
||||
const config: ISearchSelectApi<Sample> = {
|
||||
fetchObjects: getSamples,
|
||||
renderElement: (sample: Sample) => sample.name,
|
||||
value: (sample: Sample | undefined) => sample?.pk ?? "",
|
||||
value: (sample: Sample | null) => sample?.pk ?? "",
|
||||
groupBy: (samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
|
||||
};
|
||||
|
||||
@@ -54,6 +54,19 @@ export type LitPropertyRecord<T extends object> = {
|
||||
*/
|
||||
export type LitPropertyKey<K> = K extends string ? `.${K}` | `?${K}` | K : K;
|
||||
|
||||
/**
|
||||
* A React-like functional component. Used to render a component in a template.
|
||||
*
|
||||
* @template P The type of the props object.
|
||||
* @param props The props object.
|
||||
* @param children The children to render.
|
||||
* @returns The rendered template.
|
||||
*/
|
||||
export type LitFC<P> = (
|
||||
props: P,
|
||||
children?: SlottedTemplateResult,
|
||||
) => SlottedTemplateResult | SlottedTemplateResult[];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Host/Controller
|
||||
|
||||
@@ -7,6 +7,11 @@ export type NamedElement<T = Element> = T & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type predicate to check if an element currently has a `name` attribute.
|
||||
*
|
||||
* @see {@linkcode isNameableElement} to check if an element is nameable.
|
||||
*/
|
||||
export function isNamedElement(element: Element): element is NamedElement {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
@@ -15,27 +20,57 @@ export function isNamedElement(element: Element): element is NamedElement {
|
||||
return "name" in element.attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of elements that can be named, i.e. have a `name` attribute.
|
||||
*
|
||||
* @deprecated This should be replaced with a less brittle approach.
|
||||
*/
|
||||
const NameableElements = new Set([
|
||||
"INPUT",
|
||||
"TEXTAREA",
|
||||
"SELECT",
|
||||
"AK-CODEMIRROR",
|
||||
"AK-CHIP-GROUP",
|
||||
"AK-SEARCH-SELECT",
|
||||
"AK-RADIO",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Type predicate to check if an element is nameable.
|
||||
*
|
||||
* @see {@linkcode isNamedElement} to check if an element currently has a `name` attribute.
|
||||
*/
|
||||
export function isNameableElement(element: Element): element is NamedElement {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return NameableElements.has(element.tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a map of files provided by input elements within the given iterable.
|
||||
*/
|
||||
export function createFileMap<T extends PropertyKey = PropertyKey>(
|
||||
fileInputParents?: Iterable<NamedElement<LitElement>> | null,
|
||||
fileInputParents?: Iterable<LitElement> | null,
|
||||
): Map<T, File> {
|
||||
const record = new Map<T, File>();
|
||||
|
||||
for (const element of fileInputParents || []) {
|
||||
element.requestUpdate();
|
||||
|
||||
if (!isNamedElement(element)) continue;
|
||||
|
||||
const inputElement = element.querySelector<HTMLInputElement>("input[type=file]");
|
||||
|
||||
if (!inputElement) continue;
|
||||
|
||||
const file = inputElement.files?.[0];
|
||||
const name = element.name;
|
||||
const name = element.name as T;
|
||||
|
||||
if (!file || !name) continue;
|
||||
|
||||
record.set(name as T, file);
|
||||
record.set(name, file);
|
||||
}
|
||||
|
||||
return record;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import "#elements/forms/FormElement";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
import { isActiveElement } from "#elements/utils/focus";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
@@ -12,6 +12,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
@@ -40,7 +41,7 @@ const Visibility = {
|
||||
|
||||
@customElement("ak-flow-input-password")
|
||||
export class InputPassword extends AKElement {
|
||||
static styles = [PFBase, PFInputGroup, PFFormControl, PFButton];
|
||||
static styles = [PFBase, PFForm, PFInputGroup, PFFormControl, PFButton];
|
||||
|
||||
//#region Properties
|
||||
|
||||
@@ -50,7 +51,7 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String, attribute: "input-id" })
|
||||
inputId = "ak-stage-password-input";
|
||||
public inputID = "ak-stage-password-input";
|
||||
|
||||
/**
|
||||
* The name of the input field.
|
||||
@@ -58,7 +59,7 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
name = "password";
|
||||
public name = "password";
|
||||
|
||||
/**
|
||||
* The label for the input field.
|
||||
@@ -66,7 +67,7 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
label = msg("Password");
|
||||
public label = msg("Password");
|
||||
|
||||
/**
|
||||
* The placeholder text for the input field.
|
||||
@@ -74,7 +75,7 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
placeholder = msg("Please enter your password");
|
||||
public placeholder = msg("Please enter your password");
|
||||
|
||||
/**
|
||||
* The initial value of the input field.
|
||||
@@ -82,20 +83,20 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String, attribute: "prefill" })
|
||||
initialValue = "";
|
||||
public initialValue = "";
|
||||
|
||||
/**
|
||||
* The errors for the input field.
|
||||
*/
|
||||
@property({ type: Object })
|
||||
errors: Record<string, string> = {};
|
||||
public errors: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Forwarded to the input tag's aria-invalid attribute, if set
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
invalid?: string;
|
||||
public invalid?: string;
|
||||
|
||||
/**
|
||||
* Whether to allow the user to toggle the visibility of the password.
|
||||
@@ -103,7 +104,7 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "allow-show-password" })
|
||||
allowShowPassword = false;
|
||||
public allowShowPassword = false;
|
||||
|
||||
/**
|
||||
* Whether the password is currently visible.
|
||||
@@ -111,7 +112,7 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "password-visible" })
|
||||
passwordVisible = false;
|
||||
public passwordVisible = false;
|
||||
|
||||
/**
|
||||
* Automatically grab focus after rendering.
|
||||
@@ -119,15 +120,15 @@ export class InputPassword extends AKElement {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "grab-focus" })
|
||||
grabFocus = false;
|
||||
public grabFocus = false;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Refs
|
||||
|
||||
inputRef: Ref<HTMLInputElement> = createRef();
|
||||
public inputRef: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
|
||||
public toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -137,7 +138,7 @@ export class InputPassword extends AKElement {
|
||||
* Whether the caps lock key is enabled.
|
||||
*/
|
||||
@state()
|
||||
capsLock = false;
|
||||
public capsLock = false;
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -314,37 +315,33 @@ export class InputPassword extends AKElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <ak-form-element
|
||||
label="${this.label}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${this.errors}
|
||||
>
|
||||
<div class="pf-c-form__group-control">
|
||||
<div class="pf-c-input-group">
|
||||
<input
|
||||
type=${this.passwordVisible ? "text" : "password"}
|
||||
id=${this.inputId}
|
||||
name=${this.name}
|
||||
placeholder=${this.placeholder}
|
||||
autocomplete="current-password"
|
||||
class="${classMap({
|
||||
"pf-c-form-control": true,
|
||||
"pf-m-icon": true,
|
||||
"pf-m-caps-lock": this.capsLock,
|
||||
})}"
|
||||
required
|
||||
aria-invalid=${ifDefined(this.invalid)}
|
||||
value=${this.initialValue}
|
||||
${ref(this.inputRef)}
|
||||
/>
|
||||
return html` ${AKLabel({ required: true, htmlFor: this.inputID }, this.label)}
|
||||
<div class="pf-c-form__group">
|
||||
<div class="pf-c-form__group-control">
|
||||
<div class="pf-c-input-group">
|
||||
<input
|
||||
type=${this.passwordVisible ? "text" : "password"}
|
||||
id=${this.inputID}
|
||||
name=${this.name}
|
||||
placeholder=${this.placeholder}
|
||||
autocomplete="current-password"
|
||||
class="${classMap({
|
||||
"pf-c-form-control": true,
|
||||
"pf-m-icon": true,
|
||||
"pf-m-caps-lock": this.capsLock,
|
||||
})}"
|
||||
required
|
||||
aria-invalid=${ifDefined(this.invalid)}
|
||||
value=${this.initialValue}
|
||||
${ref(this.inputRef)}
|
||||
/>
|
||||
|
||||
${this.renderVisibilityToggle()}
|
||||
${this.renderVisibilityToggle()}
|
||||
</div>
|
||||
|
||||
${this.renderHelperText()}
|
||||
</div>
|
||||
|
||||
${this.renderHelperText()}
|
||||
</div>
|
||||
</ak-form-element>`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
|
||||
import {
|
||||
@@ -16,6 +18,7 @@ import { customElement } from "lit/decorators.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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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";
|
||||
@@ -25,15 +28,24 @@ export class OAuth2DeviceCode extends BaseStage<
|
||||
OAuthDeviceCodeChallenge,
|
||||
OAuthDeviceCodeChallengeResponseRequest
|
||||
> {
|
||||
static styles: CSSResult[] = [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
|
||||
static styles: CSSResult[] = [
|
||||
PFBase,
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
PFInputGroup,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<ak-flow-card .challenge=${this.challenge}>
|
||||
<form
|
||||
class="pf-c-form"
|
||||
@submit=${this.submitForm}
|
||||
>
|
||||
<form class="pf-c-form" @submit=${this.submitForm}>
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: "device-code-input" }, msg("Device Code"))}
|
||||
|
||||
<input
|
||||
id="device-code-input"
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
@@ -45,7 +57,8 @@ export class OAuth2DeviceCode extends BaseStage<
|
||||
value=""
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.code })}
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
|
||||
import {
|
||||
@@ -18,6 +20,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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";
|
||||
@@ -33,6 +36,7 @@ export class AuthenticatorEmailStage extends BaseStage<
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFInputGroup,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
];
|
||||
@@ -51,13 +55,13 @@ export class AuthenticatorEmailStage extends BaseStage<
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-form-element
|
||||
label="${msg("Configure your email")}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).email}
|
||||
>
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel(
|
||||
{ required: true, htmlFor: "email-input" },
|
||||
msg("Configure your email"),
|
||||
)}
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="${msg("Please enter your email address.")}"
|
||||
@@ -66,7 +70,8 @@ export class AuthenticatorEmailStage extends BaseStage<
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.email })}
|
||||
</div>
|
||||
${this.renderNonFieldErrors()}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
@@ -93,13 +98,10 @@ export class AuthenticatorEmailStage extends BaseStage<
|
||||
A verification token has been sent to your configured email address
|
||||
${ifDefined(this.challenge.email)}
|
||||
<form class="pf-c-form" @submit=${this.submitForm}>
|
||||
<ak-form-element
|
||||
label="${msg("Code")}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).code}
|
||||
>
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: "code-input" }, msg("Code"))}
|
||||
<input
|
||||
id="code-input"
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
@@ -110,7 +112,8 @@ export class AuthenticatorEmailStage extends BaseStage<
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.code })}
|
||||
</div>
|
||||
${this.renderNonFieldErrors()}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
|
||||
import {
|
||||
@@ -18,6 +20,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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";
|
||||
@@ -33,15 +36,18 @@ export class AuthenticatorSMSStage extends BaseStage<
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFInputGroup,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
];
|
||||
|
||||
renderPhoneNumber(): TemplateResult {
|
||||
return html`<ak-flow-card .challenge=${this.challenge}>
|
||||
<form
|
||||
class="pf-c-form"
|
||||
@submit=${this.submitForm}
|
||||
<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)}"
|
||||
@@ -49,12 +55,12 @@ export class AuthenticatorSMSStage extends BaseStage<
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-form-element
|
||||
label="${msg("Phone number")}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).phone_number}
|
||||
>
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel(
|
||||
{ required: true, htmlFor: "phone-number-input" },
|
||||
msg("Phone number"),
|
||||
)}
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
name="phoneNumber"
|
||||
@@ -64,7 +70,8 @@ export class AuthenticatorSMSStage extends BaseStage<
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.phone_number })}
|
||||
</div>
|
||||
${this.renderNonFieldErrors()}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
@@ -89,13 +96,10 @@ export class AuthenticatorSMSStage extends BaseStage<
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-form-element
|
||||
label="${msg("Code")}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).code}
|
||||
>
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: "sms-code-input" }, msg("Code"))}
|
||||
<input
|
||||
id="sms-code-input"
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
@@ -106,7 +110,8 @@ export class AuthenticatorSMSStage extends BaseStage<
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.code })}
|
||||
</div>
|
||||
${this.renderNonFieldErrors()}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
@@ -64,13 +63,13 @@ export class AuthenticatorStaticStage extends BaseStage<
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-form-element label="" class="pf-c-form__group">
|
||||
<div class="pf-c-form__group">
|
||||
<ul>
|
||||
${this.challenge.codes.map((token) => {
|
||||
return html`<li class="pf-m-monospace">${token}</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</ak-form-element>
|
||||
</div>
|
||||
<p>${msg("Make sure to keep these tokens in a safe place.")}</p>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
import "webcomponent-qr-code";
|
||||
@@ -7,6 +6,9 @@ import { MessageLevel } from "#common/messages";
|
||||
|
||||
import { showMessage } from "#elements/messages/MessageContainer";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
|
||||
import {
|
||||
@@ -22,6 +24,7 @@ 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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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";
|
||||
@@ -36,6 +39,7 @@ export class AuthenticatorTOTPStage extends BaseStage<
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFInputGroup,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
css`
|
||||
@@ -62,7 +66,8 @@ export class AuthenticatorTOTPStage extends BaseStage<
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<input type="hidden" name="otp_uri" value=${this.challenge.configUrl} />
|
||||
<ak-form-element>
|
||||
|
||||
<div class="pf-c-form__group">
|
||||
<div class="qr-container">
|
||||
<qr-code data="${this.challenge.configUrl}"></qr-code>
|
||||
<button
|
||||
@@ -92,20 +97,16 @@ export class AuthenticatorTOTPStage extends BaseStage<
|
||||
${msg("Copy")}
|
||||
</button>
|
||||
</div>
|
||||
</ak-form-element>
|
||||
</div>
|
||||
<p>
|
||||
${msg(
|
||||
"Please scan the QR code above using the Microsoft Authenticator, Google Authenticator, or other authenticator apps on your device, and enter the code the device displays below to finish setting up the MFA device.",
|
||||
)}
|
||||
</p>
|
||||
<ak-form-element
|
||||
label="${msg("Code")}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).code}
|
||||
>
|
||||
<!-- @ts-ignore -->
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: "totp-code-input" }, msg("Code"))}
|
||||
<input
|
||||
id="totp-code-input"
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
@@ -117,7 +118,8 @@ export class AuthenticatorTOTPStage extends BaseStage<
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.code })}
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { BaseDeviceStage } from "#flow/stages/authenticator_validate/base";
|
||||
import { PasswordManagerPrefill } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
@@ -74,16 +76,15 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
|
||||
<i class="fa ${this.deviceIcon()}" aria-hidden="true"></i>
|
||||
<p>${this.deviceMessage()}</p>
|
||||
</div>
|
||||
<ak-form-element
|
||||
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
||||
? msg("Static token")
|
||||
: msg("Authentication code")}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).code}
|
||||
>
|
||||
<!-- @ts-ignore -->
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel(
|
||||
{ required: true, htmlFor: "validation-code-input" },
|
||||
this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
||||
? msg("Static token")
|
||||
: msg("Authentication code"),
|
||||
)}
|
||||
<input
|
||||
id="validation-code-input"
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
||||
@@ -99,7 +100,8 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
|
||||
value="${PasswordManagerPrefill.totp || ""}"
|
||||
required
|
||||
/>
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.code })}
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "#elements/EmptyState";
|
||||
import "#elements/forms/FormElement";
|
||||
|
||||
import { BaseDeviceStage } from "#flow/stages/authenticator_validate/base";
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { property } from "lit/decorators.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 PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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";
|
||||
@@ -29,6 +30,7 @@ export class BaseDeviceStage<
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFInputGroup,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
css`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import "#elements/Divider";
|
||||
import "#elements/EmptyState";
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/components/ak-flow-card";
|
||||
import "#flow/components/ak-flow-password-input";
|
||||
import "#flow/stages/captcha/CaptchaStage";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { renderSourceIcon } from "#admin/sources/utils";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
@@ -87,6 +89,14 @@ export class IdentificationStage extends BaseStage<
|
||||
`,
|
||||
];
|
||||
|
||||
/**
|
||||
* The ID of the input field.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String, attribute: "input-id" })
|
||||
public inputID = "ak-identifier-input";
|
||||
|
||||
#form?: HTMLFormElement;
|
||||
|
||||
#rememberMe = new AkRememberMeController(this);
|
||||
@@ -301,6 +311,7 @@ export class IdentificationStage extends BaseStage<
|
||||
[UserFieldsEnum.Upn]: msg("UPN"),
|
||||
};
|
||||
const label = OR_LIST_FORMATTERS.format(fields.map((f) => uiFields[f]));
|
||||
|
||||
return html`${this.challenge.flowDesignation === FlowDesignationEnum.Recovery
|
||||
? html`
|
||||
<p>
|
||||
@@ -310,13 +321,10 @@ export class IdentificationStage extends BaseStage<
|
||||
</p>
|
||||
`
|
||||
: nothing}
|
||||
<ak-form-element
|
||||
label=${label}
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge.responseErrors || {}).uid_field}
|
||||
>
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: this.inputID }, label)}
|
||||
<input
|
||||
id=${this.inputID}
|
||||
type=${type}
|
||||
name="uidField"
|
||||
placeholder=${label}
|
||||
@@ -328,12 +336,13 @@ export class IdentificationStage extends BaseStage<
|
||||
required
|
||||
/>
|
||||
${this.#rememberMe.render()}
|
||||
</ak-form-element>
|
||||
${AKFormErrors({ errors: this.challenge.responseErrors?.uid_field })}
|
||||
</div>
|
||||
${this.challenge.passwordFields
|
||||
? html`
|
||||
<ak-flow-input-password
|
||||
label=${msg("Password")}
|
||||
inputId="ak-stage-identification-password"
|
||||
input-idd="ak-stage-identification-password"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {}).password}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
import "#flow/components/ak-flow-password-input";
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import "#elements/Divider";
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
import { LOCALES } from "#elements/ak-locale-context/definitions";
|
||||
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFCheck from "@patternfly/patternfly/components/Check/check.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.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";
|
||||
@@ -38,6 +41,7 @@ export class PromptStage extends WithCapabilitiesConfig(
|
||||
PFAlert,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFInputGroup,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
PFCheck,
|
||||
@@ -55,6 +59,7 @@ export class PromptStage extends WithCapabilitiesConfig(
|
||||
case PromptTypeEnum.Text:
|
||||
return html`<input
|
||||
type="text"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
@@ -64,6 +69,7 @@ export class PromptStage extends WithCapabilitiesConfig(
|
||||
/>`;
|
||||
case PromptTypeEnum.TextArea:
|
||||
return html`<textarea
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
@@ -75,6 +81,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.TextReadOnly:
|
||||
return html`<input
|
||||
type="text"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -83,6 +90,7 @@ ${prompt.initialValue}</textarea
|
||||
/>`;
|
||||
case PromptTypeEnum.TextAreaReadOnly:
|
||||
return html`<textarea
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -93,6 +101,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Username:
|
||||
return html`<input
|
||||
type="text"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="username"
|
||||
@@ -104,6 +113,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Email:
|
||||
return html`<input
|
||||
type="email"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -113,6 +123,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Password:
|
||||
return html`<input
|
||||
type="password"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="new-password"
|
||||
@@ -122,6 +133,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Number:
|
||||
return html`<input
|
||||
type="number"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -131,6 +143,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Date:
|
||||
return html`<input
|
||||
type="date"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -140,6 +153,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.DateTime:
|
||||
return html`<input
|
||||
type="datetime"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -149,6 +163,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.File:
|
||||
return html`<input
|
||||
type="file"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
@@ -160,6 +175,7 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Hidden:
|
||||
return html`<input
|
||||
type="hidden"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
value="${prompt.initialValue}"
|
||||
class="pf-c-form-control"
|
||||
@@ -168,7 +184,11 @@ ${prompt.initialValue}</textarea
|
||||
case PromptTypeEnum.Static:
|
||||
return html`<p>${unsafeHTML(prompt.initialValue)}</p>`;
|
||||
case PromptTypeEnum.Dropdown:
|
||||
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
return html`<select
|
||||
class="pf-c-form-control"
|
||||
id="field-${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
>
|
||||
${prompt.choices?.map((choice) => {
|
||||
return html`<option
|
||||
value="${choice}"
|
||||
@@ -256,14 +276,19 @@ ${prompt.initialValue}</textarea
|
||||
</div>`;
|
||||
}
|
||||
if (this.shouldRenderInWrapper(prompt)) {
|
||||
return html`<ak-form-element
|
||||
label="${prompt.label}"
|
||||
?required="${prompt.required}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
|
||||
>
|
||||
const errors = (this.challenge?.responseErrors || {})[prompt.fieldKey];
|
||||
|
||||
return html`<div class="pf-c-form__group">
|
||||
${AKLabel(
|
||||
{
|
||||
required: prompt.required,
|
||||
htmlFor: `field-${prompt.fieldKey}`,
|
||||
},
|
||||
prompt.label,
|
||||
)}
|
||||
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
|
||||
</ak-form-element>`;
|
||||
${AKFormErrors({ errors })}
|
||||
</div>`;
|
||||
}
|
||||
return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`;
|
||||
}
|
||||
@@ -279,9 +304,7 @@ ${prompt.initialValue}</textarea
|
||||
render(): TemplateResult {
|
||||
return html`<ak-flow-card .challenge=${this.challenge}>
|
||||
<form class="pf-c-form" @submit=${this.submitForm}>
|
||||
${this.challenge.fields.map((prompt) => {
|
||||
return this.renderField(prompt);
|
||||
})}
|
||||
${this.challenge.fields.map((prompt) => this.renderField(prompt))}
|
||||
${this.renderNonFieldErrors()} ${this.renderContinue()}
|
||||
</form>
|
||||
</ak-flow-card>`;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#elements/forms/FormElement";
|
||||
import "#flow/FormStatic";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export class LibraryPage extends AKElement {
|
||||
render() {
|
||||
return html`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
|
||||
<div class="pf-c-content header">
|
||||
<h1 role="heading" aria-level="1" id="library-page-title">
|
||||
<h1 role="heading" aria-level="1" data-test-id="page-heading">
|
||||
${msg("My applications")}
|
||||
</h1>
|
||||
${this.uiConfig.searchEnabled ? this.renderSearch() : nothing}
|
||||
@@ -193,3 +193,13 @@ export class LibraryPage extends AKElement {
|
||||
</main>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PageTestIDMap {
|
||||
heading: HTMLHeadingElement;
|
||||
}
|
||||
|
||||
interface TestIDSelectorMap {
|
||||
page: PageTestIDMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,17 +27,16 @@ export class UserSettingsPromptStage extends PromptStage {
|
||||
}
|
||||
|
||||
renderField(prompt: StagePrompt): TemplateResult {
|
||||
const errors = (this.challenge?.responseErrors || {})[prompt.fieldKey];
|
||||
const errors = this.challenge?.responseErrors?.[prompt.fieldKey];
|
||||
|
||||
if (this.shouldRenderInWrapper(prompt)) {
|
||||
return html`
|
||||
<ak-form-element-horizontal
|
||||
label=${msg(str`${prompt.label}`)}
|
||||
?required=${prompt.required}
|
||||
name=${prompt.fieldKey}
|
||||
?invalid=${errors !== undefined}
|
||||
.errorMessages=${(errors || []).map((error) => {
|
||||
return error.string;
|
||||
})}
|
||||
?invalid=${!!errors}
|
||||
.errorMessages=${errors}
|
||||
>
|
||||
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
217
web/test/browser/providers.test.ts
Normal file
217
web/test/browser/providers.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { createRandomName } from "#e2e/utils/generators";
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
import { series } from "@goauthentik/core/promises";
|
||||
|
||||
test.describe("Provider Wizard", () => {
|
||||
const providerNames = new Map<string, string>();
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
test.beforeEach("Configure Providers", async ({ page, session }, { testId }) => {
|
||||
const seed = IDGenerator.randomID(6);
|
||||
const providerName = `${createRandomName({ seed })} (${seed})`;
|
||||
|
||||
providerNames.set(testId, providerName);
|
||||
|
||||
const wizard = page.getByRole("dialog", { name: "New provider" });
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({
|
||||
to: "/if/admin/#/core/providers",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Navigate to provider wizard", async () => {
|
||||
await expect(wizard, "Wizard is initially closed").toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "New Provider" }).click();
|
||||
|
||||
await expect(wizard, "Wizard opens after clicking on New Provider").toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("listbox", { name: "Select a provider type" }),
|
||||
"Wizard opens with a list of provider types",
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
wizard.getByRole("navigation").getByRole("button", {
|
||||
name: /next|finish/i,
|
||||
}),
|
||||
"Wizard can't be navigated to next step",
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach("Verification", async ({ page }, { testId }) => {
|
||||
//#region Confirm provider
|
||||
|
||||
const providerName = providerNames.get(testId)!;
|
||||
|
||||
const $provider = await test.step("Find provider via search", async () => {
|
||||
const searchInput = page.getByRole("search").getByPlaceholder("Search for providers");
|
||||
|
||||
await searchInput.fill(providerName);
|
||||
|
||||
// We have to wait for the provider to appear in the table,
|
||||
// but several UI elements will be rendered asynchronously.
|
||||
// We attempt several times to find the provider to avoid flakiness.
|
||||
|
||||
const tries = 10;
|
||||
let found = false;
|
||||
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await searchInput.press("Enter");
|
||||
await searchInput.blur();
|
||||
|
||||
const $rowEntry = page.getByRole("row", {
|
||||
name: providerName,
|
||||
});
|
||||
|
||||
ConsoleLogger.info(
|
||||
`${i + 1}/${tries} Waiting for provider ${providerName} to appear in the table`,
|
||||
);
|
||||
|
||||
found = await $rowEntry
|
||||
.waitFor({
|
||||
timeout: 1500,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (found) {
|
||||
ConsoleLogger.info(`Provider ${providerName} found in the table`);
|
||||
return $rowEntry;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Provider ${providerName} not found in the table`);
|
||||
});
|
||||
|
||||
await expect($provider, "Provider is visible").toBeVisible();
|
||||
|
||||
//#endregion
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region OAuth2
|
||||
|
||||
test("Simple OAuth2 Provider", async ({ form, pointer }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
const { fill, selectSearchValue } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await series(
|
||||
[click, "OAuth2/OpenID", "option"],
|
||||
[click, "Next"],
|
||||
[fill, "Provider name", providerName],
|
||||
[
|
||||
selectSearchValue,
|
||||
"Authorization flow",
|
||||
/default-provider-authorization-explicit-consent/,
|
||||
],
|
||||
[click, "Finish"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Complete OAuth2 Provider", async ({ page, form, pointer }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
|
||||
const { fill, selectSearchValue, setFormGroup, setRadio, setInputCheck } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
const $clientSecretInput = page.getByRole("textbox", { name: "Client Secret" });
|
||||
|
||||
await series(
|
||||
[click, "OAuth2/OpenID", "option"],
|
||||
[click, "Next"],
|
||||
[fill, "Provider name", providerName],
|
||||
[
|
||||
selectSearchValue,
|
||||
"Authorization flow",
|
||||
/default-provider-authorization-explicit-consent/,
|
||||
],
|
||||
[setFormGroup, "Protocol settings", true],
|
||||
[setRadio, "Client Type", "Public"],
|
||||
[
|
||||
expect(
|
||||
$clientSecretInput,
|
||||
"Client Secret should be hidden when Client Type is Public",
|
||||
).toBeHidden,
|
||||
],
|
||||
[setRadio, "Client Type", "Confidential"],
|
||||
[
|
||||
expect(
|
||||
$clientSecretInput,
|
||||
"Client Secret should be visible when Client Type is Confidential",
|
||||
).toBeVisible,
|
||||
],
|
||||
[selectSearchValue, "Signing Key", /authentik Self-signed Certificate/],
|
||||
[selectSearchValue, "Encryption Key", /authentik Self-signed Certificate/],
|
||||
[setFormGroup, "Advanced flow settings", true],
|
||||
[selectSearchValue, "Authentication flow", /default-source-authentication/],
|
||||
[selectSearchValue, "Invalidation flow", /default-invalidation-flow/],
|
||||
[setFormGroup, "Advanced protocol settings", true],
|
||||
[fill, "Access code validity", "minutes=2"],
|
||||
[fill, "Access token validity", "minutes=10"],
|
||||
[fill, "Refresh token validity", "days=40"],
|
||||
[setInputCheck, "Include claims in id_token", false],
|
||||
[setRadio, "Subject mode", "Based on the User's username"],
|
||||
[setRadio, "Issuer mode", "Same identifier is used for all providers"],
|
||||
[setFormGroup, "Machine-to-Machine authentication settings", true],
|
||||
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
|
||||
);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region LDAP
|
||||
|
||||
test("Complete LDAP Provider", async ({ page, pointer, form }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
const { fill, setFormGroup, selectSearchValue, setInputCheck, setRadio } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await series(
|
||||
[click, "LDAP", "option"],
|
||||
[click, "Next"],
|
||||
|
||||
[fill, "Provider name", providerName],
|
||||
[setFormGroup, "Flow settings", true],
|
||||
[setFormGroup, "Protocol settings", true],
|
||||
[selectSearchValue, "Bind flow", /default-authentication-flow/],
|
||||
[fill, "Base DN", "DC=ldap-2,DC=goauthentik,DC=io"],
|
||||
[selectSearchValue, "Certificate", /authentik Self-signed Certificate/],
|
||||
[fill, "TLS Server name", "goauthentik.io"],
|
||||
[fill, "UID start number", "2001"],
|
||||
[fill, "GID start number", "4001"],
|
||||
[setRadio, "Search mode", "Direct querying"],
|
||||
[setRadio, "Bind mode", "Direct binding"],
|
||||
[setInputCheck, "MFA Support", false],
|
||||
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
|
||||
);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region RADIUS
|
||||
|
||||
test("Complete RADIUS Provider", async ({ page, pointer, form }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
const { fill, selectSearchValue } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await series(
|
||||
[click, "RADIUS", "option"],
|
||||
[click, "Next"],
|
||||
[fill, "Provider name", providerName],
|
||||
[selectSearchValue, "Authentication flow", /default-authentication-flow/],
|
||||
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
|
||||
);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
});
|
||||
33
web/test/browser/session.test.ts
Normal file
33
web/test/browser/session.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import {
|
||||
BAD_PASSWORD,
|
||||
BAD_USERNAME,
|
||||
GOOD_PASSWORD,
|
||||
GOOD_USERNAME,
|
||||
} from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
test.beforeEach(async ({ session }) => {
|
||||
await session.toLoginPage();
|
||||
});
|
||||
|
||||
test.describe("Session management", () => {
|
||||
test("Login with valid credentials", async ({ session, $ }) => {
|
||||
await session.login({ username: GOOD_USERNAME, password: GOOD_PASSWORD });
|
||||
|
||||
await $.page.heading.expect.toHaveText("My applications");
|
||||
});
|
||||
|
||||
test("Reject bad username", async ({ session }) => {
|
||||
await session.login({ username: BAD_USERNAME, password: GOOD_PASSWORD });
|
||||
|
||||
await expect(session.$authFailureMessage).toBeVisible();
|
||||
await expect(session.$authFailureMessage).toHaveText("Invalid password");
|
||||
});
|
||||
|
||||
test("Reject bad password", async ({ session }) => {
|
||||
await session.login({ username: GOOD_USERNAME, password: BAD_PASSWORD });
|
||||
|
||||
await expect(session.$authFailureMessage).toBeVisible();
|
||||
await expect(session.$authFailureMessage).toHaveText("Invalid password");
|
||||
});
|
||||
});
|
||||
87
web/test/lit/rendering.js
Normal file
87
web/test/lit/rendering.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Vitest browser utilities for Lit.
|
||||
*
|
||||
* @import { LocatorSelectors } from '@vitest/browser/context'
|
||||
* @import { PrettyDOMOptions } from '@vitest/browser/utils'
|
||||
* @import { RenderOptions as LitRenderOptions } from 'lit'
|
||||
*/
|
||||
|
||||
import { debug, getElementLocatorSelectors } from "@vitest/browser/utils";
|
||||
|
||||
import { render as renderLit } from "lit";
|
||||
|
||||
/**
|
||||
* @implements {Disposable}
|
||||
*/
|
||||
export class LitViteContext {
|
||||
/**
|
||||
* @type {Set<Disposable>}
|
||||
*/
|
||||
static #resources = new Set();
|
||||
|
||||
/**
|
||||
* @param {unknown} template
|
||||
* @param {HTMLElement} [container]
|
||||
* @param {LitRenderOptions} [options]
|
||||
*
|
||||
* @returns {LitViteContext}
|
||||
*/
|
||||
static render = (template, container = document.createElement("div"), options) => {
|
||||
const context = new LitViteContext(container);
|
||||
context.render(template, options);
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
static [Symbol.dispose] = () => {
|
||||
this.#resources.forEach((resource) => resource[Symbol.dispose]());
|
||||
this.#resources.clear();
|
||||
};
|
||||
|
||||
static cleanup = () => {
|
||||
return this[Symbol.dispose]();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {unknown} template
|
||||
* @param {LitRenderOptions} [options]
|
||||
*/
|
||||
render(template, options) {
|
||||
return renderLit(template, this.container, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {HTMLElement} container
|
||||
*/
|
||||
container;
|
||||
|
||||
/**
|
||||
* @type {LocatorSelectors}
|
||||
*/
|
||||
$;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.$ = getElementLocatorSelectors(container);
|
||||
}
|
||||
|
||||
toFragment() {
|
||||
return document.createRange().createContextualFragment(this.container.innerHTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [maxLength]
|
||||
* @param {PrettyDOMOptions} [options]
|
||||
*/
|
||||
debug(maxLength, options) {
|
||||
return debug(this.container, maxLength, options);
|
||||
}
|
||||
|
||||
[Symbol.dispose] = () => {
|
||||
this.container.remove();
|
||||
LitViteContext.#resources.delete(this);
|
||||
};
|
||||
}
|
||||
12
web/test/lit/setup.js
Normal file
12
web/test/lit/setup.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LitViteContext } from "./rendering.js";
|
||||
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
page.extend({
|
||||
// @ts-ignore
|
||||
renderLit: LitViteContext.render,
|
||||
[Symbol.for("vitest:component-cleanup")]: LitViteContext.cleanup,
|
||||
});
|
||||
|
||||
beforeEach(() => LitViteContext.cleanup());
|
||||
9
web/test/sum.unit.test.ts
Normal file
9
web/test/sum.unit.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
function sum(a: number, b: number) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
test("adds 1 + 2 to equal 3", () => {
|
||||
expect(sum(1, 2)).toBe(3);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts", "./tests"]
|
||||
"exclude": [
|
||||
// ---
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.comp.ts",
|
||||
"./**/*.stories.ts",
|
||||
"./tests"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
// @file TSConfig used during tests.
|
||||
// @file TSConfig used by the web package during build.
|
||||
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "webdriverio/async", "@wdio/cucumber-framework", "expect-webdriverio"],
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"experimentalDecorators": true,
|
||||
"lib": [
|
||||
"ES5",
|
||||
"ES2015",
|
||||
"ES2016",
|
||||
"ES2017",
|
||||
"ES2018",
|
||||
"ES2019",
|
||||
"ES2020",
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"WebWorker"
|
||||
]
|
||||
}
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
|
||||
23
web/types/node.d.ts
vendored
23
web/types/node.d.ts
vendored
@@ -14,12 +14,13 @@ declare module "module" {
|
||||
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
|
||||
* ```
|
||||
*/
|
||||
|
||||
var __dirname: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "process" {
|
||||
import { Level } from "pino";
|
||||
|
||||
global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
@@ -30,6 +31,26 @@ declare module "process" {
|
||||
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
|
||||
*/
|
||||
readonly NODE_ENV?: "development" | "production";
|
||||
|
||||
/**
|
||||
* Whether or not we are running on a CI server.
|
||||
*/
|
||||
readonly CI?: string;
|
||||
|
||||
/**
|
||||
* The application log level.
|
||||
*/
|
||||
readonly AK_LOG_LEVEL?: Level;
|
||||
|
||||
/**
|
||||
* The base URL of web server to run the tests against.
|
||||
*
|
||||
* Typically this is `http://localhost:9000`.
|
||||
*
|
||||
* @format url
|
||||
*/
|
||||
readonly AK_TEST_RUNNER_PAGE_URL?: string;
|
||||
|
||||
/**
|
||||
* @todo Determine where this is used and if it is needed,
|
||||
* give it a better name.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import { createBundleDefinitions } from "#bundler/utils/node";
|
||||
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
|
||||
|
||||
@@ -9,4 +11,41 @@ export default defineConfig({
|
||||
// ---
|
||||
inlineCSSPlugin(),
|
||||
],
|
||||
test: {
|
||||
dir: "./test",
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/out/**",
|
||||
"**/.{idea,git,cache,output,temp}/**",
|
||||
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
include: ["./unit/**/*.{test,spec}.ts", "**/*.unit.{test,spec}.ts"],
|
||||
name: "unit",
|
||||
environment: "node",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
setupFiles: ["./test/lit/setup.js"],
|
||||
|
||||
include: ["./browser/**/*.{test,spec}.ts", "**/*.browser.{test,spec}.ts"],
|
||||
name: "browser",
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: "playwright",
|
||||
|
||||
instances: [
|
||||
{
|
||||
browser: "chromium",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user