core: customisable user settings (#2397)

* tenants: add user_settings flow, add basic flow and basic new executor

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

* web/user: use flow PromptStage instead of custom stage

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

* web/flows: add tenant to StageHost interface

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

* web/user: fix form missing component

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

* web/user: re-add success message

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

* web/user: improve support for multiple error messages

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

* stages/prompt: allow expressions in prompt placeholders

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

* stages/prompt: add tests

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

* flows: always set pending user

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

* flows: never cache stage configuration flow plans

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

* stages/user_write: fix error when pending user is anonymous user

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

* web/admin: add checkbox for prompt placeholder expression

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

* website/docs: add prompt expression docs

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

* stages/prompt: add ak-locale field type

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

* tenants: fix default policy

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

* web/user: add function to do global refresh

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

* web/flows: fix rendering of ak-locale

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

* tenants: fix default policy, add error handling to placeholder, fix locale attribute

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

* add tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L
2022-03-03 00:13:06 +01:00
committed by GitHub
parent c57fbcfd89
commit 4f4f954693
41 changed files with 3791 additions and 2853 deletions

View File

@ -27,8 +27,8 @@ import { EVENT_REFRESH } from "../../constants";
import "../../elements/Tabs";
import "../../elements/user/SessionList";
import "../../elements/user/UserConsentList";
import "./details/UserDetailsForm";
import "./details/UserPassword";
import "./details/UserSettingsFlowExecutor";
import "./mfa/MFADevicesPage";
import "./sources/SourceSettings";
import "./tokens/UserTokenList";
@ -78,14 +78,7 @@ export class UserSettingsPage extends LitElement {
>
<div class="pf-l-stack pf-m-gutter">
<div class="pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__title">${t`Update details`}</div>
<div class="pf-c-card__body">
<ak-user-details-form
.instancePk=${1}
></ak-user-details-form>
</div>
</div>
<ak-user-settings-flow-executor></ak-user-settings-flow-executor>
</div>
<div class="pf-l-stack__item">
${until(

View File

@ -1,143 +0,0 @@
import { i18n } from "@lingui/core";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.js";
import { CoreApi, UserSelf } from "@goauthentik/api";
import { DEFAULT_CONFIG, tenant } from "../../../api/Config";
import { me } from "../../../api/Users";
import { getConfigForUser, uiConfig } from "../../../common/config";
import "../../../elements/EmptyState";
import "../../../elements/forms/Form";
import "../../../elements/forms/FormElement";
import "../../../elements/forms/HorizontalFormElement";
import { ModelForm } from "../../../elements/forms/ModelForm";
import { LOCALES, autoDetectLanguage } from "../../../interfaces/locale";
@customElement("ak-user-details-form")
export class UserDetailsForm extends ModelForm<UserSelf, number> {
currentLocale?: string;
viewportCheck = false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadInstance(pk: number): Promise<UserSelf> {
return me().then((user) => {
const config = getConfigForUser(user.user);
this.currentLocale = config.locale;
return user.user;
});
}
getSuccessMessage(): string {
return t`Successfully updated details.`;
}
send = (data: UserSelf): Promise<UserSelf> => {
const newConfig = getConfigForUser(data);
const newLocale = LOCALES.find((locale) => locale.code === newConfig.locale);
if (newLocale) {
i18n.activate(newLocale.code);
} else if (newConfig.locale === "") {
autoDetectLanguage();
} else {
console.debug(`authentik/user: invalid locale: '${newConfig.locale}'`);
}
return new CoreApi(DEFAULT_CONFIG)
.coreUsersUpdateSelfUpdate({
userSelfRequest: data,
})
.then((su) => {
return su.user;
});
};
renderForm(): TemplateResult {
if (!this.instance) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return html`${until(
uiConfig().then((config) => {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${t`Username`}
?required=${true}
name="username"
>
<input
type="text"
value="${ifDefined(this.instance?.username)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Name`} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">${t`User's display name.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Email`} name="email">
<input
type="email"
value="${ifDefined(this.instance?.email)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Locale`} name="settings.locale">
<select class="pf-c-form-control">
<option value="" ?selected=${config.locale === ""}>
${t`Auto-detect (based on your browser)`}
</option>
${LOCALES.map((locale) => {
return html`<option
value=${locale.code}
?selected=${config.locale === locale.code}
>
${locale.code.toUpperCase()} - ${locale.label}
</option>`;
})}
</select>
</ak-form-element-horizontal>
<div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<button
@click=${(ev: Event) => {
return this.submit(ev);
}}
class="pf-c-button pf-m-primary"
>
${t`Save`}
</button>
${until(
tenant().then((tenant) => {
if (tenant.flowUnenrollment) {
return html`<a
class="pf-c-button pf-m-danger"
href="/if/flow/${tenant.flowUnenrollment}"
>
${t`Delete account`}
</a>`;
}
return html``;
}),
)}
</div>
</div>
</div>
</form>`;
}),
)}`;
}
}

View File

@ -0,0 +1,231 @@
import { t } from "@lingui/macro";
import { CSSResult, LitElement, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import AKGlobal from "../../../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 PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
ChallengeChoices,
ChallengeTypes,
CurrentTenant,
FlowChallengeResponseRequest,
FlowsApi,
RedirectChallenge,
ShellChallenge,
} from "@goauthentik/api";
import { DEFAULT_CONFIG, tenant } from "../../../api/Config";
import { refreshMe } from "../../../api/Users";
import { EVENT_REFRESH } from "../../../constants";
import "../../../elements/LoadingOverlay";
import { MessageLevel } from "../../../elements/messages/Message";
import { showMessage } from "../../../elements/messages/MessageContainer";
import { StageHost } from "../../../flows/stages/base";
import "./stages/prompt/PromptStage";
@customElement("ak-user-settings-flow-executor")
export class UserSettingsFlowExecutor extends LitElement implements StageHost {
@property()
flowSlug?: string;
private _challenge?: ChallengeTypes;
@property({ attribute: false })
set challenge(value: ChallengeTypes | undefined) {
this._challenge = value;
this.requestUpdate();
}
get challenge(): ChallengeTypes | undefined {
return this._challenge;
}
@property({ type: Boolean })
loading = false;
@property({ attribute: false })
tenant!: CurrentTenant;
static get styles(): CSSResult[] {
return [PFBase, PFCard, PFPage, PFButton, PFContent, AKGlobal];
}
constructor() {
super();
tenant().then((tenant) => (this.tenant = tenant));
}
submit(payload?: FlowChallengeResponseRequest): Promise<boolean> {
if (!payload) return Promise.reject();
if (!this.challenge) return Promise.reject();
// @ts-ignore
payload.component = this.challenge.component;
this.loading = true;
return new FlowsApi(DEFAULT_CONFIG)
.flowsExecutorSolve({
flowSlug: this.flowSlug || "",
query: window.location.search.substring(1),
flowChallengeResponseRequest: payload,
})
.then((data) => {
showMessage({
level: MessageLevel.success,
message: t`Successfully updated details`,
});
this.challenge = data;
if (this.challenge.responseErrors) {
return false;
}
return true;
})
.catch((e: Error | Response) => {
this.errorMessage(e);
return false;
})
.finally(() => {
this.loading = false;
return false;
});
}
firstUpdated(): void {
tenant().then((tenant) => {
this.flowSlug = tenant.flowUserSettings;
if (!this.flowSlug) {
return;
}
this.loading = true;
new FlowsApi(DEFAULT_CONFIG)
.flowsExecutorGet({
flowSlug: this.flowSlug,
query: window.location.search.substring(1),
})
.then((challenge) => {
this.challenge = challenge;
})
.catch((e: Error | Response) => {
// Catch JSON or Update errors
this.errorMessage(e);
})
.finally(() => {
this.loading = false;
});
});
}
async errorMessage(error: Error | Response): Promise<void> {
let body = "";
if (error instanceof Error) {
body = error.message;
}
this.challenge = {
type: ChallengeChoices.Shell,
body: `<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
${t`Whoops!`}
</h1>
</header>
<div class="pf-c-login__main-body">
<h3>${t`Something went wrong! Please try again later.`}</h3>
<pre class="ak-exception">${body}</pre>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
<li class="pf-c-login__main-footer-links-item">
<a class="pf-c-button pf-m-primary pf-m-block" href="/">
${t`Return`}
</a>
</li>
</ul>
</footer>`,
} as ChallengeTypes;
}
globalRefresh(): void {
refreshMe().then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
try {
document.querySelectorAll("ak-interface-user").forEach((int) => {
(int as LitElement).requestUpdate();
});
} catch {
console.debug("authentik/user/flows: failed to find interface to refresh");
}
});
}
renderChallenge(): TemplateResult {
if (!this.challenge) {
return html``;
}
switch (this.challenge.type) {
case ChallengeChoices.Redirect:
if ((this.challenge as RedirectChallenge).to !== "/") {
return html`<a
href="${(this.challenge as RedirectChallenge).to}"
class="pf-c-button pf-m-primary"
>${"Edit settings"}</a
>`;
}
// Flow has finished, so let's load while in the background we can restart the flow
this.loading = true;
console.debug("authentik/user/flows: redirect to '/', restarting flow.");
this.firstUpdated();
this.globalRefresh();
return html``;
case ChallengeChoices.Shell:
return html`${unsafeHTML((this.challenge as ShellChallenge).body)}`;
case ChallengeChoices.Native:
switch (this.challenge.component) {
case "ak-stage-prompt":
return html`<ak-user-stage-prompt
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-user-stage-prompt>`;
default:
console.log(
`authentik/user/flows: unsupported stage type ${this.challenge.component}`,
);
return html`
<a href="/if/flow/${this.flowSlug}" class="pf-c-button pf-m-primary">
${t`Open settings`}
</a>
`;
}
default:
console.debug(`authentik/user/flows: unexpected data type ${this.challenge.type}`);
break;
}
return html``;
}
renderChallengeWrapper(): TemplateResult {
if (!this.flowSlug) {
return html`<p>${t`No settings flow configured.`}</p> `;
}
if (!this.challenge) {
return html`<ak-empty-state ?loading=${true} header=${t`Loading`}> </ak-empty-state>`;
}
return html` ${this.renderChallenge()} `;
}
render(): TemplateResult {
return html`${this.loading ? html`<ak-loading-overlay></ak-loading-overlay>` : html``}
<div class="pf-c-card">
<div class="pf-c-card__title">${t`Update details`}</div>
<div class="pf-c-card__body">${this.renderChallengeWrapper()}</div>
</div>`;
}
}

View File

@ -0,0 +1,49 @@
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { StagePrompt } from "@goauthentik/api";
import "../../../../../elements/forms/HorizontalFormElement";
import { PromptStage } from "../../../../../flows/stages/prompt/PromptStage";
@customElement("ak-user-stage-prompt")
export class UserSettingsPromptStage extends PromptStage {
renderField(prompt: StagePrompt): TemplateResult {
const errors = (this.challenge?.responseErrors || {})[prompt.fieldKey];
return html`
<ak-form-element-horizontal
label=${t`${prompt.label}`}
?required=${prompt.required}
name=${prompt.fieldKey}
?invalid=${errors !== undefined}
.errorMessages=${(errors || []).map((error) => {
return error.string;
})}
>
${unsafeHTML(this.renderPromptInner(prompt, true))} ${prompt.subText}
${this.renderPromptHelpText(prompt)}
</ak-form-element-horizontal>
`;
}
renderContinue(): TemplateResult {
return html` <div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<button type="submit" class="pf-c-button pf-m-primary">${t`Save`}</button>
${this.host.tenant.flowUnenrollment
? html` <a
class="pf-c-button pf-m-danger"
href="/if/flow/${this.host.tenant.flowUnenrollment}"
>
${t`Delete account`}
</a>`
: html``}
</div>
</div>
</div>`;
}
}

View File

@ -2,6 +2,7 @@ import { t } from "@lingui/macro";
import { CSSResult, LitElement, TemplateResult, css, 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 AKGlobal from "../../../authentik.css";
@ -97,7 +98,7 @@ export class UserSourceSettingsPage extends LitElement {
return html`<li class="pf-c-data-list__item">
<div class="pf-c-data-list__item-content">
<div class="pf-c-data-list__cell">
<img src="${stage.iconUrl}" />
<img src="${ifDefined(stage.iconUrl)}" />
${stage.title}
</div>
<div class="pf-c-data-list__cell">