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:
@ -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(
|
||||
|
||||
@ -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>`;
|
||||
}),
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
231
web/src/user/user-settings/details/UserSettingsFlowExecutor.ts
Normal file
231
web/src/user/user-settings/details/UserSettingsFlowExecutor.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
@ -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>`;
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user