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
|
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.
|
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
|
# The Theory of the authentik UI
|
||||||
|
|
||||||
In Peter Naur's 1985 essay [Programming as Theory
|
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
|
- `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
|
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
|
||||||
too many errors to be supportable.
|
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",
|
"build-proxy": "run-s build-locales esbuild:build-proxy",
|
||||||
"watch": "run-s build-locales esbuild:watch",
|
"watch": "run-s build-locales esbuild:watch",
|
||||||
"lint": "cross-env NODE_OPTIONS='--max_old_space_size=65536' eslint . --max-warnings 0 --fix",
|
"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",
|
"lint:spelling": "node scripts/check-spelling.mjs",
|
||||||
"lit-analyse": "lit-analyzer src",
|
"lit-analyse": "lit-analyzer src",
|
||||||
"precommit": "npm-run-all --parallel tsc lit-analyse lint:spelling --sequential lint:precommit prettier",
|
"precommit": "npm-run-all --parallel tsc lit-analyse lint:spelling --sequential lint:precommit prettier",
|
||||||
@ -65,7 +65,8 @@
|
|||||||
"style-mod": "^4.1.2",
|
"style-mod": "^4.1.2",
|
||||||
"ts-pattern": "^5.1.2",
|
"ts-pattern": "^5.1.2",
|
||||||
"webcomponent-qr-code": "^1.2.0",
|
"webcomponent-qr-code": "^1.2.0",
|
||||||
"yaml": "^2.4.5"
|
"yaml": "^2.4.5",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.7",
|
"@babel/core": "^7.24.7",
|
||||||
@ -94,6 +95,7 @@
|
|||||||
"@types/grecaptcha": "^3.0.9",
|
"@types/grecaptcha": "^3.0.9",
|
||||||
"@types/guacamole-common-js": "1.5.2",
|
"@types/guacamole-common-js": "1.5.2",
|
||||||
"@types/showdown": "^2.0.6",
|
"@types/showdown": "^2.0.6",
|
||||||
|
"@types/zxcvbn": "^4.4.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
"@typescript-eslint/parser": "^7.5.0",
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
"babel-plugin-macros": "^3.1.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 { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||||
import "@goauthentik/elements/router/RouterOutlet";
|
import "@goauthentik/elements/router/RouterOutlet";
|
||||||
import "@goauthentik/elements/sidebar/Sidebar";
|
import "@goauthentik/elements/sidebar/Sidebar";
|
||||||
|
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||||
|
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants";
|
import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants";
|
||||||
import { eventActionLabels } from "@goauthentik/common/labels";
|
|
||||||
import { me } from "@goauthentik/common/users";
|
import { me } from "@goauthentik/common/users";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import {
|
import {
|
||||||
@ -8,63 +7,17 @@ import {
|
|||||||
WithCapabilitiesConfig,
|
WithCapabilitiesConfig,
|
||||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||||
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
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 { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
|
||||||
|
import { spread } from "@open-wc/lit-helpers";
|
||||||
|
|
||||||
import { msg, str } from "@lit/localize";
|
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 { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { map } from "lit/directives/map.js";
|
||||||
|
|
||||||
import { AdminApi } from "@goauthentik/api";
|
import { AdminApi, CoreApi, UiThemeEnum, Version } from "@goauthentik/api";
|
||||||
import { CoreApi, Version } from "@goauthentik/api";
|
|
||||||
import type { SessionUser, UserSelf } 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")
|
@customElement("ak-admin-sidebar")
|
||||||
export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
||||||
@property({ type: Boolean, reflect: true })
|
@property({ type: Boolean, reflect: true })
|
||||||
@ -76,13 +29,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
|||||||
@state()
|
@state()
|
||||||
impersonation: UserSelf["username"] | null = null;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => {
|
new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => {
|
||||||
@ -128,6 +74,19 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
|
|||||||
super.disconnectedCallback();
|
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() {
|
updated() {
|
||||||
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
|
// 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
|
// 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");
|
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 = () =>
|
const reload = () =>
|
||||||
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
// prettier-ignore
|
return this.impersonation
|
||||||
const newVersionMessage: LocalSidebarEntry[] =
|
? html`<ak-sidebar-item ?highlight=${true} @click=${reload}>
|
||||||
this.version && this.version !== VERSION
|
<span slot="label"
|
||||||
? [[ "https://goauthentik.io", msg("A newer version of the frontend is available."),
|
>${msg(
|
||||||
{ highlight: true }]]
|
str`You're currently impersonating ${this.impersonation}. Click to stop.`,
|
||||||
: [];
|
)}</span
|
||||||
|
>
|
||||||
// prettier-ignore
|
</ak-sidebar-item>`
|
||||||
const impersonationMessage: LocalSidebarEntry[] = this.impersonation
|
: nothing;
|
||||||
? [[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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
renderEnterpriseMenu() {
|
||||||
return html`
|
return this.can(CapabilitiesEnum.IsEnterprise)
|
||||||
<ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar>
|
? 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})`;
|
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 {
|
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 {
|
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";
|
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 */
|
/* Various tables in the API for which we need to supply labels */
|
||||||
|
|
||||||
export const intentEnumToLabel = new Map<IntentEnum, string>([
|
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 intentToLabel = (intent: IntentEnum) => intentEnumToLabel.get(intent);
|
||||||
|
|
||||||
export const eventActionLabels: Pair<EventActions>[] = [
|
export const eventActionToLabel = new Map<EventActions | undefined, string>([
|
||||||
[EventActions.Login, msg("Login")],
|
[EventActions.Login, msg("Login")],
|
||||||
[EventActions.LoginFailed, msg("Failed login")],
|
[EventActions.LoginFailed, msg("Failed login")],
|
||||||
[EventActions.Logout, msg("Logout")],
|
[EventActions.Logout, msg("Logout")],
|
||||||
@ -45,9 +43,7 @@ export const eventActionLabels: Pair<EventActions>[] = [
|
|||||||
[EventActions.ModelDeleted, msg("Model deleted")],
|
[EventActions.ModelDeleted, msg("Model deleted")],
|
||||||
[EventActions.EmailSent, msg("Email sent")],
|
[EventActions.EmailSent, msg("Email sent")],
|
||||||
[EventActions.UpdateAvailable, msg("Update available")],
|
[EventActions.UpdateAvailable, msg("Update available")],
|
||||||
];
|
]);
|
||||||
|
|
||||||
export const eventActionToLabel = new Map<EventActions | undefined, string>(eventActionLabels);
|
|
||||||
|
|
||||||
export const actionToLabel = (action?: EventActions): string =>
|
export const actionToLabel = (action?: EventActions): string =>
|
||||||
eventActionToLabel.get(action) ?? action ?? "";
|
eventActionToLabel.get(action) ?? action ?? "";
|
||||||
|
@ -3,7 +3,7 @@ import { PFSize } from "@goauthentik/common/enums.js";
|
|||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
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 { css, html } from "lit";
|
||||||
import { property } from "lit/decorators.js";
|
import { property } from "lit/decorators.js";
|
||||||
|
|
||||||
@ -67,7 +67,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
|||||||
this.onError = this.onError.bind(this);
|
this.onError = this.onError.bind(this);
|
||||||
this.onClick = this.onClick.bind(this);
|
this.onClick = this.onClick.bind(this);
|
||||||
this.actionTask = new Task(this, {
|
this.actionTask = new Task(this, {
|
||||||
task: () => this.callAction(),
|
task: () => this.runCallAction(),
|
||||||
args: () => [],
|
args: () => [],
|
||||||
autoRun: false,
|
autoRun: false,
|
||||||
onComplete: (r: unknown) => this.onSuccess(r),
|
onComplete: (r: unknown) => this.onSuccess(r),
|
||||||
@ -77,7 +77,6 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
|||||||
|
|
||||||
onComplete() {
|
onComplete() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.actionTask.status = TaskStatus.INITIAL;
|
|
||||||
this.dispatchCustomEvent(`${this.eventPrefix}-reset`);
|
this.dispatchCustomEvent(`${this.eventPrefix}-reset`);
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}, SPINNER_TIMEOUT);
|
}, SPINNER_TIMEOUT);
|
||||||
@ -97,10 +96,12 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
|||||||
this.onComplete();
|
this.onComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runCallAction() {
|
||||||
|
await this.callAction();
|
||||||
|
return initialState;
|
||||||
|
}
|
||||||
|
|
||||||
onClick() {
|
onClick() {
|
||||||
if (this.actionTask.status !== TaskStatus.INITIAL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.dispatchCustomEvent(`${this.eventPrefix}-click`);
|
this.dispatchCustomEvent(`${this.eventPrefix}-click`);
|
||||||
this.actionTask.run();
|
this.actionTask.run();
|
||||||
}
|
}
|
||||||
@ -113,7 +114,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
|
|||||||
return [
|
return [
|
||||||
...this.classList,
|
...this.classList,
|
||||||
StatusMap.get(this.actionTask.status),
|
StatusMap.get(this.actionTask.status),
|
||||||
this.actionTask.status === TaskStatus.INITIAL ? "" : "working",
|
this.actionTask.status === TaskStatus.PENDING ? "working" : "",
|
||||||
]
|
]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.trim();
|
.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 { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/sidebar/SidebarBrand";
|
import "@goauthentik/elements/sidebar/SidebarBrand";
|
||||||
import "@goauthentik/elements/sidebar/SidebarItems";
|
|
||||||
import "@goauthentik/elements/sidebar/SidebarUser";
|
import "@goauthentik/elements/sidebar/SidebarUser";
|
||||||
|
|
||||||
import { html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
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 { UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
import { sidebarStyles } from "./Sidebar.css.js";
|
|
||||||
import type { SidebarEntry } from "./types";
|
|
||||||
|
|
||||||
@customElement("ak-sidebar")
|
@customElement("ak-sidebar")
|
||||||
export class Sidebar extends AKElement {
|
export class Sidebar extends AKElement {
|
||||||
@property({ type: Array })
|
static get styles(): CSSResult[] {
|
||||||
entries: SidebarEntry[] = [];
|
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() {
|
.pf-c-nav__section + .pf-c-nav__section {
|
||||||
return sidebarStyles;
|
--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
|
return html`<nav
|
||||||
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
||||||
aria-label="Global"
|
aria-label="Global"
|
||||||
>
|
>
|
||||||
<ak-sidebar-brand></ak-sidebar-brand>
|
<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>
|
<ak-sidebar-user></ak-sidebar-user>
|
||||||
</nav>`;
|
</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);
|
return super.styles.concat(PFPage, PFContent, PFSidebar);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.dataset.akApiTable = "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSidebarBefore(): TemplateResult {
|
renderSidebarBefore(): TemplateResult {
|
||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
@ -97,18 +92,3 @@ export abstract class TablePage<T> extends Table<T> {
|
|||||||
${this.renderSectionAfter()}`;
|
${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/Divider";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
import {
|
import { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||||
CapabilitiesEnum,
|
|
||||||
WithCapabilitiesConfig,
|
|
||||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
|
||||||
import { LOCALES } from "@goauthentik/elements/ak-locale-context/definitions";
|
|
||||||
import "@goauthentik/elements/forms/FormElement";
|
import "@goauthentik/elements/forms/FormElement";
|
||||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement } from "lit/decorators.js";
|
import { customElement } from "lit/decorators.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
||||||
|
|
||||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||||
@ -29,6 +24,14 @@ import {
|
|||||||
StagePrompt,
|
StagePrompt,
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
|
import { renderCheckbox } from "./FieldRenderers";
|
||||||
|
import {
|
||||||
|
renderContinue,
|
||||||
|
renderPromptHelpText,
|
||||||
|
renderPromptInner,
|
||||||
|
shouldRenderInWrapper,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
@customElement("ak-stage-prompt")
|
@customElement("ak-stage-prompt")
|
||||||
export class PromptStage extends WithCapabilitiesConfig(
|
export class PromptStage extends WithCapabilitiesConfig(
|
||||||
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
|
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
|
||||||
@ -53,232 +56,35 @@ export class PromptStage extends WithCapabilitiesConfig(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPromptInner(prompt: StagePrompt): TemplateResult {
|
/* TODO: Legacy: None of these refer to the `this` field. Static fields are a code smell. */
|
||||||
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> `,
|
|
||||||
);
|
|
||||||
|
|
||||||
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
renderPromptInner(prompt: StagePrompt) {
|
||||||
<option value="" ?selected=${prompt.initialValue === ""}>
|
return renderPromptInner(prompt);
|
||||||
${msg("Auto-detect (based on your browser)")}
|
|
||||||
</option>
|
|
||||||
${options}
|
|
||||||
</select>`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return html`<p>invalid type '${prompt.type}'</p>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
renderPromptHelpText(prompt: StagePrompt) {
|
||||||
renderPromptHelpText(prompt: StagePrompt): TemplateResult {
|
return renderPromptHelpText(prompt);
|
||||||
if (prompt.subText === "") {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
|
||||||
}
|
}
|
||||||
|
shouldRenderInWrapper(prompt: StagePrompt) {
|
||||||
shouldRenderInWrapper(prompt: StagePrompt): boolean {
|
return shouldRenderInWrapper(prompt);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderField(prompt: StagePrompt): TemplateResult {
|
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) {
|
if (prompt.type === PromptTypeEnum.Checkbox) {
|
||||||
return html`<div class="pf-c-check">
|
return renderCheckbox(prompt);
|
||||||
<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>`;
|
|
||||||
}
|
}
|
||||||
if (this.shouldRenderInWrapper(prompt)) {
|
|
||||||
|
if (shouldRenderInWrapper(prompt)) {
|
||||||
return html`<ak-form-element
|
return html`<ak-form-element
|
||||||
label="${prompt.label}"
|
label="${prompt.label}"
|
||||||
?required="${prompt.required}"
|
?required="${prompt.required}"
|
||||||
class="pf-c-form__group"
|
class="pf-c-form__group"
|
||||||
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
|
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
|
||||||
>
|
>
|
||||||
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
|
${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
|
||||||
</ak-form-element>`;
|
</ak-form-element>`;
|
||||||
}
|
}
|
||||||
return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`;
|
return html` ${renderPromptInner(prompt)} ${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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
@ -286,6 +92,7 @@ ${prompt.initialValue}</textarea
|
|||||||
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
|
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
|
||||||
</ak-empty-state>`;
|
</ak-empty-state>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<header class="pf-c-login__main-header">
|
return html`<header class="pf-c-login__main-header">
|
||||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||||
</header>
|
</header>
|
||||||
@ -304,7 +111,7 @@ ${prompt.initialValue}</textarea
|
|||||||
this.challenge?.responseErrors?.non_field_errors || [],
|
this.challenge?.responseErrors?.non_field_errors || [],
|
||||||
)
|
)
|
||||||
: html``}
|
: html``}
|
||||||
${this.renderContinue()}
|
${renderContinue()}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<footer class="pf-c-login__main-footer">
|
<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