web: re-organise frontend and cleanup common code (#3572)

* fix repo in api client

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* web: re-organise files to match their interface

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* core: include version in script tags

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* cleanup maybe broken

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* revert rename

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* web: get rid of Client.ts

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* move more to common

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* more moving

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* format

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* unfuck files that vscode fucked, thanks

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* move more

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* finish moving (maybe)

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* ok more moving

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix more stuff that vs code destroyed

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* get rid "web" prefix for virtual package

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix locales

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* use custom base element

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix css file

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* don't run autoDetectLanguage when importing locale

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix circular dependencies

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* web: fix build

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L
2022-09-15 00:05:21 +02:00
committed by GitHub
parent 369440652c
commit 4a91a7d2e2
291 changed files with 2062 additions and 1921 deletions

View File

@ -0,0 +1,155 @@
import "@goauthentik/admin/flows/StageBindingForm";
import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/stages/StageWizard";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/forms/ProxyForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { FlowStageBinding, FlowsApi } from "@goauthentik/api";
@customElement("ak-bound-stages-list")
export class BoundStagesList extends Table<FlowStageBinding> {
expandable = true;
checkbox = true;
@property()
target?: string;
async apiEndpoint(page: number): Promise<PaginatedResponse<FlowStageBinding>> {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsList({
target: this.target || "",
ordering: "order",
page: page,
pageSize: (await uiConfig()).pagination.perPage,
});
}
columns(): TableColumn[] {
return [
new TableColumn(t`Order`),
new TableColumn(t`Name`),
new TableColumn(t`Type`),
new TableColumn(t`Actions`),
];
}
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${t`Stage binding(s)`}
.objects=${this.selectedElements}
.metadata=${(item: FlowStageBinding) => {
return [
{ key: t`Stage`, value: item.stageObj?.name || "" },
{ key: t`Stage type`, value: item.stageObj?.verboseName || "" },
];
}}
.usedBy=${(item: FlowStageBinding) => {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsUsedByList({
fsbUuid: item.pk,
});
}}
.delete=${(item: FlowStageBinding) => {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsDestroy({
fsbUuid: item.pk,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${t`Delete`}
</button>
</ak-forms-delete-bulk>`;
}
row(item: FlowStageBinding): TemplateResult[] {
return [
html`${item.order}`,
html`${item.stageObj?.name}`,
html`${item.stageObj?.verboseName}`,
html` <ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update ${item.stageObj?.verboseName}`} </span>
<ak-proxy-form
slot="form"
.args=${{
instancePk: item.stage,
}}
type=${ifDefined(item.stageObj?.component)}
>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${t`Edit Stage`}
</button>
</ak-forms-modal>
<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update Stage binding`} </span>
<ak-stage-binding-form slot="form" .instancePk=${item.pk}>
</ak-stage-binding-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${t`Edit Binding`}
</button>
</ak-forms-modal>`,
];
}
renderExpanded(item: FlowStageBinding): TemplateResult {
return html` <td></td>
<td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content">
<div class="pf-c-content">
<p>
${t`These bindings control if this stage will be applied to the flow.`}
</p>
<ak-bound-policies-list .target=${item.policybindingmodelPtrId}>
</ak-bound-policies-list>
</div>
</div>
</td>`;
}
renderEmpty(): TemplateResult {
return super.renderEmpty(html`<ak-empty-state
header=${t`No Stages bound`}
icon="pf-icon-module"
>
<div slot="body">${t`No stages are currently bound to this flow.`}</div>
<div slot="primary">
<ak-forms-modal>
<span slot="submit"> ${t`Create`} </span>
<span slot="header"> ${t`Create Stage binding`} </span>
<ak-stage-binding-form slot="form" targetPk=${ifDefined(this.target)}>
</ak-stage-binding-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${t`Bind stage`}
</button>
</ak-forms-modal>
</div>
</ak-empty-state>`);
}
renderToolbar(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${t`Create`} </span>
<span slot="header"> ${t`Create Stage binding`} </span>
<ak-stage-binding-form slot="form" targetPk=${ifDefined(this.target)}>
</ak-stage-binding-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${t`Bind stage`}</button>
</ak-forms-modal>
<ak-stage-wizard createText=${t`Create Stage`}></ak-stage-wizard>
${super.renderToolbar()}
`;
}
}

View File

@ -0,0 +1,110 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
import FlowChart from "flowchart.js";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FlowsApi } from "@goauthentik/api";
export const FONT_COLOUR_DARK_MODE = "#fafafa";
export const FONT_COLOUR_LIGHT_MODE = "#151515";
export const FILL_DARK_MODE = "#18191a";
export const FILL_LIGHT_MODE = "#f0f0f0";
@customElement("ak-flow-diagram")
export class FlowDiagram extends AKElement {
_flowSlug?: string;
@property()
set flowSlug(value: string) {
this._flowSlug = value;
this.diagram = undefined;
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesDiagramRetrieve({
slug: value,
})
.then((data) => {
this.diagram = data.diagram;
this.requestUpdate();
});
}
@property({ attribute: false })
diagram?: string;
@property()
fontColour: string = FONT_COLOUR_DARK_MODE;
@property()
fill: string = FILL_DARK_MODE;
handlerBound = false;
createRenderRoot(): Element | ShadowRoot {
return this;
}
get isInViewport(): boolean {
const rect = this.getBoundingClientRect();
return !(rect.x + rect.y + rect.width + rect.height === 0);
}
constructor() {
super();
const matcher = window.matchMedia("(prefers-color-scheme: light)");
const handler = (ev?: MediaQueryListEvent) => {
if (ev?.matches || matcher.matches) {
this.fontColour = FONT_COLOUR_LIGHT_MODE;
this.fill = FILL_LIGHT_MODE;
} else {
this.fontColour = FONT_COLOUR_DARK_MODE;
this.fill = FILL_DARK_MODE;
}
this.requestUpdate();
};
matcher.addEventListener("change", handler);
handler();
}
firstUpdated(): void {
if (this.handlerBound) return;
window.addEventListener(EVENT_REFRESH, this.refreshHandler);
this.handlerBound = true;
}
refreshHandler = (): void => {
if (!this._flowSlug) return;
this.flowSlug = this._flowSlug;
};
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_REFRESH, this.refreshHandler);
}
render(): TemplateResult {
this.querySelectorAll("*").forEach((el) => {
try {
el.remove();
} catch {
console.debug(`authentik/flow/diagram: failed to remove element ${el}`);
}
});
if (this.diagram) {
const diagram = FlowChart.parse(this.diagram);
diagram.drawSVG(this, {
"font-color": this.fontColour,
"line-color": "#bebebe",
"element-color": "#bebebe",
"fill": this.fill,
"yes-text": "Policy passes",
"no-text": "Policy denies",
});
return html``;
}
return html`<ak-empty-state ?loading=${true}></ak-empty-state>`;
}
}

View File

@ -0,0 +1,331 @@
import { DesignationToLabel, LayoutToLabel } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.js";
import {
CapabilitiesEnum,
DeniedActionEnum,
Flow,
FlowDesignationEnum,
FlowsApi,
LayoutEnum,
PolicyEngineMode,
} from "@goauthentik/api";
@customElement("ak-flow-form")
export class FlowForm extends ModelForm<Flow, string> {
loadInstance(pk: string): Promise<Flow> {
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({
slug: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return t`Successfully updated flow.`;
} else {
return t`Successfully created flow.`;
}
}
@property({ type: Boolean })
clearBackground = false;
send = async (data: Flow): Promise<void | Flow> => {
let flow: Flow;
if (this.instance) {
flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesUpdate({
slug: this.instance.slug,
flowRequest: data,
});
} else {
flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesCreate({
flowRequest: data,
});
}
const c = await config();
if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) {
const icon = this.getFormFiles()["background"];
if (icon || this.clearBackground) {
await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundCreate({
slug: flow.slug,
file: icon,
clear: this.clearBackground,
});
}
} else {
await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundUrlCreate({
slug: flow.slug,
filePathRequest: {
url: data.background || "",
},
});
}
return flow;
};
renderDesignations(): TemplateResult {
return html`
<option
value=${FlowDesignationEnum.Authentication}
?selected=${this.instance?.designation === FlowDesignationEnum.Authentication}
>
${DesignationToLabel(FlowDesignationEnum.Authentication)}
</option>
<option
value=${FlowDesignationEnum.Authorization}
?selected=${this.instance?.designation === FlowDesignationEnum.Authorization}
>
${DesignationToLabel(FlowDesignationEnum.Authorization)}
</option>
<option
value=${FlowDesignationEnum.Enrollment}
?selected=${this.instance?.designation === FlowDesignationEnum.Enrollment}
>
${DesignationToLabel(FlowDesignationEnum.Enrollment)}
</option>
<option
value=${FlowDesignationEnum.Invalidation}
?selected=${this.instance?.designation === FlowDesignationEnum.Invalidation}
>
${DesignationToLabel(FlowDesignationEnum.Invalidation)}
</option>
<option
value=${FlowDesignationEnum.Recovery}
?selected=${this.instance?.designation === FlowDesignationEnum.Recovery}
>
${DesignationToLabel(FlowDesignationEnum.Recovery)}
</option>
<option
value=${FlowDesignationEnum.StageConfiguration}
?selected=${this.instance?.designation === FlowDesignationEnum.StageConfiguration}
>
${DesignationToLabel(FlowDesignationEnum.StageConfiguration)}
</option>
<option
value=${FlowDesignationEnum.Unenrollment}
?selected=${this.instance?.designation === FlowDesignationEnum.Unenrollment}
>
${DesignationToLabel(FlowDesignationEnum.Unenrollment)}
</option>
`;
}
renderDeniedAction(): TemplateResult {
return html` <option
value=${DeniedActionEnum.MessageContinue}
?selected=${this.instance?.deniedAction === DeniedActionEnum.MessageContinue}
>
${t`MESSAGE_CONTINUE will follow the ?next parameter if set, otherwise show a message.`}
</option>
<option
value=${DeniedActionEnum.Continue}
?selected=${this.instance?.deniedAction === DeniedActionEnum.Continue}
>
${t`CONTINUE will either follow the ?next parameter or redirect to the default interface.`}
</option>
<option
value=${DeniedActionEnum.Message}
?selected=${this.instance?.deniedAction === DeniedActionEnum.Message}
>
${t`MESSAGE will notify the user the flow isn't applicable.`}
</option>`;
}
renderLayout(): TemplateResult {
return html`
<option
value=${LayoutEnum.Stacked}
?selected=${this.instance?.layout === LayoutEnum.Stacked}
>
${LayoutToLabel(LayoutEnum.Stacked)}
</option>
<option
value=${LayoutEnum.ContentLeft}
?selected=${this.instance?.layout === LayoutEnum.ContentLeft}
>
${LayoutToLabel(LayoutEnum.ContentLeft)}
</option>
<option
value=${LayoutEnum.ContentRight}
?selected=${this.instance?.layout === LayoutEnum.ContentRight}
>
${LayoutToLabel(LayoutEnum.ContentRight)}
</option>
<option
value=${LayoutEnum.SidebarLeft}
?selected=${this.instance?.layout === LayoutEnum.SidebarLeft}
>
${LayoutToLabel(LayoutEnum.SidebarLeft)}
</option>
<option
value=${LayoutEnum.SidebarRight}
?selected=${this.instance?.layout === LayoutEnum.SidebarRight}
>
${LayoutToLabel(LayoutEnum.SidebarRight)}
</option>
`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Title`} ?required=${true} name="title">
<input
type="text"
value="${ifDefined(this.instance?.title)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${t`Shown as the Title in Flow pages.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Slug`} ?required=${true} name="slug">
<input
type="text"
value="${ifDefined(this.instance?.slug)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${t`Visible in the URL.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Policy engine mode`}
?required=${true}
name="policyEngineMode"
>
<select class="pf-c-form-control">
<option
value=${PolicyEngineMode.Any}
?selected=${this.instance?.policyEngineMode === PolicyEngineMode.Any}
>
${t`ANY, any policy must match to grant access.`}
</option>
<option
value=${PolicyEngineMode.All}
?selected=${this.instance?.policyEngineMode === PolicyEngineMode.All}
>
${t`ALL, all policies must match to grant access.`}
</option>
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Designation`}
?required=${true}
name="designation"
>
<select class="pf-c-form-control">
<option value="" ?selected=${this.instance?.designation === undefined}>
---------
</option>
${this.renderDesignations()}
</select>
<p class="pf-c-form__helper-text">
${t`Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Denied action`}
?required=${true}
name="deniedAction"
>
<select class="pf-c-form-control">
${this.renderDeniedAction()}
</select>
<p class="pf-c-form__helper-text">
${t`Decides the response when a policy denies access to this flow for a user.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Layout`} ?required=${true} name="layout">
<select class="pf-c-form-control">
${this.renderLayout()}
</select>
</ak-form-element-horizontal>
${until(
config().then((c) => {
if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) {
return html`<ak-form-element-horizontal
label=${t`Background`}
name="background"
>
<input type="file" value="" class="pf-c-form-control" />
${this.instance?.background
? html`
<p class="pf-c-form__helper-text">
${t`Currently set to:`} ${this.instance?.background}
</p>
`
: html``}
<p class="pf-c-form__helper-text">
${t`Background shown during execution.`}
</p>
</ak-form-element-horizontal>
${this.instance?.background
? html`
<ak-form-element-horizontal>
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
@change=${(ev: Event) => {
const target = ev.target as HTMLInputElement;
this.clearBackground = target.checked;
}}
/>
<label class="pf-c-check__label">
${t`Clear background image`}
</label>
</div>
<p class="pf-c-form__helper-text">
${t`Delete currently set background image.`}
</p>
</ak-form-element-horizontal>
`
: html``}`;
}
return html`<ak-form-element-horizontal
label=${t`Background`}
name="background"
>
<input
type="text"
value="${first(this.instance?.background, "")}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${t`Background shown during execution.`}
</p>
</ak-form-element-horizontal>`;
}),
)}
<ak-form-element-horizontal name="compatibilityMode">
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
?checked=${first(this.instance?.compatibilityMode, false)}
/>
<label class="pf-c-check__label"> ${t`Compatibility mode`} </label>
</div>
<p class="pf-c-form__helper-text">
${t`Enable compatibility mode, increases compatibility with password managers on mobile devices.`}
</p>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -0,0 +1,40 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { Flow, FlowsApi } from "@goauthentik/api";
@customElement("ak-flow-import-form")
export class FlowImportForm extends Form<Flow> {
getSuccessMessage(): string {
return t`Successfully imported flow.`;
}
// eslint-disable-next-line
send = (data: Flow): Promise<void> => {
const file = this.getFormFiles()["flow"];
if (!file) {
throw new SentryIgnoredError("No form data");
}
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesImportFlowCreate({
file: file,
});
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Flow`} name="flow">
<input type="file" value="" class="pf-c-form-control" />
<p class="pf-c-form__helper-text">
${t`.yaml files, which can be found on goauthentik.io and can be exported by authentik.`}
</p>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -0,0 +1,170 @@
import "@goauthentik/admin/flows/FlowForm";
import "@goauthentik/admin/flows/FlowImportForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ConfirmationForm";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flow, FlowsApi } from "@goauthentik/api";
@customElement("ak-flow-list")
export class FlowListPage extends TablePage<Flow> {
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return t`Flows`;
}
pageDescription(): string {
return t`Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them.`;
}
pageIcon(): string {
return "pf-icon pf-icon-process-automation";
}
checkbox = true;
@property()
order = "slug";
async apiEndpoint(page: number): Promise<PaginatedResponse<Flow>> {
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
ordering: this.order,
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
});
}
groupBy(items: Flow[]): [string, Flow[]][] {
return groupBy(items, (flow) => {
if (!flow.designation) {
return "";
}
return DesignationToLabel(flow.designation);
});
}
columns(): TableColumn[] {
return [
new TableColumn(t`Identifier`, "slug"),
new TableColumn(t`Name`, "name"),
new TableColumn(t`Stages`),
new TableColumn(t`Policies`),
new TableColumn(t`Actions`),
];
}
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${t`Flow(s)`}
.objects=${this.selectedElements}
.usedBy=${(item: Flow) => {
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesUsedByList({
slug: item.slug,
});
}}
.delete=${(item: Flow) => {
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesDestroy({
slug: item.slug,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${t`Delete`}
</button>
</ak-forms-delete-bulk>`;
}
row(item: Flow): TemplateResult[] {
return [
html`<div>
<div>
<a href="#/flow/flows/${item.slug}">
<code>${item.slug}</code>
</a>
</div>
<small>${item.title}</small>
</div>`,
html`${item.name}`,
html`${Array.from(item.stages || []).length}`,
html`${Array.from(item.policies || []).length}`,
html` <ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update Flow`} </span>
<ak-flow-form slot="form" .instancePk=${item.slug}> </ak-flow-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<i class="fas fa-edit"></i>
</button>
</ak-forms-modal>
<button
class="pf-c-button pf-m-plain"
@click=${() => {
const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
}}
>
<i class="fas fa-play"></i>
</button>
<a class="pf-c-button pf-m-plain" href=${item.exportUrl}>
<i class="fas fa-download"></i>
</a>`,
];
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${t`Create`} </span>
<span slot="header"> ${t`Create Flow`} </span>
<ak-flow-form slot="form"> </ak-flow-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${t`Create`}</button>
</ak-forms-modal>
<ak-forms-modal>
<span slot="submit"> ${t`Import`} </span>
<span slot="header"> ${t`Import Flow`} </span>
<ak-flow-import-form slot="form"> </ak-flow-import-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${t`Import`}</button>
</ak-forms-modal>
`;
}
renderToolbar(): TemplateResult {
return html`
${super.renderToolbar()}
<ak-forms-confirm
successMessage=${t`Successfully cleared flow cache`}
errorMessage=${t`Failed to delete flow cache`}
action=${t`Clear cache`}
.onConfirm=${() => {
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesCacheClearCreate();
}}
>
<span slot="header"> ${t`Clear Flow cache`} </span>
<p slot="body">
${t`Are you sure you want to clear the flow cache?
This will cause all flows to be re-evaluated on their next usage.`}
</p>
<button slot="trigger" class="pf-c-button pf-m-secondary" type="button">
${t`Clear cache`}
</button>
<div slot="modal"></div>
</ak-forms-confirm>
`;
}
}

View File

@ -0,0 +1,254 @@
import "@goauthentik/admin/flows/BoundStagesList";
import "@goauthentik/admin/flows/FlowDiagram";
import "@goauthentik/admin/flows/FlowForm";
import "@goauthentik/admin/policies/BoundPoliciesList";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/events/ObjectChangelog";
import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Flow, FlowsApi, ResponseError } from "@goauthentik/api";
@customElement("ak-flow-view")
export class FlowViewPage extends AKElement {
@property()
set flowSlug(value: string) {
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesRetrieve({
slug: value,
})
.then((flow) => {
this.flow = flow;
});
}
@property({ attribute: false })
flow!: Flow;
static get styles(): CSSResult[] {
return [
PFBase,
PFPage,
PFDescriptionList,
PFButton,
PFCard,
PFContent,
PFGrid,
AKGlobal,
].concat(
css`
img.pf-icon {
max-height: 24px;
}
ak-tabs {
height: 100%;
}
`,
);
}
render(): TemplateResult {
if (!this.flow) {
return html``;
}
return html`<ak-page-header
icon="pf-icon pf-icon-process-automation"
header=${this.flow.name}
description=${this.flow.title}
>
</ak-page-header>
<ak-tabs>
<div
slot="page-overview"
data-tab-title="${t`Flow Overview`}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-2-col-on-xl pf-m-2-col-on-2xl"
>
<div class="pf-c-card__title">${t`Related`}</div>
<div class="pf-c-card__body">
<dl class="pf-c-description-list">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${t`Edit`}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update Flow`} </span>
<ak-flow-form
slot="form"
.instancePk=${this.flow.slug}
>
</ak-flow-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${t`Edit`}
</button>
</ak-forms-modal>
</div>
</dd>
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${t`Execute flow`}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<button
class="pf-c-button pf-m-primary"
@click=${() => {
const finalURL = `${
window.location.origin
}/if/flow/${this.flow.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
}}
>
${t`Normal`}
</button>
<button
class="pf-c-button pf-m-secondary"
@click=${() => {
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesExecuteRetrieve({
slug: this.flow.slug,
})
.then((link) => {
const finalURL = `${
link.link
}${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
});
}}
>
${t`with current user`}
</button>
<button
class="pf-c-button pf-m-secondary"
@click=${() => {
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesExecuteRetrieve({
slug: this.flow.slug,
})
.then((link) => {
const finalURL = `${
link.link
}?${encodeURI(
`inspector&next=/#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
})
.catch((exc: ResponseError) => {
// This request can return a HTTP 400 when a flow
// is not applicable.
window.open(
exc.response.url,
"_blank",
);
});
}}
>
${t`with inspector`}
</button>
</div>
</dd>
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${t`Export flow`}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<a
class="pf-c-button pf-m-secondary"
href=${this.flow.exportUrl}
>
${t`Export`}
</a>
</div>
</dd>
</div>
</dl>
</div>
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-10-col-on-xl pf-m-10-col-on-2xl"
>
<div class="pf-c-card__title">${t`Diagram`}</div>
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-flow-diagram flowSlug=${this.flow.slug}> </ak-flow-diagram>
</div>
</div>
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl"
>
<div class="pf-c-card__title">${t`Changelog`}</div>
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.flow.pk || ""}
targetModelApp="authentik_flows"
targetModelName="flow"
>
</ak-object-changelog>
</div>
</div>
</div>
</div>
<div
slot="page-stage-bindings"
data-tab-title="${t`Stage Bindings`}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-bound-stages-list .target=${this.flow.pk}> </ak-bound-stages-list>
</div>
</div>
</div>
<div
slot="page-policy-bindings"
data-tab-title="${t`Policy / Group / User Bindings`}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__title">
${t`These bindings control which users can access this flow.`}
</div>
<div class="pf-c-card__body">
<ak-bound-policies-list .target=${this.flow.policybindingmodelPtrId}>
</ak-bound-policies-list>
</div>
</div>
</div>
</ak-tabs>`;
}
}

View File

@ -0,0 +1,225 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first, groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.js";
import {
FlowStageBinding,
FlowsApi,
InvalidResponseActionEnum,
PolicyEngineMode,
Stage,
StagesApi,
} from "@goauthentik/api";
@customElement("ak-stage-binding-form")
export class StageBindingForm extends ModelForm<FlowStageBinding, string> {
loadInstance(pk: string): Promise<FlowStageBinding> {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsRetrieve({
fsbUuid: pk,
});
}
@property()
targetPk?: string;
getSuccessMessage(): string {
if (this.instance) {
return t`Successfully updated binding.`;
} else {
return t`Successfully created binding.`;
}
}
send = (data: FlowStageBinding): Promise<FlowStageBinding> => {
if (this.instance) {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsUpdate({
fsbUuid: this.instance.pk || "",
flowStageBindingRequest: data,
});
} else {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsCreate({
flowStageBindingRequest: data,
});
}
};
groupStages(stages: Stage[]): TemplateResult {
return html`
<option value="">---------</option>
${groupBy<Stage>(stages, (s) => s.verboseName || "").map(([group, stages]) => {
return html`<optgroup label=${group}>
${stages.map((stage) => {
const selected = this.instance?.stage === stage.pk;
return html`<option ?selected=${selected} value=${ifDefined(stage.pk)}>
${stage.name}
</option>`;
})}
</optgroup>`;
})}
`;
}
getOrder(): Promise<number> {
if (this.instance) {
return Promise.resolve(this.instance.order);
}
return new FlowsApi(DEFAULT_CONFIG)
.flowsBindingsList({
target: this.targetPk || "",
})
.then((bindings) => {
const orders = bindings.results.map((binding) => binding.order);
if (orders.length < 1) {
return 0;
}
return Math.max(...orders) + 1;
});
}
renderTarget(): TemplateResult {
if (this.instance?.target || this.targetPk) {
return html`
<input
required
name="target"
type="hidden"
value=${ifDefined(this.instance?.target || this.targetPk)}
/>
`;
}
return html`<ak-form-element-horizontal label=${t`Target`} ?required=${true} name="target">
<select class="pf-c-form-control">
${until(
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesList({
ordering: "slug",
})
.then((flows) => {
return flows.results.map((flow) => {
// No ?selected check here, as this input isn't shown on update forms
return html`<option value=${ifDefined(flow.pk)}>
${flow.name} (${flow.slug})
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
</ak-form-element-horizontal>`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${this.renderTarget()}
<ak-form-element-horizontal label=${t`Stage`} ?required=${true} name="stage">
<select class="pf-c-form-control">
${until(
new StagesApi(DEFAULT_CONFIG)
.stagesAllList({
ordering: "name",
})
.then((stages) => {
return this.groupStages(stages.results);
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Order`} ?required=${true} name="order">
<!-- @ts-ignore -->
<input
type="number"
value="${until(this.getOrder())}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="evaluateOnPlan">
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
?checked=${first(this.instance?.evaluateOnPlan, true)}
/>
<label class="pf-c-check__label"> ${t`Evaluate on plan`} </label>
</div>
<p class="pf-c-form__helper-text">
${t`Evaluate policies during the Flow planning process. Disable this for input-based policies. Should be used in conjunction with 'Re-evaluate policies', as with both options disabled, policies are **not** evaluated.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="reEvaluatePolicies">
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
?checked=${first(this.instance?.reEvaluatePolicies, false)}
/>
<label class="pf-c-check__label"> ${t`Re-evaluate policies`} </label>
</div>
<p class="pf-c-form__helper-text">
${t`Evaluate policies before the Stage is present to the user.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Invalid response action`}
?required=${true}
name="invalidResponseAction"
>
<select class="pf-c-form-control">
<option
value=${InvalidResponseActionEnum.Retry}
?selected=${this.instance?.invalidResponseAction ===
InvalidResponseActionEnum.Retry}
>
${t`RETRY returns the error message and a similar challenge to the executor.`}
</option>
<option
value=${InvalidResponseActionEnum.Restart}
?selected=${this.instance?.invalidResponseAction ===
InvalidResponseActionEnum.Restart}
>
${t`RESTART restarts the flow from the beginning.`}
</option>
<option
value=${InvalidResponseActionEnum.RestartWithContext}
?selected=${this.instance?.invalidResponseAction ===
InvalidResponseActionEnum.RestartWithContext}
>
${t`RESTART_WITH_CONTEXT restarts the flow from the beginning, while keeping the flow context.`}
</option>
</select>
<p class="pf-c-form__helper-text">
${t`Configure how the flow executor should handle an invalid response to a challenge.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Policy engine mode`}
?required=${true}
name="policyEngineMode"
>
<select class="pf-c-form-control">
<option
value=${PolicyEngineMode.Any}
?selected=${this.instance?.policyEngineMode === PolicyEngineMode.Any}
>
${t`ANY, any policy must match to include this stage access.`}
</option>
<option
value=${PolicyEngineMode.All}
?selected=${this.instance?.policyEngineMode === PolicyEngineMode.All}
>
${t`ALL, all policies must match to include this stage access.`}
</option>
</select>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -0,0 +1,37 @@
import { t } from "@lingui/macro";
import { FlowDesignationEnum, LayoutEnum } from "@goauthentik/api";
export function DesignationToLabel(designation: FlowDesignationEnum): string {
switch (designation) {
case FlowDesignationEnum.Authentication:
return t`Authentication`;
case FlowDesignationEnum.Authorization:
return t`Authorization`;
case FlowDesignationEnum.Enrollment:
return t`Enrollment`;
case FlowDesignationEnum.Invalidation:
return t`Invalidation`;
case FlowDesignationEnum.Recovery:
return t`Recovery`;
case FlowDesignationEnum.StageConfiguration:
return t`Stage Configuration`;
case FlowDesignationEnum.Unenrollment:
return t`Unenrollment`;
}
}
export function LayoutToLabel(layout: LayoutEnum): string {
switch (layout) {
case LayoutEnum.Stacked:
return t`Stacked`;
case LayoutEnum.ContentLeft:
return t`Content left`;
case LayoutEnum.ContentRight:
return t`Content right`;
case LayoutEnum.SidebarLeft:
return t`Sidebar left`;
case LayoutEnum.SidebarRight:
return t`Sidebar right`;
}
}