Compare commits
9 Commits
web/sideba
...
5165-passw
Author | SHA1 | Date | |
---|---|---|---|
5b9bb12822 | |||
8f09955d58 | |||
465820b002 | |||
a75c9434d9 | |||
4ea9b69ab5 | |||
c48eee0ebf | |||
0d94373f10 | |||
1c85dc512f | |||
a71778651f |
@ -3,6 +3,15 @@
|
||||
This is the default UI for the authentik server. The documentation is going to be a little sparse
|
||||
for awhile, but at least let's get started.
|
||||
|
||||
# Standards
|
||||
|
||||
- Be flexible in what you accept as input, be precise in what you produce as output.
|
||||
- Mis-use is always a crash. A component that takes the ID of an HTMLInputElement as an argument
|
||||
should throw an exception if the element is anything but an HTMLInputElement ("anything" includes
|
||||
non-existent, null, undefined, etc.).
|
||||
- Single Responsibility is ideal, but not always practical. To the best of your obility, every
|
||||
object in the system should do one thing and do it well.
|
||||
|
||||
# The Theory of the authentik UI
|
||||
|
||||
In Peter Naur's 1985 essay [Programming as Theory
|
||||
@ -107,3 +116,7 @@ settings in JSON files, which do not support comments.
|
||||
- `compilerOptions.plugins.ts-lit-plugin.rules.no-incompatible-type-binding: "warn"`: lit-analyzer
|
||||
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
|
||||
too many errors to be supportable.
|
||||
- `package.json`
|
||||
- `prettier` should always be the last thing run in any pre-commit pass. The `precommit` script
|
||||
does this, but if you don't use `precommit`, make sure `prettier` is the _last_ thing you do
|
||||
before a `git commit`.
|
||||
|
6667
web/package-lock.json
generated
6667
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,7 @@
|
||||
"build-proxy": "run-s build-locales esbuild:build-proxy",
|
||||
"watch": "run-s build-locales esbuild:watch",
|
||||
"lint": "cross-env NODE_OPTIONS='--max_old_space_size=65536' eslint . --max-warnings 0 --fix",
|
||||
"lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=65536' node scripts/eslint-precommit.mjs",
|
||||
"lint:precommit": "bun scripts/eslint-precommit.mjs",
|
||||
"lint:spelling": "node scripts/check-spelling.mjs",
|
||||
"lit-analyse": "lit-analyzer src",
|
||||
"precommit": "npm-run-all --parallel tsc lit-analyse lint:spelling --sequential lint:precommit prettier",
|
||||
@ -65,7 +65,8 @@
|
||||
"style-mod": "^4.1.2",
|
||||
"ts-pattern": "^5.1.2",
|
||||
"webcomponent-qr-code": "^1.2.0",
|
||||
"yaml": "^2.4.5"
|
||||
"yaml": "^2.4.5",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.7",
|
||||
@ -94,6 +95,7 @@
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/guacamole-common-js": "1.5.2",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"@types/zxcvbn": "^4.4.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
|
@ -17,6 +17,7 @@ import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import "@goauthentik/elements/router/RouterOutlet";
|
||||
import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants";
|
||||
import { eventActionLabels } from "@goauthentik/common/labels";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import {
|
||||
@ -8,63 +7,17 @@ import {
|
||||
WithCapabilitiesConfig,
|
||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
||||
import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import {
|
||||
SidebarAttributes,
|
||||
SidebarEntry,
|
||||
SidebarEventHandler,
|
||||
} from "@goauthentik/elements/sidebar/types";
|
||||
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { TemplateResult, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { map } from "lit/directives/map.js";
|
||||
|
||||
import { AdminApi } from "@goauthentik/api";
|
||||
import { CoreApi, Version } from "@goauthentik/api";
|
||||
import { AdminApi, CoreApi, UiThemeEnum, Version } from "@goauthentik/api";
|
||||
import type { SessionUser, UserSelf } from "@goauthentik/api";
|
||||
|
||||
import { flowDesignationTable } from "../flows/utils";
|
||||
import ConnectionTypesController from "./SidebarEntries/ConnectionTypesController";
|
||||
import PolicyTypesController from "./SidebarEntries/PolicyTypesController";
|
||||
import PropertyMappingsController from "./SidebarEntries/PropertyMappingsController";
|
||||
import ProviderTypesController from "./SidebarEntries/ProviderTypesController";
|
||||
import SourceTypesController from "./SidebarEntries/SourceTypesController";
|
||||
import StageTypesController from "./SidebarEntries/StageTypesController";
|
||||
|
||||
/**
|
||||
* AdminSidebar
|
||||
*
|
||||
* The AdminSidebar has two responsibilities:
|
||||
*
|
||||
* 1. Control the styling of the sidebar host, specifically when to show it and whether to show
|
||||
* it as an overlay or as a push.
|
||||
* 2. Control what content the sidebar will receive. The sidebar takes a tree, maximally three deep,
|
||||
* of type SidebarEventHandler.
|
||||
*/
|
||||
|
||||
type SidebarUrl = string;
|
||||
|
||||
export type LocalSidebarEntry = [
|
||||
// - null: This entry is not a link.
|
||||
// - string: the url for the entry
|
||||
// - SidebarEventHandler: a function to run if the entry is clicked.
|
||||
SidebarUrl | SidebarEventHandler | null,
|
||||
// The visible text of the entry.
|
||||
string,
|
||||
// Attributes to which the sidebar responds. See the sidebar for details.
|
||||
(SidebarAttributes | string[] | null)?, // eslint-disable-line
|
||||
// Children of the entry
|
||||
LocalSidebarEntry[]?,
|
||||
];
|
||||
|
||||
const localToSidebarEntry = (l: LocalSidebarEntry): SidebarEntry => ({
|
||||
path: l[0],
|
||||
label: l[1],
|
||||
...(l[2] ? { attributes: Array.isArray(l[2]) ? { activeWhen: l[2] } : l[2] } : {}),
|
||||
...(l[3] ? { children: l[3].map(localToSidebarEntry) } : {}),
|
||||
});
|
||||
|
||||
@customElement("ak-admin-sidebar")
|
||||
export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
@ -76,13 +29,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
||||
@state()
|
||||
impersonation: UserSelf["username"] | null = null;
|
||||
|
||||
private connectionTypes = new ConnectionTypesController(this);
|
||||
private policyTypes = new PolicyTypesController(this);
|
||||
private propertyMapper = new PropertyMappingsController(this);
|
||||
private providerTypes = new ProviderTypesController(this);
|
||||
private sourceTypes = new SourceTypesController(this);
|
||||
private stageTypes = new StageTypesController(this);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => {
|
||||
@ -128,6 +74,19 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<ak-sidebar
|
||||
class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this
|
||||
.activeTheme === UiThemeEnum.Light
|
||||
? "pf-m-light"
|
||||
: ""}"
|
||||
>
|
||||
${this.renderSidebarItems()}
|
||||
</ak-sidebar>
|
||||
`;
|
||||
}
|
||||
|
||||
updated() {
|
||||
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
|
||||
// scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
|
||||
@ -138,86 +97,118 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
||||
this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
|
||||
}
|
||||
|
||||
get sidebarItems(): SidebarEntry[] {
|
||||
renderSidebarItems(): TemplateResult {
|
||||
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
|
||||
// commonplace and singular enough to merit its own handler.
|
||||
type SidebarEntry = [
|
||||
path: string | null,
|
||||
label: string,
|
||||
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
|
||||
children?: SidebarEntry[],
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const sidebarContent: SidebarEntry[] = [
|
||||
["/if/user/", msg("User interface"), { "?isAbsoluteLink": true, "?highlight": true }],
|
||||
[null, msg("Dashboards"), { "?expanded": true }, [
|
||||
["/administration/overview", msg("Overview")],
|
||||
["/administration/dashboard/users", msg("User Statistics")],
|
||||
["/administration/system-tasks", msg("System Tasks")]]],
|
||||
[null, msg("Applications"), null, [
|
||||
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
|
||||
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
|
||||
["/outpost/outposts", msg("Outposts")]]],
|
||||
[null, msg("Events"), null, [
|
||||
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
|
||||
["/events/rules", msg("Notification Rules")],
|
||||
["/events/transports", msg("Notification Transports")]]],
|
||||
[null, msg("Customization"), null, [
|
||||
["/policy/policies", msg("Policies")],
|
||||
["/core/property-mappings", msg("Property Mappings")],
|
||||
["/blueprints/instances", msg("Blueprints")],
|
||||
["/policy/reputation", msg("Reputation scores")]]],
|
||||
[null, msg("Flows and Stages"), null, [
|
||||
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
|
||||
["/flow/stages", msg("Stages")],
|
||||
["/flow/stages/prompts", msg("Prompts")]]],
|
||||
[null, msg("Directory"), null, [
|
||||
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
|
||||
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
|
||||
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
|
||||
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
|
||||
["/core/tokens", msg("Tokens and App passwords")],
|
||||
["/flow/stages/invitations", msg("Invitations")]]],
|
||||
[null, msg("System"), null, [
|
||||
["/core/brands", msg("Brands")],
|
||||
["/crypto/certificates", msg("Certificates")],
|
||||
["/outpost/integrations", msg("Outpost Integrations")],
|
||||
["/admin/settings", msg("Settings")]]],
|
||||
];
|
||||
|
||||
// Typescript requires the type here to correctly type the recursive path
|
||||
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
|
||||
|
||||
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
|
||||
const properties = Array.isArray(attributes)
|
||||
? { ".activeWhen": attributes }
|
||||
: attributes ?? {};
|
||||
if (path) {
|
||||
properties["path"] = path;
|
||||
}
|
||||
return html`<ak-sidebar-item ${spread(properties)}>
|
||||
${label ? html`<span slot="label">${label}</span>` : nothing}
|
||||
${map(children, renderOneSidebarItem)}
|
||||
</ak-sidebar-item>`;
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
return html`
|
||||
${this.renderNewVersionMessage()}
|
||||
${this.renderImpersonationMessage()}
|
||||
${map(sidebarContent, renderOneSidebarItem)}
|
||||
${this.renderEnterpriseMenu()}
|
||||
`;
|
||||
}
|
||||
|
||||
renderNewVersionMessage() {
|
||||
return this.version && this.version !== VERSION
|
||||
? html`
|
||||
<ak-sidebar-item ?highlight=${true}>
|
||||
<span slot="label"
|
||||
>${msg("A newer version of the frontend is available.")}</span
|
||||
>
|
||||
</ak-sidebar-item>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
renderImpersonationMessage() {
|
||||
const reload = () =>
|
||||
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// prettier-ignore
|
||||
const newVersionMessage: LocalSidebarEntry[] =
|
||||
this.version && this.version !== VERSION
|
||||
? [[ "https://goauthentik.io", msg("A newer version of the frontend is available."),
|
||||
{ highlight: true }]]
|
||||
: [];
|
||||
|
||||
// prettier-ignore
|
||||
const impersonationMessage: LocalSidebarEntry[] = this.impersonation
|
||||
? [[reload, msg(str`You're currently impersonating ${this.impersonation}. Click to stop.`)]]
|
||||
: [];
|
||||
|
||||
// prettier-ignore
|
||||
const enterpriseMenu: LocalSidebarEntry[] = this.can(CapabilitiesEnum.IsEnterprise)
|
||||
? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]]
|
||||
: [];
|
||||
|
||||
const flowTypes: LocalSidebarEntry[] = flowDesignationTable.map(([_designation, label]) => [
|
||||
`/flow/flows;${encodeURIComponent(JSON.stringify({ search: label }))}`,
|
||||
label,
|
||||
]);
|
||||
|
||||
const eventTypes: LocalSidebarEntry[] = eventActionLabels.map(([_action, label]) => [
|
||||
`/events/log;${encodeURIComponent(JSON.stringify({ search: label }))}`,
|
||||
label,
|
||||
]);
|
||||
|
||||
// prettier-ignore
|
||||
const localSidebar: LocalSidebarEntry[] = [
|
||||
...(newVersionMessage),
|
||||
...(impersonationMessage),
|
||||
["/if/user/", msg("User interface"), { isAbsoluteLink: true, highlight: true }],
|
||||
[null, msg("Dashboards"), { expanded: true }, [
|
||||
["/administration/overview", msg("Overview")],
|
||||
["/administration/dashboard/users", msg("User Statistics")],
|
||||
["/administration/system-tasks", msg("System Tasks")]]],
|
||||
[null, msg("Applications"), null, [
|
||||
["/core/applications", msg("Applications"), [`^/core/applications(/(?<slug>${SLUG_REGEX}))?$`]],
|
||||
["/core/providers", msg("Providers"), [`^/core/providers(/(?<id>${ID_REGEX}))?$`], this.providerTypes.entries()],
|
||||
["/outpost/outposts", msg("Outposts")]]],
|
||||
[null, msg("Events"), null, [
|
||||
["/events/log", msg("Logs"), [`^/events/log(/(?<id>${UUID_REGEX}))?$`], eventTypes],
|
||||
["/events/rules", msg("Notification Rules")],
|
||||
["/events/transports", msg("Notification Transports")]]],
|
||||
[null, msg("Customisation"), null, [
|
||||
["/policy/policies", msg("Policies"), null, this.policyTypes.entries()],
|
||||
["/core/property-mappings", msg("Property Mappings"), null, this.propertyMapper.entries()],
|
||||
["/blueprints/instances", msg("Blueprints")],
|
||||
["/policy/reputation", msg("Reputation scores")]]],
|
||||
[null, msg("Flows and Stages"), null, [
|
||||
["/flow/flows", msg("Flows"), [`^/flow/flows(/(?<slug>${SLUG_REGEX}))?$`], flowTypes],
|
||||
["/flow/stages", msg("Stages"), null, this.stageTypes.entries()],
|
||||
["/flow/stages/prompts", msg("Prompts")]]],
|
||||
[null, msg("Directory"), null, [
|
||||
["/identity/users", msg("Users"), [`^/identity/users(/(?<id>${ID_REGEX}))?$`]],
|
||||
["/identity/groups", msg("Groups"), [`^/identity/groups(/(?<id>${UUID_REGEX}))?$`]],
|
||||
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
|
||||
["/core/sources", msg("Federation and Social login"), [`^/core/sources(/(?<slug>${SLUG_REGEX}))?$`], this.sourceTypes.entries()],
|
||||
["/core/tokens", msg("Tokens and App passwords")],
|
||||
["/flow/stages/invitations", msg("Invitations")]]],
|
||||
[null, msg("System"), null, [
|
||||
["/core/brands", msg("Brands")],
|
||||
["/crypto/certificates", msg("Certificates")],
|
||||
["/outpost/integrations", msg("Outpost Integrations"), null, this.connectionTypes.entries()],
|
||||
["/admin/settings", msg("Settings")]]],
|
||||
...(enterpriseMenu)
|
||||
];
|
||||
|
||||
return localSidebar.map(localToSidebarEntry);
|
||||
return this.impersonation
|
||||
? html`<ak-sidebar-item ?highlight=${true} @click=${reload}>
|
||||
<span slot="label"
|
||||
>${msg(
|
||||
str`You're currently impersonating ${this.impersonation}. Click to stop.`,
|
||||
)}</span
|
||||
>
|
||||
</ak-sidebar-item>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar>
|
||||
`;
|
||||
renderEnterpriseMenu() {
|
||||
return this.can(CapabilitiesEnum.IsEnterprise)
|
||||
? html`
|
||||
<ak-sidebar-item>
|
||||
<span slot="label">${msg("Enterprise")}</span>
|
||||
<ak-sidebar-item path="/enterprise/licenses">
|
||||
<span slot="label">${msg("Licenses")}</span>
|
||||
</ak-sidebar-item>
|
||||
</ak-sidebar-item>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
|
||||
import { OutpostsApi } from "@goauthentik/api";
|
||||
|
||||
import { createTypesController } from "./GenericTypesController";
|
||||
|
||||
export const ConnectionTypesController = createTypesController(
|
||||
() => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(),
|
||||
"/outpost/integrations",
|
||||
);
|
||||
|
||||
export default ConnectionTypesController;
|
@ -1,55 +0,0 @@
|
||||
import { ReactiveControllerHost } from "lit";
|
||||
|
||||
import { TypeCreate } from "@goauthentik/api";
|
||||
|
||||
import { LocalSidebarEntry } from "../AdminSidebar";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Fetcher = () => Promise<TypeCreate[]>;
|
||||
|
||||
const typeCreateToSidebar = (baseUrl: string, tcreate: TypeCreate[]): LocalSidebarEntry[] =>
|
||||
tcreate.map((t) => [
|
||||
`${baseUrl};${encodeURIComponent(JSON.stringify({ search: t.name }))}`,
|
||||
t.name,
|
||||
]);
|
||||
|
||||
/**
|
||||
* createTypesController
|
||||
*
|
||||
* The Sidebar accesses a number objects of `TypeCreate`, which all have the exact same type, just
|
||||
* different accessors for generating the lists and different paths to which they respond. This
|
||||
* function is a template for a (simple) reactive controller that fetches the data for that type on
|
||||
* construction, then informs the host that the data is available.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO (2023-11-17): This function is unlikely to survive in this form. It would be nice if it were more
|
||||
* generic, able to take a converter that can handle more that TypeCreate[] as its inbound argument,
|
||||
* since we need to refine what's displayed and on what the search is conducted.
|
||||
*
|
||||
*/
|
||||
|
||||
export function createTypesController(
|
||||
fetch: Fetcher,
|
||||
path: string,
|
||||
converter = typeCreateToSidebar,
|
||||
) {
|
||||
return class GenericTypesController {
|
||||
createTypes: TypeCreate[] = [];
|
||||
host: ReactiveControllerHost;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
fetch().then((types) => {
|
||||
this.createTypes = types;
|
||||
host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
entries(): LocalSidebarEntry[] {
|
||||
return converter(path, this.createTypes);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default createTypesController;
|
@ -1,12 +0,0 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
|
||||
import { PoliciesApi } from "@goauthentik/api";
|
||||
|
||||
import { createTypesController } from "./GenericTypesController";
|
||||
|
||||
export const PolicyTypesController = createTypesController(
|
||||
() => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(),
|
||||
"/policy/policies",
|
||||
);
|
||||
|
||||
export default PolicyTypesController;
|
@ -1,12 +0,0 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
|
||||
import { PropertymappingsApi } from "@goauthentik/api";
|
||||
|
||||
import { createTypesController } from "./GenericTypesController";
|
||||
|
||||
export const PropertyMappingsController = createTypesController(
|
||||
() => new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList(),
|
||||
"/core/property-mappings",
|
||||
);
|
||||
|
||||
export default PropertyMappingsController;
|
@ -1,12 +0,0 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
|
||||
import { ProvidersApi } from "@goauthentik/api";
|
||||
|
||||
import { createTypesController } from "./GenericTypesController";
|
||||
|
||||
export const ProviderTypesController = createTypesController(
|
||||
() => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(),
|
||||
"/core/providers",
|
||||
);
|
||||
|
||||
export default ProviderTypesController;
|
@ -1,12 +0,0 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
|
||||
import { SourcesApi } from "@goauthentik/api";
|
||||
|
||||
import { createTypesController } from "./GenericTypesController";
|
||||
|
||||
export const SourceTypesController = createTypesController(
|
||||
() => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(),
|
||||
"/core/sources",
|
||||
);
|
||||
|
||||
export default SourceTypesController;
|
@ -1,12 +0,0 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
|
||||
import { StagesApi } from "@goauthentik/api";
|
||||
|
||||
import { createTypesController } from "./GenericTypesController";
|
||||
|
||||
export const StageTypesController = createTypesController(
|
||||
() => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(),
|
||||
"/flow/stages",
|
||||
);
|
||||
|
||||
export default StageTypesController;
|
@ -6,33 +6,40 @@ export function RenderFlowOption(flow: Flow): string {
|
||||
return `${flow.slug} (${flow.name})`;
|
||||
}
|
||||
|
||||
type FlowDesignationPair = [FlowDesignationEnum, string];
|
||||
|
||||
export const flowDesignationTable: FlowDesignationPair[] = [
|
||||
[FlowDesignationEnum.Authentication, msg("Authentication")],
|
||||
[FlowDesignationEnum.Authorization, msg("Authorization")],
|
||||
[FlowDesignationEnum.Enrollment, msg("Enrollment")],
|
||||
[FlowDesignationEnum.Invalidation, msg("Invalidation")],
|
||||
[FlowDesignationEnum.Recovery, msg("Recovery")],
|
||||
[FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")],
|
||||
[FlowDesignationEnum.Unenrollment, msg("Unenrollment")],
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const flowDesignations = new Map(flowDesignationTable);
|
||||
|
||||
export function DesignationToLabel(designation: FlowDesignationEnum): string {
|
||||
return flowDesignations.get(designation) ?? msg("Unknown designation");
|
||||
switch (designation) {
|
||||
case FlowDesignationEnum.Authentication:
|
||||
return msg("Authentication");
|
||||
case FlowDesignationEnum.Authorization:
|
||||
return msg("Authorization");
|
||||
case FlowDesignationEnum.Enrollment:
|
||||
return msg("Enrollment");
|
||||
case FlowDesignationEnum.Invalidation:
|
||||
return msg("Invalidation");
|
||||
case FlowDesignationEnum.Recovery:
|
||||
return msg("Recovery");
|
||||
case FlowDesignationEnum.StageConfiguration:
|
||||
return msg("Stage Configuration");
|
||||
case FlowDesignationEnum.Unenrollment:
|
||||
return msg("Unenrollment");
|
||||
case FlowDesignationEnum.UnknownDefaultOpenApi:
|
||||
return msg("Unknown designation");
|
||||
}
|
||||
}
|
||||
|
||||
const layoutToLabel = new Map<FlowLayoutEnum, string>([
|
||||
[FlowLayoutEnum.Stacked, msg("Stacked")],
|
||||
[FlowLayoutEnum.ContentLeft, msg("Content left")],
|
||||
[FlowLayoutEnum.ContentRight, msg("Content right")],
|
||||
[FlowLayoutEnum.SidebarLeft, msg("Sidebar left")],
|
||||
[FlowLayoutEnum.SidebarRight, msg("Sidebar right")],
|
||||
]);
|
||||
|
||||
export function LayoutToLabel(layout: FlowLayoutEnum): string {
|
||||
return layoutToLabel.get(layout) ?? msg("Unknown layout");
|
||||
switch (layout) {
|
||||
case FlowLayoutEnum.Stacked:
|
||||
return msg("Stacked");
|
||||
case FlowLayoutEnum.ContentLeft:
|
||||
return msg("Content left");
|
||||
case FlowLayoutEnum.ContentRight:
|
||||
return msg("Content right");
|
||||
case FlowLayoutEnum.SidebarLeft:
|
||||
return msg("Sidebar left");
|
||||
case FlowLayoutEnum.SidebarRight:
|
||||
return msg("Sidebar right");
|
||||
case FlowLayoutEnum.UnknownDefaultOpenApi:
|
||||
return msg("Unknown layout");
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,6 @@ import { msg } from "@lit/localize";
|
||||
|
||||
import { Device, EventActions, IntentEnum, SeverityEnum, UserTypeEnum } from "@goauthentik/api";
|
||||
|
||||
type Pair<T> = [T, string];
|
||||
|
||||
/* Various tables in the API for which we need to supply labels */
|
||||
|
||||
export const intentEnumToLabel = new Map<IntentEnum, string>([
|
||||
@ -16,7 +14,7 @@ export const intentEnumToLabel = new Map<IntentEnum, string>([
|
||||
|
||||
export const intentToLabel = (intent: IntentEnum) => intentEnumToLabel.get(intent);
|
||||
|
||||
export const eventActionLabels: Pair<EventActions>[] = [
|
||||
export const eventActionToLabel = new Map<EventActions | undefined, string>([
|
||||
[EventActions.Login, msg("Login")],
|
||||
[EventActions.LoginFailed, msg("Failed login")],
|
||||
[EventActions.Logout, msg("Logout")],
|
||||
@ -45,9 +43,7 @@ export const eventActionLabels: Pair<EventActions>[] = [
|
||||
[EventActions.ModelDeleted, msg("Model deleted")],
|
||||
[EventActions.EmailSent, msg("Email sent")],
|
||||
[EventActions.UpdateAvailable, msg("Update available")],
|
||||
];
|
||||
|
||||
export const eventActionToLabel = new Map<EventActions | undefined, string>(eventActionLabels);
|
||||
]);
|
||||
|
||||
export const actionToLabel = (action?: EventActions): string =>
|
||||
eventActionToLabel.get(action) ?? action ?? "";
|
||||
|
@ -3,7 +3,7 @@ import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { Task, TaskStatus } from "@lit/task";
|
||||
import { Task, TaskStatus, initialState } from "@lit/task";
|
||||
import { css, html } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
@ -67,7 +67,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
||||
this.onError = this.onError.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.actionTask = new Task(this, {
|
||||
task: () => this.callAction(),
|
||||
task: () => this.runCallAction(),
|
||||
args: () => [],
|
||||
autoRun: false,
|
||||
onComplete: (r: unknown) => this.onSuccess(r),
|
||||
@ -77,7 +77,6 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
||||
|
||||
onComplete() {
|
||||
setTimeout(() => {
|
||||
this.actionTask.status = TaskStatus.INITIAL;
|
||||
this.dispatchCustomEvent(`${this.eventPrefix}-reset`);
|
||||
this.requestUpdate();
|
||||
}, SPINNER_TIMEOUT);
|
||||
@ -97,10 +96,12 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
||||
this.onComplete();
|
||||
}
|
||||
|
||||
async runCallAction() {
|
||||
await this.callAction();
|
||||
return initialState;
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.actionTask.status !== TaskStatus.INITIAL) {
|
||||
return;
|
||||
}
|
||||
this.dispatchCustomEvent(`${this.eventPrefix}-click`);
|
||||
this.actionTask.run();
|
||||
}
|
||||
@ -113,7 +114,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
||||
return [
|
||||
...this.classList,
|
||||
StatusMap.get(this.actionTask.status),
|
||||
this.actionTask.status === TaskStatus.INITIAL ? "" : "working",
|
||||
this.actionTask.status === TaskStatus.PENDING ? "working" : "",
|
||||
]
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
5
web/src/elements/password-match-indicator/index.ts
Normal file
5
web/src/elements/password-match-indicator/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import PasswordMatchIndicator from "./password-match-indicator.js";
|
||||
|
||||
export { PasswordMatchIndicator };
|
||||
|
||||
export default PasswordMatchIndicator;
|
@ -0,0 +1,19 @@
|
||||
import { html } from "lit";
|
||||
|
||||
import ".";
|
||||
|
||||
export default {
|
||||
title: "Elements/Password Match Indicator",
|
||||
};
|
||||
|
||||
export const Primary = () =>
|
||||
html`<div style="background: #fff; padding: 4em">
|
||||
<p>Type some text: <input id="primary-example" style="color:#000" /></p>
|
||||
<p style="margin-top:0.5em">
|
||||
Type some other text: <input id="primary-example_repeat" style="color:#000" />
|
||||
<ak-password-match-indicator
|
||||
first="#primary-example"
|
||||
second="#primary-example_repeat"
|
||||
></ak-password-match-indicator>
|
||||
</p>
|
||||
</div>`;
|
@ -0,0 +1,94 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import findInput from "../password-strength-indicator/findInput.js";
|
||||
|
||||
const ELEMENT = "ak-password-match-indicator";
|
||||
|
||||
@customElement(ELEMENT)
|
||||
export class PasswordMatchIndicator extends AKElement {
|
||||
static styles = [
|
||||
PFBase,
|
||||
css`
|
||||
:host {
|
||||
display: grid;
|
||||
place-items: center center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/**
|
||||
* A valid selector for the first input element to observe. Attaching this to anything other
|
||||
* than an HTMLInputElement will throw an exception.
|
||||
*/
|
||||
@property({ attribute: true })
|
||||
first = "";
|
||||
|
||||
/**
|
||||
* A valid selector for the second input element to observe. Attaching this to anything other
|
||||
* than an HTMLInputElement will throw an exception.
|
||||
*/
|
||||
@property({ attribute: true })
|
||||
second = "";
|
||||
|
||||
firstElement?: HTMLInputElement;
|
||||
|
||||
secondElement?: HTMLInputElement;
|
||||
|
||||
@state()
|
||||
match = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.checkPasswordMatch = this.checkPasswordMatch.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.firstInput.addEventListener("keyup", this.checkPasswordMatch);
|
||||
this.secondInput.addEventListener("keyup", this.checkPasswordMatch);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.secondInput.removeEventListener("keyup", this.checkPasswordMatch);
|
||||
this.firstInput.removeEventListener("keyup", this.checkPasswordMatch);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
checkPasswordMatch() {
|
||||
this.match =
|
||||
this.firstInput.value.length > 0 &&
|
||||
this.secondInput.value.length > 0 &&
|
||||
this.firstInput.value === this.secondInput.value;
|
||||
}
|
||||
|
||||
get firstInput() {
|
||||
if (this.firstElement) {
|
||||
return this.firstElement;
|
||||
}
|
||||
return (this.firstElement = findInput(this.getRootNode() as Element, ELEMENT, this.first));
|
||||
}
|
||||
|
||||
get secondInput() {
|
||||
if (this.secondElement) {
|
||||
return this.secondElement;
|
||||
}
|
||||
return (this.secondElement = findInput(
|
||||
this.getRootNode() as Element,
|
||||
ELEMENT,
|
||||
this.second,
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.match
|
||||
? html`<i class="pf-icon pf-icon-ok pf-m-success"></i>`
|
||||
: html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordMatchIndicator;
|
18
web/src/elements/password-strength-indicator/findInput.ts
Normal file
18
web/src/elements/password-strength-indicator/findInput.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export function findInput(root: Element, tag: string, src: string) {
|
||||
const inputs = Array.from(root.querySelectorAll(src));
|
||||
if (inputs.length === 0) {
|
||||
throw new Error(`${tag}: no element found for 'src' ${src}`);
|
||||
}
|
||||
if (inputs.length > 1) {
|
||||
throw new Error(`${tag}: more than one element found for 'src' ${src}`);
|
||||
}
|
||||
const input = inputs[0];
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error(
|
||||
`${tag}: the 'src' element must be an <input> tag, found ${input.localName}`,
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export default findInput;
|
5
web/src/elements/password-strength-indicator/index.ts
Normal file
5
web/src/elements/password-strength-indicator/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import PasswordStrengthIndicator from "./password-strength-indicator.js";
|
||||
|
||||
export { PasswordStrengthIndicator };
|
||||
|
||||
export default PasswordStrengthIndicator;
|
@ -0,0 +1,13 @@
|
||||
import { html } from "lit";
|
||||
|
||||
import ".";
|
||||
|
||||
export default {
|
||||
title: "Elements/Password Strength Indicator",
|
||||
};
|
||||
|
||||
export const Primary = () =>
|
||||
html`<div style="background: #fff; padding: 4em">
|
||||
<p>Type some text: <input id="primary-example" style="color:#000" /></p>
|
||||
<ak-password-strength-indicator src="#primary-example"></ak-password-strength-indicator>
|
||||
</div>`;
|
@ -0,0 +1,91 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
import { css, html } from "lit";
|
||||
import { styleMap } from "lit-html/directives/style-map.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
import findInput from "./findInput";
|
||||
|
||||
const styles = css`
|
||||
.password-meter-wrap {
|
||||
margin-top: 5px;
|
||||
height: 0.5em;
|
||||
background-color: #ddd;
|
||||
border-radius: 0.25em;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.password-meter-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
transition: width 400ms ease-in;
|
||||
}
|
||||
`;
|
||||
|
||||
const LEVELS = [
|
||||
["20%", "#dd0000"],
|
||||
["40%", "#ff5500"],
|
||||
["60%", "#ffff00"],
|
||||
["80%", "#a1a841"],
|
||||
["100%", "#339933"],
|
||||
].map(([width, backgroundColor]) => ({ width, backgroundColor }));
|
||||
|
||||
/**
|
||||
* A simple display of the password strength.
|
||||
*/
|
||||
|
||||
const ELEMENT = "ak-password-strength-indicator";
|
||||
|
||||
@customElement(ELEMENT)
|
||||
export class PasswordStrengthIndicator extends AKElement {
|
||||
static styles = styles;
|
||||
|
||||
/**
|
||||
* The input element to observe. Attaching this to anything other than an HTMLInputElement will
|
||||
* throw an exception.
|
||||
*/
|
||||
@property({ attribute: true })
|
||||
src = "";
|
||||
|
||||
sourceInput?: HTMLInputElement;
|
||||
|
||||
@state()
|
||||
strength = LEVELS[0];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.checkPasswordStrength = this.checkPasswordStrength.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.input.addEventListener("keyup", this.checkPasswordStrength);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.input.removeEventListener("keyup", this.checkPasswordStrength);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
checkPasswordStrength() {
|
||||
const { score } = zxcvbn(this.input.value);
|
||||
this.strength = LEVELS[score];
|
||||
}
|
||||
|
||||
get input(): HTMLInputElement {
|
||||
if (this.sourceInput) {
|
||||
return this.sourceInput;
|
||||
}
|
||||
return (this.sourceInput = findInput(this.getRootNode() as Element, ELEMENT, this.src));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <div class="password-meter-wrap">
|
||||
<div class="password-meter-bar" style=${styleMap(this.strength)}></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordStrengthIndicator;
|
@ -1,56 +0,0 @@
|
||||
import { css } from "lit";
|
||||
|
||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export const sidebarStyles = [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFNav,
|
||||
css`
|
||||
:host {
|
||||
z-index: 100;
|
||||
}
|
||||
.pf-c-nav__link.pf-m-current::after,
|
||||
.pf-c-nav__link.pf-m-current:hover::after,
|
||||
.pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after {
|
||||
--pf-c-nav__link--m-current--after--BorderColor: #fd4b2d;
|
||||
}
|
||||
:host([theme="light"]) {
|
||||
border-right-color: transparent !important;
|
||||
}
|
||||
|
||||
.pf-c-nav__section + .pf-c-nav__section {
|
||||
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
||||
}
|
||||
.pf-c-nav__list .sidebar-brand {
|
||||
max-height: 82px;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
ak-sidebar-items {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pf-c-nav__link {
|
||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||
}
|
||||
.pf-c-nav__section-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
.pf-c-nav__item {
|
||||
--pf-c-nav__item--MarginTop: 0px;
|
||||
}
|
||||
`,
|
||||
];
|
@ -1,32 +1,79 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/sidebar/SidebarBrand";
|
||||
import "@goauthentik/elements/sidebar/SidebarItems";
|
||||
import "@goauthentik/elements/sidebar/SidebarUser";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import { sidebarStyles } from "./Sidebar.css.js";
|
||||
import type { SidebarEntry } from "./types";
|
||||
|
||||
@customElement("ak-sidebar")
|
||||
export class Sidebar extends AKElement {
|
||||
@property({ type: Array })
|
||||
entries: SidebarEntry[] = [];
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFNav,
|
||||
css`
|
||||
:host {
|
||||
z-index: 100;
|
||||
}
|
||||
.pf-c-nav__link.pf-m-current::after,
|
||||
.pf-c-nav__link.pf-m-current:hover::after,
|
||||
.pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after {
|
||||
--pf-c-nav__link--m-current--after--BorderColor: #fd4b2d;
|
||||
}
|
||||
:host([theme="light"]) {
|
||||
border-right-color: transparent !important;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return sidebarStyles;
|
||||
.pf-c-nav__section + .pf-c-nav__section {
|
||||
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
||||
}
|
||||
.pf-c-nav__list .sidebar-brand {
|
||||
max-height: 82px;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.pf-c-nav__list {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pf-c-nav__link {
|
||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||
}
|
||||
.pf-c-nav__section-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
.pf-c-nav__item {
|
||||
--pf-c-nav__item--MarginTop: 0px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): TemplateResult {
|
||||
return html`<nav
|
||||
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
||||
aria-label="Global"
|
||||
>
|
||||
<ak-sidebar-brand></ak-sidebar-brand>
|
||||
<ak-sidebar-items .entries=${this.entries}></ak-sidebar-items>
|
||||
<ul class="pf-c-nav__list">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
<ak-sidebar-user></ak-sidebar-user>
|
||||
</nav>`;
|
||||
}
|
||||
|
@ -1,86 +0,0 @@
|
||||
import { css } from "lit";
|
||||
|
||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export const sidebarItemStyles = [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFNav,
|
||||
css`
|
||||
:host {
|
||||
z-index: 100;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background-color: var(--ak-accent);
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.highlighted .pf-c-nav__link {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.pf-c-nav__link.pf-m-current::after,
|
||||
.pf-c-nav__link.pf-m-current:hover::after,
|
||||
.pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after {
|
||||
--pf-c-nav__link--m-current--after--BorderColor: #fd4b2d;
|
||||
}
|
||||
|
||||
.pf-c-nav__item .pf-c-nav__item::before {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.pf-c-nav__section + .pf-c-nav__section {
|
||||
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
||||
}
|
||||
.pf-c-nav__list .sidebar-brand {
|
||||
max-height: 82px;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
.pf-c-nav__toggle {
|
||||
width: calc(var(--pf-c-nav__toggle--FontSize) + calc(2 * var(--pf-global--spacer--md)));
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.pf-c-nav__list {
|
||||
flex: 1 0 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pf-c-nav__link {
|
||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pf-c-nav__link a {
|
||||
flex: 1 0 max-content;
|
||||
color: var(--pf-c-nav__link--Color);
|
||||
}
|
||||
|
||||
a.pf-c-nav__link:hover {
|
||||
color: var(--pf-c-nav__link--Color);
|
||||
text-decoration: var(--pf-global--link--TextDecoration--hover);
|
||||
}
|
||||
|
||||
.pf-c-nav__section-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
.pf-c-nav__item {
|
||||
--pf-c-nav__item--MarginTop: 0px;
|
||||
}
|
||||
|
||||
.pf-c-nav__toggle-icon {
|
||||
padding: var(--pf-global--spacer--sm) var(--pf-global--spacer--md);
|
||||
}
|
||||
`,
|
||||
];
|
@ -1,247 +0,0 @@
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { findTable } from "@goauthentik/elements/table/TablePage";
|
||||
|
||||
import { TemplateResult, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { map } from "lit/directives/map.js";
|
||||
|
||||
import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import { sidebarItemStyles } from "./SidebarItems.css.js";
|
||||
import type { SidebarEntry } from "./types";
|
||||
import { entryKey, findMatchForNavbarUrl, makeParentMap } from "./utils";
|
||||
|
||||
/**
|
||||
* Display the sidebar item tree.
|
||||
*
|
||||
* Along with the `reclick()` complaint down below, the other thing I dislike about this design is
|
||||
* that it's effectively two different programs glued together. The first responds to the `click`
|
||||
* and performs the navigation, which either triggers the router or triggers a new search on the
|
||||
* existing view. The second responds to the navigation change event when the URL is changed by the
|
||||
* navigation event, at which point it figures out which entry to highlight as "current," which
|
||||
* causes the re-render.
|
||||
*/
|
||||
|
||||
@customElement("ak-sidebar-items")
|
||||
export class SidebarItems extends AKElement {
|
||||
static get styles() {
|
||||
return sidebarItemStyles;
|
||||
}
|
||||
|
||||
@property({ type: Array })
|
||||
entries: SidebarEntry[] = [];
|
||||
|
||||
expanded: Set<string> = new Set();
|
||||
|
||||
@state()
|
||||
current = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.renderItem = this.renderItem.bind(this);
|
||||
this.toggleExpand = this.toggleExpand.bind(this);
|
||||
this.onHashChange = this.onHashChange.bind(this);
|
||||
this.reclick = this.reclick.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.onHashChange();
|
||||
window.addEventListener("hashchange", this.onHashChange);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("hashchange", this.onHashChange);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
expandParents(entry: SidebarEntry) {
|
||||
const reverseMap = makeParentMap(this.entries);
|
||||
let start: SidebarEntry | undefined = reverseMap.get(entry);
|
||||
while (start) {
|
||||
this.expanded.add(entryKey(start));
|
||||
start = reverseMap.get(start);
|
||||
}
|
||||
}
|
||||
|
||||
onHashChange() {
|
||||
this.current = "";
|
||||
const match = findMatchForNavbarUrl(this.entries);
|
||||
if (match) {
|
||||
this.current = entryKey(match);
|
||||
this.expandParents(match);
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpand(entry: SidebarEntry) {
|
||||
const key = entryKey(entry);
|
||||
if (this.expanded.has(key)) {
|
||||
this.expanded.delete(key);
|
||||
} else {
|
||||
this.expanded.add(key);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// This is gross and feels like 2007: using a path from the root through the shadowDoms (see
|
||||
// `TablePage:findTable()`), this code finds the element that *should* be triggered by an event
|
||||
// on the URL, and forcibly injects the text of the search and the click of the search button.
|
||||
|
||||
reclick(ev: Event, path: string) {
|
||||
const oldPath = window.location.hash.split(ROUTE_SEPARATOR)[0];
|
||||
const [curPath, ...curSearchComponents] = path.split(ROUTE_SEPARATOR);
|
||||
const curSearch: string =
|
||||
curSearchComponents.length > 0 ? curSearchComponents.join(ROUTE_SEPARATOR) : "";
|
||||
|
||||
if (curPath !== oldPath) {
|
||||
// A Tier 1 or Tier 2 change should be handled by the router. (So should a Tier 3
|
||||
// change, but... here we are.)
|
||||
return;
|
||||
}
|
||||
|
||||
const table = findTable();
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always wrap the minimal exceptional code possible in an IIFE and supply the failure
|
||||
// alternative. Turn exceptions into expressions with the smallest functional rewind
|
||||
// whenever possible.
|
||||
const search = (() => {
|
||||
try {
|
||||
return curSearch ? JSON.parse(decodeURIComponent(curSearch)) : { search: "" };
|
||||
} catch {
|
||||
return { search: "" };
|
||||
}
|
||||
})();
|
||||
|
||||
if ("search" in search) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
table.search = search.search;
|
||||
table.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
console.log("C:", this.current);
|
||||
const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light };
|
||||
|
||||
return html` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation">
|
||||
<ul class="pf-c-nav__list">
|
||||
${map(this.entries, this.renderItem)}
|
||||
</ul>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
renderItem(entry: SidebarEntry) {
|
||||
// Ensure the attributes are undefined, not null; they can be null in the placeholders, but
|
||||
// not when being forwarded to the correct renderer.
|
||||
const hasChildren = !!(entry.children && entry.children.length > 0);
|
||||
|
||||
// This is grossly imperative, in that it HAS to come before the content is rendered to make
|
||||
// sure the content gets the right settings with respect to expansion.
|
||||
if (entry.attributes?.expanded) {
|
||||
this.expanded.add(entryKey(entry));
|
||||
delete entry.attributes.expanded;
|
||||
}
|
||||
|
||||
const content =
|
||||
entry.path && hasChildren
|
||||
? this.renderLinkAndChildren(entry)
|
||||
: hasChildren
|
||||
? this.renderLabelAndChildren(entry)
|
||||
: entry.path
|
||||
? this.renderLink(entry)
|
||||
: this.renderLabel(entry);
|
||||
|
||||
const expanded = {
|
||||
"highlighted": !!entry.attributes?.highlight,
|
||||
"pf-m-expanded": this.expanded.has(entryKey(entry)),
|
||||
"pf-m-expandable": hasChildren,
|
||||
};
|
||||
|
||||
return html`<li class="pf-c-nav__item ${classMap(expanded)}">${content}</li>`;
|
||||
}
|
||||
|
||||
getLinkClasses(entry: SidebarEntry) {
|
||||
const a = entry.attributes ?? {};
|
||||
const key = entryKey(entry);
|
||||
return {
|
||||
"pf-m-current": key === this.current,
|
||||
"pf-c-nav__link": true,
|
||||
"highlight": !!(typeof a.highlight === "function" ? a.highlight() : a.highlight),
|
||||
};
|
||||
}
|
||||
|
||||
renderLabel(entry: SidebarEntry) {
|
||||
return html`<div class=${classMap(this.getLinkClasses(entry))}>${entry.label}</div>`;
|
||||
}
|
||||
|
||||
// note the responsibilities pushed up to the caller
|
||||
renderLink(entry: SidebarEntry) {
|
||||
if (typeof entry.path === "function") {
|
||||
return html` <a @click=${entry.path} class=${classMap(this.getLinkClasses(entry))}>
|
||||
${entry.label}
|
||||
</a>`;
|
||||
}
|
||||
const path = `${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}`;
|
||||
return html` <a
|
||||
href=${path}
|
||||
@click=${(ev: Event) => this.reclick(ev, path)}
|
||||
class=${classMap(this.getLinkClasses(entry))}
|
||||
>
|
||||
${entry.label}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
renderChildren(children: SidebarEntry[]) {
|
||||
return html`<section class="pf-c-nav__subnav">
|
||||
<ul class="pf-c-nav__list">
|
||||
${map(children, this.renderItem)}
|
||||
</ul>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
renderLabelAndChildren(entry: SidebarEntry): TemplateResult {
|
||||
const handler = () => this.toggleExpand(entry);
|
||||
const current = { "pf-m-current": this.current === entryKey(entry) };
|
||||
|
||||
return html` <div class="pf-c-nav__link ${classMap(current)}">
|
||||
<div class="ak-nav__link">${entry.label}</div>
|
||||
<span class="pf-c-nav__toggle" @click=${handler}>
|
||||
<span class="pf-c-nav__toggle-icon">
|
||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
${this.expanded.has(entryKey(entry))
|
||||
? this.renderChildren(entry.children ?? [])
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
renderLinkAndChildren(entry: SidebarEntry): TemplateResult {
|
||||
const handler = () => this.toggleExpand(entry);
|
||||
const current = { "pf-m-current": this.current === entryKey(entry) };
|
||||
const path = `${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}`;
|
||||
return html` <div class="pf-c-nav__link ${classMap(current)}">
|
||||
<a
|
||||
href=${path}
|
||||
@click=${(ev: Event) => this.reclick(ev, path)}
|
||||
class="ak-nav__link"
|
||||
>
|
||||
${entry.label}
|
||||
</a>
|
||||
<span class="pf-c-nav__toggle" @click=${handler}>
|
||||
<span class="pf-c-nav__toggle-icon">
|
||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
${this.expanded.has(entryKey(entry))
|
||||
? this.renderChildren(entry.children ?? [])
|
||||
: nothing}`;
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { TemplateResult } from "lit";
|
||||
|
||||
export type SidebarEventHandler = () => void;
|
||||
|
||||
export type SidebarAttributes = {
|
||||
isAbsoluteLink?: boolean | (() => boolean);
|
||||
highlight?: boolean | (() => boolean);
|
||||
expanded?: boolean | (() => boolean);
|
||||
activeWhen?: string[];
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export type SidebarEntry = {
|
||||
path: string | SidebarEventHandler | null;
|
||||
label: string;
|
||||
attributes?: SidebarAttributes | null; // eslint-disable-line
|
||||
children?: SidebarEntry[];
|
||||
};
|
||||
|
||||
// Typescript requires the type here to correctly type the recursive path
|
||||
export type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
|
@ -1,60 +0,0 @@
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||
|
||||
import { SidebarEntry } from "./types";
|
||||
|
||||
export function entryKey(entry: SidebarEntry) {
|
||||
return `${entry.path || "no-path"}:${entry.label}`;
|
||||
}
|
||||
|
||||
// "Never store what you can calculate." (At least, if it's cheap.)
|
||||
|
||||
/**
|
||||
* Takes tree and creates a map where every key is an entry in the tree and every value is that
|
||||
* entry's parent.
|
||||
*/
|
||||
|
||||
export function makeParentMap(entries: SidebarEntry[]) {
|
||||
const reverseMap = new WeakMap<SidebarEntry, SidebarEntry>();
|
||||
function reverse(entry: SidebarEntry) {
|
||||
(entry.children ?? []).forEach((e) => {
|
||||
reverseMap.set(e, entry);
|
||||
reverse(e);
|
||||
});
|
||||
}
|
||||
entries.forEach(reverse);
|
||||
return reverseMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the current path and the collection of entries, identify which entry is currently live.
|
||||
*
|
||||
*/
|
||||
|
||||
const trailingSlash = new RegExp("/$");
|
||||
const fixed = (s: string) => s.replace(trailingSlash, "");
|
||||
|
||||
function scanner(entry: SidebarEntry, activePath: string): SidebarEntry | undefined {
|
||||
if (typeof entry.path === "string" && fixed(activePath) === fixed(entry.path)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
for (const matcher of entry.attributes?.activeWhen ?? []) {
|
||||
const matchtest = new RegExp(matcher);
|
||||
if (matchtest.test(activePath)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return (entry.children ?? []).find((e) => scanner(e, activePath));
|
||||
}
|
||||
|
||||
export function findMatchForNavbarUrl(entries: SidebarEntry[]) {
|
||||
const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
|
||||
for (const entry of entries) {
|
||||
const result = scanner(entry, activePath);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
@ -20,11 +20,6 @@ export abstract class TablePage<T> extends Table<T> {
|
||||
return super.styles.concat(PFPage, PFContent, PFSidebar);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dataset.akApiTable = "true";
|
||||
}
|
||||
|
||||
renderSidebarBefore(): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
@ -97,18 +92,3 @@ export abstract class TablePage<T> extends Table<T> {
|
||||
${this.renderSectionAfter()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// This painstakingly researched path is nonetheless surprisingly robust; it works for every extant
|
||||
// TablePage, but only because Jens has been utterly consistent in where he puts his TablePage
|
||||
// elements with respect to the Interface object. If we ever re-arrange this code, we're going
|
||||
// to have to re-arrange this as well.
|
||||
|
||||
export function findTable<T, U extends TablePage<T>>(): U | undefined {
|
||||
return (
|
||||
(document.body
|
||||
?.querySelector("[data-ak-interface-root]")
|
||||
?.shadowRoot?.querySelector("ak-locale-context")
|
||||
?.querySelector("ak-router-outlet")
|
||||
?.shadowRoot?.querySelector("[data-ak-api-table]") as U) ?? undefined
|
||||
);
|
||||
}
|
||||
|
108
web/src/flow/stages/prompt/FieldRenderers.stories.ts
Normal file
108
web/src/flow/stages/prompt/FieldRenderers.stories.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import "@patternfly/patternfly/components/Button/button.css";
|
||||
import "@patternfly/patternfly/components/Check/check.css";
|
||||
import "@patternfly/patternfly/components/Form/form.css";
|
||||
import "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import "@patternfly/patternfly/components/Login/login.css";
|
||||
import "@patternfly/patternfly/components/Title/title.css";
|
||||
import "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { PromptTypeEnum } from "@goauthentik/api";
|
||||
import type { StagePrompt } from "@goauthentik/api";
|
||||
|
||||
import promptRenderers from "./FieldRenderers";
|
||||
import { renderContinue, renderPromptHelpText, renderPromptInner } from "./helpers";
|
||||
|
||||
// Storybook stories are meant to show not just that the objects work, but to document good
|
||||
// practices around using them. Because of their uniform signature, the renderers can easily
|
||||
// be encapsulated into containers that show them at their most functional, even without
|
||||
// building Shadow DOMs with which to do it. This is 100% Light DOM work, and they still
|
||||
// work well.
|
||||
|
||||
const baseRenderer = (prompt: TemplateResult) =>
|
||||
html`<div style="background: #fff; padding: 4em; max-width: 24em;">
|
||||
<style>
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button,
|
||||
.pf-c-form__helper-text:not(.pf-m-error),
|
||||
input + label.pf-c-check__label {
|
||||
color: #000;
|
||||
}
|
||||
input[readonly],
|
||||
textarea[readonly] {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
${prompt}
|
||||
</div>`;
|
||||
|
||||
function renderer(kind: PromptTypeEnum, prompt: Partial<StagePrompt>) {
|
||||
const renderer = promptRenderers.get(kind);
|
||||
if (!renderer) {
|
||||
throw new Error(`A renderer of type ${kind} does not exist.`);
|
||||
}
|
||||
return baseRenderer(html`${renderer(prompt as StagePrompt)}`);
|
||||
}
|
||||
|
||||
const textPrompt = {
|
||||
fieldKey: "test_text_field",
|
||||
placeholder: "This is the placeholder",
|
||||
required: false,
|
||||
initialValue: "initial value",
|
||||
};
|
||||
|
||||
export const Text = () => renderer(PromptTypeEnum.Text, textPrompt);
|
||||
export const TextArea = () => renderer(PromptTypeEnum.TextArea, textPrompt);
|
||||
export const TextReadOnly = () => renderer(PromptTypeEnum.TextReadOnly, textPrompt);
|
||||
export const TextAreaReadOnly = () => renderer(PromptTypeEnum.TextAreaReadOnly, textPrompt);
|
||||
export const Username = () => renderer(PromptTypeEnum.Username, textPrompt);
|
||||
export const Password = () => renderer(PromptTypeEnum.Password, textPrompt);
|
||||
|
||||
const emailPrompt = { ...textPrompt, initialValue: "example@example.fun" };
|
||||
export const Email = () => renderer(PromptTypeEnum.Email, emailPrompt);
|
||||
|
||||
const numberPrompt = { ...textPrompt, initialValue: "10" };
|
||||
export const Number = () => renderer(PromptTypeEnum.Number, numberPrompt);
|
||||
|
||||
const datePrompt = { ...textPrompt, initialValue: "2018-06-12T19:30" };
|
||||
export const Date = () => renderer(PromptTypeEnum.Date, datePrompt);
|
||||
export const DateTime = () => renderer(PromptTypeEnum.DateTime, datePrompt);
|
||||
|
||||
const separatorPrompt = { placeholder: "😊" };
|
||||
export const Separator = () => renderer(PromptTypeEnum.Separator, separatorPrompt);
|
||||
|
||||
const staticPrompt = { initialValue: "😊" };
|
||||
export const Static = () => renderer(PromptTypeEnum.Static, staticPrompt);
|
||||
|
||||
const choicePrompt = {
|
||||
fieldKey: "test_text_field",
|
||||
placeholder: "This is the placeholder",
|
||||
required: false,
|
||||
initialValue: "first",
|
||||
choices: ["first", "second", "third"],
|
||||
};
|
||||
|
||||
export const Dropdown = () => renderer(PromptTypeEnum.Dropdown, choicePrompt);
|
||||
export const RadioButtonGroup = () => renderer(PromptTypeEnum.RadioButtonGroup, choicePrompt);
|
||||
|
||||
const checkPrompt = { ...textPrompt, label: "Favorite Subtext?", subText: "(Xena & Gabrielle)" };
|
||||
export const Checkbox = () => renderer(PromptTypeEnum.Checkbox, checkPrompt);
|
||||
|
||||
const localePrompt = { ...textPrompt, initialValue: "en" };
|
||||
export const Locale = () => renderer(PromptTypeEnum.AkLocale, localePrompt);
|
||||
|
||||
export const PromptFailure = () =>
|
||||
baseRenderer(renderPromptInner({ type: null } as unknown as StagePrompt));
|
||||
|
||||
export const HelpText = () =>
|
||||
baseRenderer(renderPromptHelpText({ subText: "There is no subtext here." } as StagePrompt));
|
||||
|
||||
export const Continue = () => baseRenderer(renderContinue());
|
||||
|
||||
export default {
|
||||
title: "Flow Components/Field Renderers",
|
||||
};
|
271
web/src/flow/stages/prompt/FieldRenderers.ts
Normal file
271
web/src/flow/stages/prompt/FieldRenderers.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import { rootInterface } from "@goauthentik/elements/Base";
|
||||
import { LOCALES } from "@goauthentik/elements/ak-locale-context/helpers";
|
||||
import "@goauthentik/elements/password-match-indicator";
|
||||
import "@goauthentik/elements/password-strength-indicator";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { CapabilitiesEnum, PromptTypeEnum, StagePrompt } from "@goauthentik/api";
|
||||
|
||||
export function password(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="password"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="new-password"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
/><ak-password-strength-indicator
|
||||
src='input[name="${prompt.fieldKey}"]'
|
||||
></ak-password-strength-indicator>`;
|
||||
}
|
||||
|
||||
const REPEAT = /_repeat/;
|
||||
|
||||
export function repeatPassword(prompt: StagePrompt) {
|
||||
const first = `input[name="${prompt.fieldKey}"]`;
|
||||
const second = `input[name="${prompt.fieldKey.replace(REPEAT, "")}"]`;
|
||||
|
||||
return html` <div style="display:flex; flex-direction:row; gap: 0.5em; align-content: center">
|
||||
<input
|
||||
style="flex:1 0"
|
||||
type="password"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="new-password"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
/><ak-password-match-indicator
|
||||
first="${first}"
|
||||
second="${second}"
|
||||
></ak-password-match-indicator>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderPassword(prompt: StagePrompt) {
|
||||
return REPEAT.test(prompt.fieldKey) ? repeatPassword(prompt) : password(prompt);
|
||||
}
|
||||
|
||||
export function renderText(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderTextArea(prompt: StagePrompt) {
|
||||
return html`<textarea
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
>
|
||||
${prompt.initialValue}</textarea
|
||||
>`;
|
||||
}
|
||||
|
||||
export function renderTextReadOnly(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?readonly=${true}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderTextAreaReadOnly(prompt: StagePrompt) {
|
||||
return html`<textarea
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
>
|
||||
${prompt.initialValue}</textarea
|
||||
>`;
|
||||
}
|
||||
|
||||
export function renderUsername(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="username"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderEmail(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="email"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderNumber(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="number"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderDate(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="date"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderDateTime(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="datetime"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderFile(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="file"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderSeparator(prompt: StagePrompt) {
|
||||
return html`<ak-divider>${prompt.placeholder}</ak-divider>`;
|
||||
}
|
||||
|
||||
export function renderHidden(prompt: StagePrompt) {
|
||||
return html`<input
|
||||
type="hidden"
|
||||
name="${prompt.fieldKey}"
|
||||
value="${prompt.initialValue}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
/>`;
|
||||
}
|
||||
|
||||
export function renderStatic(prompt: StagePrompt) {
|
||||
return html`<p>${unsafeHTML(prompt.initialValue)}</p>`;
|
||||
}
|
||||
|
||||
export function renderDropdown(prompt: StagePrompt) {
|
||||
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
${prompt.choices?.map((choice) => {
|
||||
return html`<option value="${choice}" ?selected=${prompt.initialValue === choice}>
|
||||
${choice}
|
||||
</option>`;
|
||||
})}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
export function renderRadioButtonGroup(prompt: StagePrompt) {
|
||||
return html`${(prompt.choices || []).map((choice) => {
|
||||
const id = `${prompt.fieldKey}-${choice}`;
|
||||
return html`<div class="pf-c-check">
|
||||
<input
|
||||
type="radio"
|
||||
class="pf-c-check__input"
|
||||
name="${prompt.fieldKey}"
|
||||
id="${id}"
|
||||
?checked="${prompt.initialValue === choice}"
|
||||
?required="${prompt.required}"
|
||||
value="${choice}"
|
||||
/>
|
||||
<label class="pf-c-check__label" for=${id}>${choice}</label>
|
||||
</div> `;
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function renderCheckbox(prompt: StagePrompt) {
|
||||
return html`<div class="pf-c-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="pf-c-check__input"
|
||||
id="${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
?checked=${prompt.initialValue !== ""}
|
||||
?required=${prompt.required}
|
||||
/>
|
||||
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
|
||||
${prompt.required
|
||||
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
|
||||
: html``}
|
||||
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderAkLocale(prompt: StagePrompt) {
|
||||
// TODO: External reference.
|
||||
const inDebug = rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanDebug);
|
||||
const locales = inDebug ? LOCALES : LOCALES.filter((locale) => locale.code !== "debug");
|
||||
|
||||
const options = locales.map(
|
||||
(locale) =>
|
||||
html`<option value=${locale.code} ?selected=${locale.code === prompt.initialValue}>
|
||||
${locale.code.toUpperCase()} - ${locale.label()}
|
||||
</option> `,
|
||||
);
|
||||
|
||||
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
<option value="" ?selected=${prompt.initialValue === ""}>
|
||||
${msg("Auto-detect (based on your browser)")}
|
||||
</option>
|
||||
${options}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
type Renderer = (prompt: StagePrompt) => TemplateResult;
|
||||
|
||||
export const promptRenderers = new Map<PromptTypeEnum, Renderer>([
|
||||
[PromptTypeEnum.Text, renderText],
|
||||
[PromptTypeEnum.TextArea, renderTextArea],
|
||||
[PromptTypeEnum.TextReadOnly, renderTextReadOnly],
|
||||
[PromptTypeEnum.TextAreaReadOnly, renderTextAreaReadOnly],
|
||||
[PromptTypeEnum.Username, renderUsername],
|
||||
[PromptTypeEnum.Email, renderEmail],
|
||||
[PromptTypeEnum.Password, renderPassword],
|
||||
[PromptTypeEnum.Number, renderNumber],
|
||||
[PromptTypeEnum.Date, renderDate],
|
||||
[PromptTypeEnum.DateTime, renderDateTime],
|
||||
[PromptTypeEnum.File, renderFile],
|
||||
[PromptTypeEnum.Separator, renderSeparator],
|
||||
[PromptTypeEnum.Hidden, renderHidden],
|
||||
[PromptTypeEnum.Static, renderStatic],
|
||||
[PromptTypeEnum.Dropdown, renderDropdown],
|
||||
[PromptTypeEnum.RadioButtonGroup, renderRadioButtonGroup],
|
||||
[PromptTypeEnum.Checkbox, renderCheckbox],
|
||||
[PromptTypeEnum.AkLocale, renderAkLocale],
|
||||
]);
|
||||
|
||||
export default promptRenderers;
|
@ -1,17 +1,12 @@
|
||||
import "@goauthentik/elements/Divider";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import {
|
||||
CapabilitiesEnum,
|
||||
WithCapabilitiesConfig,
|
||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||
import { LOCALES } from "@goauthentik/elements/ak-locale-context/definitions";
|
||||
import { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
@ -29,6 +24,14 @@ import {
|
||||
StagePrompt,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { renderCheckbox } from "./FieldRenderers";
|
||||
import {
|
||||
renderContinue,
|
||||
renderPromptHelpText,
|
||||
renderPromptInner,
|
||||
shouldRenderInWrapper,
|
||||
} from "./helpers";
|
||||
|
||||
@customElement("ak-stage-prompt")
|
||||
export class PromptStage extends WithCapabilitiesConfig(
|
||||
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
|
||||
@ -53,232 +56,35 @@ export class PromptStage extends WithCapabilitiesConfig(
|
||||
];
|
||||
}
|
||||
|
||||
renderPromptInner(prompt: StagePrompt): TemplateResult {
|
||||
switch (prompt.type) {
|
||||
case PromptTypeEnum.Text:
|
||||
return html`<input
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.TextArea:
|
||||
return html`<textarea
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
>
|
||||
${prompt.initialValue}</textarea
|
||||
>`;
|
||||
case PromptTypeEnum.TextReadOnly:
|
||||
return html`<input
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?readonly=${true}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.TextAreaReadOnly:
|
||||
return html`<textarea
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
>
|
||||
${prompt.initialValue}</textarea
|
||||
>`;
|
||||
case PromptTypeEnum.Username:
|
||||
return html`<input
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="username"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.Email:
|
||||
return html`<input
|
||||
type="email"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.Password:
|
||||
return html`<input
|
||||
type="password"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="new-password"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
/>`;
|
||||
case PromptTypeEnum.Number:
|
||||
return html`<input
|
||||
type="number"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.Date:
|
||||
return html`<input
|
||||
type="date"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.DateTime:
|
||||
return html`<input
|
||||
type="datetime"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.File:
|
||||
return html`<input
|
||||
type="file"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${prompt.initialValue}"
|
||||
/>`;
|
||||
case PromptTypeEnum.Separator:
|
||||
return html`<ak-divider>${prompt.placeholder}</ak-divider>`;
|
||||
case PromptTypeEnum.Hidden:
|
||||
return html`<input
|
||||
type="hidden"
|
||||
name="${prompt.fieldKey}"
|
||||
value="${prompt.initialValue}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
/>`;
|
||||
case PromptTypeEnum.Static:
|
||||
return html`<p>${unsafeHTML(prompt.initialValue)}</p>`;
|
||||
case PromptTypeEnum.Dropdown:
|
||||
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
${prompt.choices?.map((choice) => {
|
||||
return html`<option
|
||||
value="${choice}"
|
||||
?selected=${prompt.initialValue === choice}
|
||||
>
|
||||
${choice}
|
||||
</option>`;
|
||||
})}
|
||||
</select>`;
|
||||
case PromptTypeEnum.RadioButtonGroup:
|
||||
return html`${(prompt.choices || []).map((choice) => {
|
||||
const id = `${prompt.fieldKey}-${choice}`;
|
||||
return html`<div class="pf-c-check">
|
||||
<input
|
||||
type="radio"
|
||||
class="pf-c-check__input"
|
||||
name="${prompt.fieldKey}"
|
||||
id="${id}"
|
||||
?checked="${prompt.initialValue === choice}"
|
||||
?required="${prompt.required}"
|
||||
value="${choice}"
|
||||
/>
|
||||
<label class="pf-c-check__label" for=${id}>${choice}</label>
|
||||
</div> `;
|
||||
})}`;
|
||||
case PromptTypeEnum.AkLocale: {
|
||||
const locales = this.can(CapabilitiesEnum.CanDebug)
|
||||
? LOCALES
|
||||
: LOCALES.filter((locale) => locale.code !== "debug");
|
||||
const options = locales.map(
|
||||
(locale) =>
|
||||
html`<option
|
||||
value=${locale.code}
|
||||
?selected=${locale.code === prompt.initialValue}
|
||||
>
|
||||
${locale.code.toUpperCase()} - ${locale.label()}
|
||||
</option> `,
|
||||
);
|
||||
/* TODO: Legacy: None of these refer to the `this` field. Static fields are a code smell. */
|
||||
|
||||
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
<option value="" ?selected=${prompt.initialValue === ""}>
|
||||
${msg("Auto-detect (based on your browser)")}
|
||||
</option>
|
||||
${options}
|
||||
</select>`;
|
||||
}
|
||||
default:
|
||||
return html`<p>invalid type '${prompt.type}'</p>`;
|
||||
}
|
||||
renderPromptInner(prompt: StagePrompt) {
|
||||
return renderPromptInner(prompt);
|
||||
}
|
||||
|
||||
renderPromptHelpText(prompt: StagePrompt): TemplateResult {
|
||||
if (prompt.subText === "") {
|
||||
return html``;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
||||
renderPromptHelpText(prompt: StagePrompt) {
|
||||
return renderPromptHelpText(prompt);
|
||||
}
|
||||
|
||||
shouldRenderInWrapper(prompt: StagePrompt): boolean {
|
||||
// Special types that aren't rendered in a wrapper
|
||||
if (
|
||||
prompt.type === PromptTypeEnum.Static ||
|
||||
prompt.type === PromptTypeEnum.Hidden ||
|
||||
prompt.type === PromptTypeEnum.Separator
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
shouldRenderInWrapper(prompt: StagePrompt) {
|
||||
return shouldRenderInWrapper(prompt);
|
||||
}
|
||||
|
||||
renderField(prompt: StagePrompt): TemplateResult {
|
||||
// Checkbox is rendered differently
|
||||
// Checkbox has a slightly different layout, so it must be intercepted early.
|
||||
if (prompt.type === PromptTypeEnum.Checkbox) {
|
||||
return html`<div class="pf-c-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="pf-c-check__input"
|
||||
id="${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
?checked=${prompt.initialValue !== ""}
|
||||
?required=${prompt.required}
|
||||
/>
|
||||
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
|
||||
${prompt.required
|
||||
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
|
||||
: html``}
|
||||
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
|
||||
</div>`;
|
||||
return renderCheckbox(prompt);
|
||||
}
|
||||
if (this.shouldRenderInWrapper(prompt)) {
|
||||
|
||||
if (shouldRenderInWrapper(prompt)) {
|
||||
return html`<ak-form-element
|
||||
label="${prompt.label}"
|
||||
?required="${prompt.required}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
|
||||
>
|
||||
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
|
||||
${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
|
||||
</ak-form-element>`;
|
||||
}
|
||||
return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`;
|
||||
}
|
||||
|
||||
renderContinue(): TemplateResult {
|
||||
return html` <div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${msg("Continue")}
|
||||
</button>
|
||||
</div>`;
|
||||
return html` ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
@ -286,6 +92,7 @@ ${prompt.initialValue}</textarea
|
||||
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
|
||||
</ak-empty-state>`;
|
||||
}
|
||||
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||
</header>
|
||||
@ -304,7 +111,7 @@ ${prompt.initialValue}</textarea
|
||||
this.challenge?.responseErrors?.non_field_errors || [],
|
||||
)
|
||||
: html``}
|
||||
${this.renderContinue()}
|
||||
${renderContinue()}
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
|
37
web/src/flow/stages/prompt/helpers.ts
Normal file
37
web/src/flow/stages/prompt/helpers.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { PromptTypeEnum, StagePrompt } from "@goauthentik/api";
|
||||
|
||||
import promptRenderers from "./FieldRenderers";
|
||||
|
||||
export function renderPromptInner(prompt: StagePrompt) {
|
||||
const renderer = promptRenderers.get(prompt.type);
|
||||
if (!renderer) {
|
||||
return html`<p>invalid type '${JSON.stringify(prompt.type, null, 2)}'</p>`;
|
||||
}
|
||||
return renderer(prompt);
|
||||
}
|
||||
|
||||
export function renderPromptHelpText(prompt: StagePrompt) {
|
||||
if (prompt.subText === "") {
|
||||
return html``;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
||||
}
|
||||
|
||||
export function shouldRenderInWrapper(prompt: StagePrompt) {
|
||||
// Special types that aren't rendered in a wrapper
|
||||
const specialTypes = [PromptTypeEnum.Static, PromptTypeEnum.Hidden, PromptTypeEnum.Separator];
|
||||
const special = specialTypes.find((s) => s === prompt.type);
|
||||
return !special;
|
||||
}
|
||||
|
||||
export function renderContinue() {
|
||||
return html` <div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${msg("Continue")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
Reference in New Issue
Block a user