Compare commits

..

9 Commits

Author SHA1 Message Date
5b9bb12822 web: confirmation prompt updated to the latest version 2024-06-13 09:43:05 -07:00
8f09955d58 Merge branch 'main' into 5165-password-strength-indicator
* main: (2701 commits)
  website/developer-docs: add a baby Style Guide (#9900)
  website/integrations: gitlab: update certificate key pair location and specify sha (#9925)
  root: handle asgi exception (#10085)
  website: bump prettier from 3.3.1 to 3.3.2 in /website (#10082)
  web: bump prettier from 3.3.1 to 3.3.2 in /web (#10081)
  core: bump google-api-python-client from 2.132.0 to 2.133.0 (#10083)
  web: bump prettier from 3.3.1 to 3.3.2 in /tests/wdio (#10079)
  web: bump chromedriver from 125.0.3 to 126.0.0 in /tests/wdio (#10078)
  web: bump @sentry/browser from 8.8.0 to 8.9.1 in /web in the sentry group (#10080)
  web: bump braces from 3.0.2 to 3.0.3 in /web (#10077)
  website: bump braces from 3.0.2 to 3.0.3 in /website (#10076)
  web: bump braces from 3.0.2 to 3.0.3 in /tests/wdio (#10075)
  core: bump azure-identity from 1.16.0 to 1.16.1 (#10071)
  rbac: filters: fix missing attribute for unauthenticated requests (#10061)
  tests/e2e: docker-compose.yml: remove version element forgotten last time (#10067)
  providers/microsoft_entra: fix error when updating connection attributes (#10039)
  website/integrations: aws: fix about service link (#10062)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in it (#10060)
  core: bump github.com/redis/go-redis/v9 from 9.5.2 to 9.5.3 (#10046)
  core: bump github.com/gorilla/websocket from 1.5.1 to 1.5.2 (#10047)
  ...
2024-06-13 08:17:33 -07:00
465820b002 Merge branch 'main' into 5165-password-strength-indicator
* main: (160 commits)
  website: update hackathon with prize pool (#6170)
  web: bump @babel/plugin-transform-runtime from 7.22.6 to 7.22.7 in /web (#6166)
  web: bump @babel/core from 7.22.6 to 7.22.7 in /web (#6165)
  web: bump @babel/plugin-proposal-decorators from 7.22.6 to 7.22.7 in /web (#6167)
  web: bump @babel/preset-env from 7.22.6 to 7.22.7 in /web (#6168)
  website: bump prettier from 2.8.8 to 3.0.0 in /website (#6155)
  web: bump storybook from 7.0.25 to 7.0.26 in /web (#6162)
  core: bump goauthentik.io/api/v3 from 3.2023054.2 to 3.2023054.4 (#6154)
  core: bump golang.org/x/oauth2 from 0.9.0 to 0.10.0 (#6153)
  web: bump @storybook/addon-essentials from 7.0.25 to 7.0.26 in /web (#6158)
  ci: bump actions/setup-node from 3.6.0 to 3.7.0 (#6156)
  web: bump core-js from 3.31.0 to 3.31.1 in /web (#6160)
  web: bump @storybook/addon-links from 7.0.25 to 7.0.26 in /web (#6159)
  web: bump @storybook/web-components-vite from 7.0.25 to 7.0.26 in /web (#6163)
  web: bump lit from 2.7.5 to 2.7.6 in /web (#6161)
  core: bump lxml from 4.9.2 to 4.9.3 (#6151)
  web: bump @babel/core from 7.22.5 to 7.22.6 in /web (#6143)
  web: bump @babel/plugin-transform-runtime from 7.22.5 to 7.22.6 in /web (#6142)
  web: bump @babel/preset-env from 7.22.5 to 7.22.6 in /web (#6144)
  web: bump @babel/plugin-proposal-decorators from 7.22.5 to 7.22.6 in /web (#6141)
  ...
2023-07-06 08:05:05 -07:00
a75c9434d9 Merge branch 'main' into 5165-password-strength-indicator
* main: (23 commits)
  web: bump API Client version (#5935)
  sources/ldap: add support for cert based auth (#5850)
  ci: replace status with state for auto-deployment
  ci: don't write CI status to file
  ci: add workflow to automatically update next branch (#5921)
  providers/ldap: fix Outpost provider listing excluding backchannel providers (#5933)
  root: revert to use secret_key for JWT signing (#5934)
  sources/ldap: fix duplicate bind when authenticating user directly to… (#5927)
  web: bump core-js from 3.30.2 to 3.31.0 in /web (#5928)
  core: bump pytest from 7.3.1 to 7.3.2 (#5929)
  web: bump @rollup/plugin-commonjs from 25.0.0 to 25.0.1 in /web (#5931)
  web: bump @formatjs/intl-listformat from 7.3.0 to 7.4.0 in /web (#5932)
  core: bump github.com/go-ldap/ldap/v3 from 3.4.4 to 3.4.5 (#5930)
  website/integrations: Fix header in dokuwiki instructions (#5926)
  providers/oauth2: launch url: if URL parsing fails, return no launch URL (#5918)
  web: bump @babel/core from 7.22.1 to 7.22.5 in /web (#5909)
  web: bump @babel/plugin-proposal-decorators from 7.22.3 to 7.22.5 in /web (#5910)
  web: bump @babel/preset-typescript from 7.21.5 to 7.22.5 in /web (#5912)
  web: bump @babel/preset-env from 7.22.4 to 7.22.5 in /web (#5915)
  core: bump requests-mock from 1.10.0 to 1.11.0 (#5911)
  ...
2023-06-12 09:55:35 -07:00
4ea9b69ab5 web: fix out-of-date comment 2023-06-08 14:38:45 -07:00
c48eee0ebf web: add visualizing and testing for the FieldRenderers 2023-06-08 13:43:13 -07:00
0d94373f10 web: password quality indicators
Resolves issue 5165

This commit updates the password match indicator so that the user, and not
the component, makes decisions about the names of the initial and confirmation
inputs.
2023-06-08 11:25:13 -07:00
1c85dc512f Merge branch 'main' into 5165-password-strength-indicator
* main:
  providers/ldap: rework Schema and DSE (#5838)
  web/flows: update default flow background (#5905)
  web: bump @formatjs/intl-listformat from 7.2.2 to 7.3.0 in /web (#5866)
  website/integrations: add account linking note for WriteFreely (#5804)
  web: bump @storybook/addon-essentials from 7.0.18 to 7.0.20 in /web (#5894)
  web: bump @storybook/web-components-vite from 7.0.18 to 7.0.20 in /web (#5895)
  web: bump @storybook/blocks from 7.0.18 to 7.0.20 in /web (#5893)
  web: bump storybook from 7.0.18 to 7.0.20 in /web (#5896)
  website/docs: correct LDAP StartTLS documentation (#5886)
  core: bump python from 3.11.3-slim-bullseye to 3.11.4-slim-bullseye (#5891)
  ci: bump docker/setup-qemu-action from 2.1.0 to 2.2.0 (#5892)
  core: bump selenium from 4.9.1 to 4.10.0 (#5897)
  web: bump pyright from 1.1.312 to 1.1.313 in /web (#5898)
  web: bump @storybook/addon-links from 7.0.18 to 7.0.20 in /web (#5899)
  web: bump @storybook/web-components from 7.0.18 to 7.0.20 in /web (#5900)
  core: bump urllib3 from 2.0.2 to 2.0.3 (#5901)
  core: bump ruff from 0.0.271 to 0.0.272 (#5902)
  core: bump sentry-sdk from 1.25.0 to 1.25.1 (#5903)
2023-06-08 08:42:11 -07:00
a71778651f web: improve password experience
This commit disassembles PromptStage and places function that don't
need a reference to the PromptStage object into a collection of
maps between the Stage type and the prompt associated with it.  (In
a better world, this would be a great place to try some post-Midgard
mplementation of itemtype/itemid/itemprop).

This surfaced the nature of the relationship between Password and
Password (Repeat), allowing us to modify both to show password
strength and password matching for the "change password" dialog.
2023-06-08 08:35:23 -07:00
33 changed files with 3388 additions and 5224 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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";

View File

@ -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;
} }
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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");
}
} }

View File

@ -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 ?? "";

View File

@ -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();

View File

@ -0,0 +1,5 @@
import PasswordMatchIndicator from "./password-match-indicator.js";
export { PasswordMatchIndicator };
export default PasswordMatchIndicator;

View File

@ -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>`;

View File

@ -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;

View 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;

View File

@ -0,0 +1,5 @@
import PasswordStrengthIndicator from "./password-strength-indicator.js";
export { PasswordStrengthIndicator };
export default PasswordStrengthIndicator;

View File

@ -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>`;

View File

@ -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;

View File

@ -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;
}
`,
];

View File

@ -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>`;
} }

View File

@ -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);
}
`,
];

View File

@ -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}`;
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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
);
}

View 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",
};

View 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;

View File

@ -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">

View 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>`;
}