web: use generated API Client (#616)
* api: fix types for config API * api: remove broken swagger UI * admin: re-fix system task enum * events: make event optional * events: fix Schema for notification transport test * flows: use APIView for Flow Executor * core: fix schema for Metrics APIs * web: rewrite to use generated API client * web: generate API Client in CI * admin: use x_cord and y_cord to prevent yaml issues * events: fix linting errors * web: don't lint generated code * core: fix fields not being required in TypeSerializer * flows: fix missing permission_classes * web: cleanup * web: fix rendering of graph on Overview page * web: cleanup imports * core: fix missing background image filter * flows: fix flows not advancing properly * stages/*: fix warnings during get_challenge * web: send Flow response as JSON instead of FormData * web: fix styles for horizontal tabs * web: add base chart class and custom chart for application view * root: generate ts client for e2e tests * web: don't attempt to connect to websocket in selenium tests * web: fix UserTokenList not being included in the build * web: fix styling for static token list * web: fix CSRF Token missing * stages/authenticator_static: fix error when disable static tokens * core: fix display issue when updating user info * web: fix Flow executor not showing spinner when redirecting
This commit is contained in:
185
web/src/flows/FlowExecutor.ts
Normal file
185
web/src/flows/FlowExecutor.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { gettext } from "django";
|
||||
import { LitElement, html, customElement, property, TemplateResult, CSSResult, css } from "lit-element";
|
||||
import { unsafeHTML } from "lit-html/directives/unsafe-html";
|
||||
import "./stages/authenticator_static/AuthenticatorStaticStage";
|
||||
import "./stages/authenticator_totp/AuthenticatorTOTPStage";
|
||||
import "./stages/authenticator_validate/AuthenticatorValidateStage";
|
||||
import "./stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
|
||||
import "./stages/autosubmit/AutosubmitStage";
|
||||
import "./stages/captcha/CaptchaStage";
|
||||
import "./stages/consent/ConsentStage";
|
||||
import "./stages/email/EmailStage";
|
||||
import "./stages/identification/IdentificationStage";
|
||||
import "./stages/password/PasswordStage";
|
||||
import "./stages/prompt/PromptStage";
|
||||
import { ShellChallenge, RedirectChallenge } from "../api/Flows";
|
||||
import { IdentificationChallenge } from "./stages/identification/IdentificationStage";
|
||||
import { PasswordChallenge } from "./stages/password/PasswordStage";
|
||||
import { ConsentChallenge } from "./stages/consent/ConsentStage";
|
||||
import { EmailChallenge } from "./stages/email/EmailStage";
|
||||
import { AutosubmitChallenge } from "./stages/autosubmit/AutosubmitStage";
|
||||
import { PromptChallenge } from "./stages/prompt/PromptStage";
|
||||
import { AuthenticatorTOTPChallenge } from "./stages/authenticator_totp/AuthenticatorTOTPStage";
|
||||
import { AuthenticatorStaticChallenge } from "./stages/authenticator_static/AuthenticatorStaticStage";
|
||||
import { AuthenticatorValidateStageChallenge } from "./stages/authenticator_validate/AuthenticatorValidateStage";
|
||||
import { WebAuthnAuthenticatorRegisterChallenge } from "./stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
|
||||
import { CaptchaChallenge } from "./stages/captcha/CaptchaStage";
|
||||
import { COMMON_STYLES } from "../common/styles";
|
||||
import { SpinnerSize } from "../elements/Spinner";
|
||||
import { StageHost } from "./stages/base";
|
||||
import { Challenge, ChallengeTypeEnum, FlowsApi } from "../api";
|
||||
import { DEFAULT_CONFIG } from "../api/Config";
|
||||
|
||||
@customElement("ak-flow-executor")
|
||||
export class FlowExecutor extends LitElement implements StageHost {
|
||||
@property()
|
||||
flowSlug = "";
|
||||
|
||||
@property({attribute: false})
|
||||
challenge?: Challenge;
|
||||
|
||||
@property({type: Boolean})
|
||||
loading = false;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES.concat(css`
|
||||
.ak-loading {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
background-color: #0303039e;
|
||||
}
|
||||
.ak-hidden {
|
||||
display: none;
|
||||
}
|
||||
:host {
|
||||
position: relative;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener("ak-flow-submit", () => {
|
||||
this.submit();
|
||||
});
|
||||
}
|
||||
|
||||
submit<T>(formData?: T): Promise<void> {
|
||||
this.loading = true;
|
||||
return new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolveRaw({
|
||||
flowSlug: this.flowSlug,
|
||||
data: formData || {},
|
||||
}).then((challengeRaw) => {
|
||||
return challengeRaw.raw.json();
|
||||
}).then((data) => {
|
||||
this.challenge = data;
|
||||
}).catch((e) => {
|
||||
this.errorMessage(e);
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.loading = true;
|
||||
new FlowsApi(DEFAULT_CONFIG).flowsExecutorGetRaw({
|
||||
flowSlug: this.flowSlug
|
||||
}).then((challengeRaw) => {
|
||||
return challengeRaw.raw.json();
|
||||
}).then((challenge) => {
|
||||
this.challenge = challenge as Challenge;
|
||||
}).catch((e) => {
|
||||
// Catch JSON or Update errors
|
||||
this.errorMessage(e);
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
errorMessage(error: string): void {
|
||||
this.challenge = <ShellChallenge>{
|
||||
type: ChallengeTypeEnum.Shell,
|
||||
body: `<style>
|
||||
.ak-exception {
|
||||
font-family: monospace;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${gettext("Whoops!")}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<h3>${gettext("Something went wrong! Please try again later.")}</h3>
|
||||
<pre class="ak-exception">${error}</pre>
|
||||
</div>`
|
||||
};
|
||||
}
|
||||
|
||||
renderLoading(): TemplateResult {
|
||||
return html`<div class="ak-loading">
|
||||
<ak-spinner size=${SpinnerSize.XLarge}></ak-spinner>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderChallenge(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return this.renderLoading();
|
||||
}
|
||||
switch (this.challenge.type) {
|
||||
case ChallengeTypeEnum.Redirect:
|
||||
console.debug(`authentik/flows: redirecting to ${(this.challenge as RedirectChallenge).to}`);
|
||||
window.location.assign((this.challenge as RedirectChallenge).to);
|
||||
return this.renderLoading();
|
||||
case ChallengeTypeEnum.Shell:
|
||||
return html`${unsafeHTML((this.challenge as ShellChallenge).body)}`;
|
||||
case ChallengeTypeEnum.Native:
|
||||
switch (this.challenge.component) {
|
||||
case "ak-stage-identification":
|
||||
return html`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></ak-stage-identification>`;
|
||||
case "ak-stage-password":
|
||||
return html`<ak-stage-password .host=${this} .challenge=${this.challenge as PasswordChallenge}></ak-stage-password>`;
|
||||
case "ak-stage-captcha":
|
||||
return html`<ak-stage-captcha .host=${this} .challenge=${this.challenge as CaptchaChallenge}></ak-stage-captcha>`;
|
||||
case "ak-stage-consent":
|
||||
return html`<ak-stage-consent .host=${this} .challenge=${this.challenge as ConsentChallenge}></ak-stage-consent>`;
|
||||
case "ak-stage-email":
|
||||
return html`<ak-stage-email .host=${this} .challenge=${this.challenge as EmailChallenge}></ak-stage-email>`;
|
||||
case "ak-stage-autosubmit":
|
||||
return html`<ak-stage-autosubmit .host=${this} .challenge=${this.challenge as AutosubmitChallenge}></ak-stage-autosubmit>`;
|
||||
case "ak-stage-prompt":
|
||||
return html`<ak-stage-prompt .host=${this} .challenge=${this.challenge as PromptChallenge}></ak-stage-prompt>`;
|
||||
case "ak-stage-authenticator-totp":
|
||||
return html`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`;
|
||||
case "ak-stage-authenticator-static":
|
||||
return html`<ak-stage-authenticator-static .host=${this} .challenge=${this.challenge as AuthenticatorStaticChallenge}></ak-stage-authenticator-static>`;
|
||||
case "ak-stage-authenticator-webauthn":
|
||||
return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
|
||||
case "ak-stage-authenticator-validate":
|
||||
return html`<ak-stage-authenticator-validate .host=${this} .challenge=${this.challenge as AuthenticatorValidateStageChallenge}></ak-stage-authenticator-validate>`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.debug(`authentik/flows: unexpected data type ${this.challenge.type}`);
|
||||
break;
|
||||
}
|
||||
return html``;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return this.renderLoading();
|
||||
}
|
||||
return html`
|
||||
${this.loading ? this.renderLoading() : html``}
|
||||
${this.renderChallenge()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
import { gettext } from "django";
|
||||
import { css, CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../form";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export interface AuthenticatorStaticChallenge extends WithUserInfoChallenge {
|
||||
codes: number[];
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-static")
|
||||
export class AuthenticatorStaticStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: AuthenticatorStaticChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES.concat(css`
|
||||
/* Static OTP Tokens */
|
||||
.ak-otp-tokens {
|
||||
list-style: circle;
|
||||
columns: 2;
|
||||
-webkit-columns: 2;
|
||||
-moz-columns: 2;
|
||||
margin-left: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.ak-otp-tokens li {
|
||||
font-size: var(--pf-global--FontSize--2xl);
|
||||
font-family: monospace;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
|
||||
${this.challenge.pending_user}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ak-form-element
|
||||
label="${gettext("Tokens")}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group">
|
||||
<ul class="ak-otp-tokens">
|
||||
${this.challenge.codes.map((token) => {
|
||||
return html`<li>${token}</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</ak-form-element>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "webcomponent-qr-code";
|
||||
import "../form";
|
||||
import { showMessage } from "../../../elements/messages/MessageContainer";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export interface AuthenticatorTOTPChallenge extends WithUserInfoChallenge {
|
||||
config_url: string;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-totp")
|
||||
export class AuthenticatorTOTPStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: AuthenticatorTOTPChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
|
||||
${this.challenge.pending_user}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="otp_uri" value=${this.challenge.config_url} />
|
||||
<ak-form-element>
|
||||
<!-- @ts-ignore -->
|
||||
<qr-code data="${this.challenge.config_url}"></qr-code>
|
||||
<button type="button" class="pf-c-button pf-m-secondary pf-m-progress pf-m-in-progress" @click=${(e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!this.challenge?.config_url) return;
|
||||
navigator.clipboard.writeText(this.challenge?.config_url).then(() => {
|
||||
showMessage({
|
||||
level_tag: "success",
|
||||
message: gettext("Successfully copied TOTP Config.")
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<span class="pf-c-button__progress"><i class="fas fa-copy"></i></span>
|
||||
${gettext("Copy")}
|
||||
</button>
|
||||
</ak-form-element>
|
||||
<ak-form-element
|
||||
label="${gettext("Code")}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.response_errors || {})["code"]}>
|
||||
<!-- @ts-ignore -->
|
||||
<input type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="${gettext("Please enter your TOTP Code")}"
|
||||
autofocus=""
|
||||
autocomplete="one-time-code"
|
||||
class="pf-c-form-control"
|
||||
required="">
|
||||
</ak-form-element>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
import { gettext } from "django";
|
||||
import { css, CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage, StageHost } from "../base";
|
||||
import "./AuthenticatorValidateStageWebAuthn";
|
||||
import "./AuthenticatorValidateStageCode";
|
||||
|
||||
export enum DeviceClasses {
|
||||
STATIC = "static",
|
||||
TOTP = "totp",
|
||||
WEBAUTHN = "webauthn",
|
||||
}
|
||||
|
||||
export interface DeviceChallenge {
|
||||
device_class: DeviceClasses;
|
||||
device_uid: string;
|
||||
challenge: unknown;
|
||||
}
|
||||
|
||||
export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallenge {
|
||||
device_challenges: DeviceChallenge[];
|
||||
}
|
||||
|
||||
export interface AuthenticatorValidateStageChallengeResponse {
|
||||
code: string;
|
||||
webauthn: string;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-validate")
|
||||
export class AuthenticatorValidateStage extends BaseStage implements StageHost {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: AuthenticatorValidateStageChallenge;
|
||||
|
||||
@property({attribute: false})
|
||||
selectedDeviceChallenge?: DeviceChallenge;
|
||||
|
||||
submit<T>(formData?: T): Promise<void> {
|
||||
return this.host?.submit<T>(formData) || Promise.resolve();
|
||||
}
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES.concat(css`
|
||||
ul > li:not(:last-child) {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.authenticator-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
padding: 1rem 0;
|
||||
width: 5rem;
|
||||
}
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.right > * {
|
||||
height: 50%;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
renderDevicePickerSingle(deviceChallenge: DeviceChallenge): TemplateResult {
|
||||
switch (deviceChallenge.device_class) {
|
||||
case DeviceClasses.WEBAUTHN:
|
||||
return html`<i class="fas fa-mobile-alt"></i>
|
||||
<div class="right">
|
||||
<p>${gettext("Authenticator")}</p>
|
||||
<small>${gettext("Use a security key to prove your identity.")}</small>
|
||||
</div>`;
|
||||
case DeviceClasses.TOTP:
|
||||
return html`<i class="fas fa-clock"></i>
|
||||
<div class="right">
|
||||
<p>${gettext("Traditional authenticator")}</p>
|
||||
<small>${gettext("Use a code-based authenticator.")}</small>
|
||||
</div>`;
|
||||
case DeviceClasses.STATIC:
|
||||
return html`<i class="fas fa-key"></i>
|
||||
<div class="right">
|
||||
<p>${gettext("Recovery keys")}</p>
|
||||
<small>${gettext("In case you can't access any other method.")}</small>
|
||||
</div>`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return html``;
|
||||
}
|
||||
|
||||
renderDevicePicker(): TemplateResult {
|
||||
return html`
|
||||
<ul>
|
||||
${this.challenge?.device_challenges.map((challenges) => {
|
||||
return html`<li>
|
||||
<button class="pf-c-button authenticator-button" type="button" @click=${() => {
|
||||
this.selectedDeviceChallenge = challenges;
|
||||
}}>
|
||||
${this.renderDevicePickerSingle(challenges)}
|
||||
</button>
|
||||
</li>`;
|
||||
})}
|
||||
</ul>`;
|
||||
}
|
||||
|
||||
renderDeviceChallenge(): TemplateResult {
|
||||
if (!this.selectedDeviceChallenge) {
|
||||
return html``;
|
||||
}
|
||||
switch (this.selectedDeviceChallenge?.device_class) {
|
||||
case DeviceClasses.STATIC:
|
||||
case DeviceClasses.TOTP:
|
||||
return html`<ak-stage-authenticator-validate-code
|
||||
.host=${this}
|
||||
.challenge=${this.challenge}
|
||||
.deviceChallenge=${this.selectedDeviceChallenge}
|
||||
.showBackButton=${(this.challenge?.device_challenges.length || []) > 1}>
|
||||
</ak-stage-authenticator-validate-code>`;
|
||||
case DeviceClasses.WEBAUTHN:
|
||||
return html`<ak-stage-authenticator-validate-webauthn
|
||||
.host=${this}
|
||||
.challenge=${this.challenge}
|
||||
.deviceChallenge=${this.selectedDeviceChallenge}
|
||||
.showBackButton=${(this.challenge?.device_challenges.length || []) > 1}>
|
||||
</ak-stage-authenticator-validate-webauthn>`;
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
// User only has a single device class, so we don't show a picker
|
||||
if (this.challenge?.device_challenges.length === 1) {
|
||||
this.selectedDeviceChallenge = this.challenge.device_challenges[0];
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
${this.selectedDeviceChallenge ? "" : html`<p class="pf-c-login__main-header-desc">
|
||||
${gettext("Select an identification method.")}
|
||||
</p>`}
|
||||
</header>
|
||||
${this.selectedDeviceChallenge ?
|
||||
this.renderDeviceChallenge() :
|
||||
html`<div class="pf-c-login__main-body">
|
||||
${this.renderDevicePicker()}
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`}`;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import { AuthenticatorValidateStage, AuthenticatorValidateStageChallenge, DeviceChallenge } from "./AuthenticatorValidateStage";
|
||||
import "../form";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
@customElement("ak-stage-authenticator-validate-code")
|
||||
export class AuthenticatorValidateStageWebCode extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: AuthenticatorValidateStageChallenge;
|
||||
|
||||
@property({ attribute: false })
|
||||
deviceChallenge?: DeviceChallenge;
|
||||
|
||||
@property({ type: Boolean })
|
||||
showBackButton = false;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
|
||||
${this.challenge.pending_user}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ak-form-element
|
||||
label="${gettext("Code")}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.response_errors || {})["code"]}>
|
||||
<!-- @ts-ignore -->
|
||||
<input type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="${gettext("Please enter your TOTP Code")}"
|
||||
autofocus=""
|
||||
autocomplete="one-time-code"
|
||||
class="pf-c-form-control"
|
||||
required="">
|
||||
</ak-form-element>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
${this.showBackButton ?
|
||||
html`<li class="pf-c-login__main-footer-links-item">
|
||||
<button class="pf-c-button pf-m-secondary pf-m-block" @click=${() => {
|
||||
if (!this.host) return;
|
||||
(this.host as AuthenticatorValidateStage).selectedDeviceChallenge = undefined;
|
||||
}}>
|
||||
${gettext("Return to device picker")}
|
||||
</button>
|
||||
</li>`:
|
||||
html``}
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { SpinnerSize } from "../../../elements/Spinner";
|
||||
import { transformAssertionForServer, transformCredentialRequestOptions } from "../authenticator_webauthn/utils";
|
||||
import { BaseStage } from "../base";
|
||||
import { AuthenticatorValidateStage, AuthenticatorValidateStageChallenge, DeviceChallenge } from "./AuthenticatorValidateStage";
|
||||
|
||||
@customElement("ak-stage-authenticator-validate-webauthn")
|
||||
export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||
|
||||
@property({attribute: false})
|
||||
challenge?: AuthenticatorValidateStageChallenge;
|
||||
|
||||
@property({attribute: false})
|
||||
deviceChallenge?: DeviceChallenge;
|
||||
|
||||
@property({ type: Boolean })
|
||||
authenticateRunning = false;
|
||||
|
||||
@property()
|
||||
authenticateMessage = "";
|
||||
|
||||
@property({type: Boolean})
|
||||
showBackButton = false;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
async authenticate(): Promise<void> {
|
||||
// convert certain members of the PublicKeyCredentialRequestOptions into
|
||||
// byte arrays as expected by the spec.
|
||||
const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.deviceChallenge?.challenge;
|
||||
const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions);
|
||||
|
||||
// request the authenticator to create an assertion signature using the
|
||||
// credential private key
|
||||
let assertion;
|
||||
try {
|
||||
assertion = await navigator.credentials.get({
|
||||
publicKey: transformedCredentialRequestOptions,
|
||||
});
|
||||
if (!assertion) {
|
||||
throw new Error(gettext("Assertions is empty"));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error when creating credential: ${err}`));
|
||||
}
|
||||
|
||||
// we now have an authentication assertion! encode the byte arrays contained
|
||||
// in the assertion data as strings for posting to the server
|
||||
const transformedAssertionForServer = transformAssertionForServer(<PublicKeyCredential>assertion);
|
||||
|
||||
// post the assertion to the server for verification.
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("webauthn", JSON.stringify(transformedAssertionForServer));
|
||||
await this.host?.submit(formData);
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error when validating assertion on server: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.authenticateWrapper();
|
||||
}
|
||||
|
||||
async authenticateWrapper(): Promise<void> {
|
||||
if (this.authenticateRunning) {
|
||||
return;
|
||||
}
|
||||
this.authenticateRunning = true;
|
||||
this.authenticate().catch((e) => {
|
||||
console.error(gettext(e));
|
||||
this.authenticateMessage = e.toString();
|
||||
}).finally(() => {
|
||||
this.authenticateRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-c-login__main-body">
|
||||
${this.authenticateRunning ?
|
||||
html`<div class="pf-c-empty-state__content">
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-l-bullseye__item">
|
||||
<ak-spinner size="${SpinnerSize.XLarge}"></ak-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</div>`:
|
||||
html`
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<p class="pf-m-block">${this.authenticateMessage}</p>
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" @click=${() => {
|
||||
this.authenticateWrapper();
|
||||
}}>
|
||||
${gettext("Retry authentication")}
|
||||
</button>
|
||||
</div>`}
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
${this.showBackButton ?
|
||||
html`<li class="pf-c-login__main-footer-links-item">
|
||||
<button class="pf-c-button pf-m-secondary pf-m-block" @click=${() => {
|
||||
if (!this.host) return;
|
||||
(this.host as AuthenticatorValidateStage).selectedDeviceChallenge = undefined;
|
||||
}}>
|
||||
${gettext("Return to device picker")}
|
||||
</button>
|
||||
</li>`:
|
||||
html``}
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
import { gettext } from "django";
|
||||
import { customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { SpinnerSize } from "../../../elements/Spinner";
|
||||
import { BaseStage } from "../base";
|
||||
import { Assertion, transformCredentialCreateOptions, transformNewAssertionForServer } from "./utils";
|
||||
|
||||
export interface WebAuthnAuthenticatorRegisterChallenge extends WithUserInfoChallenge {
|
||||
registration: PublicKeyCredentialCreationOptions;
|
||||
}
|
||||
|
||||
export interface WebAuthnAuthenticatorRegisterChallengeResponse {
|
||||
response: Assertion;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-webauthn")
|
||||
export class WebAuthnAuthenticatorRegisterStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: WebAuthnAuthenticatorRegisterChallenge;
|
||||
|
||||
@property({type: Boolean})
|
||||
registerRunning = false;
|
||||
|
||||
@property()
|
||||
registerMessage = "";
|
||||
|
||||
createRenderRoot(): Element | ShadowRoot {
|
||||
return this;
|
||||
}
|
||||
|
||||
async register(): Promise<void> {
|
||||
if (!this.challenge) {
|
||||
return;
|
||||
}
|
||||
// convert certain members of the PublicKeyCredentialCreateOptions into
|
||||
// byte arrays as expected by the spec.
|
||||
const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(this.challenge?.registration);
|
||||
|
||||
// request the authenticator(s) to create a new credential keypair.
|
||||
let credential;
|
||||
try {
|
||||
credential = <PublicKeyCredential> await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreateOptions
|
||||
});
|
||||
if (!credential) {
|
||||
throw new Error("Credential is empty");
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error creating credential: ${err}`));
|
||||
}
|
||||
|
||||
// we now have a new credential! We now need to encode the byte arrays
|
||||
// in the credential into strings, for posting to our server.
|
||||
const newAssertionForServer = transformNewAssertionForServer(credential);
|
||||
|
||||
// post the transformed credential data to the server for validation
|
||||
// and storing the public key
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("response", JSON.stringify(newAssertionForServer));
|
||||
await this.host?.submit(formData);
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Server validation of credential failed: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
async registerWrapper(): Promise<void> {
|
||||
if (this.registerRunning) {
|
||||
return;
|
||||
}
|
||||
this.registerRunning = true;
|
||||
this.register().catch((e) => {
|
||||
console.error(e);
|
||||
this.registerMessage = e.toString();
|
||||
}).finally(() => {
|
||||
this.registerRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.registerWrapper();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge?.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
${this.registerRunning ?
|
||||
html`<div class="pf-c-empty-state__content">
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-l-bullseye__item">
|
||||
<ak-spinner size="${SpinnerSize.XLarge}"></ak-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</div>`:
|
||||
html`
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
${this.challenge?.response_errors ?
|
||||
html`<p class="pf-m-block">${this.challenge.response_errors["response"][0].string}</p>`:
|
||||
html``}
|
||||
<p class="pf-m-block">${this.registerMessage}</p>
|
||||
<button class="pf-c-button pf-m-primary pf-m-block" @click=${() => {
|
||||
this.registerWrapper();
|
||||
}}>
|
||||
${gettext("Register device")}
|
||||
</button>
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
124
web/src/flows/stages/authenticator_webauthn/utils.ts
Normal file
124
web/src/flows/stages/authenticator_webauthn/utils.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import * as base64js from "base64-js";
|
||||
|
||||
export function b64enc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
export function b64RawEnc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
export function hexEncode(buf: Uint8Array): string {
|
||||
return Array.from(buf)
|
||||
.map(function (x) {
|
||||
return ("0" + x.toString(16)).substr(-2);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms items in the credentialCreateOptions generated on the server
|
||||
* into byte arrays expected by the navigator.credentials.create() call
|
||||
*/
|
||||
export function transformCredentialCreateOptions(credentialCreateOptions: PublicKeyCredentialCreationOptions): PublicKeyCredentialCreationOptions {
|
||||
const user = credentialCreateOptions.user;
|
||||
user.id = u8arr(credentialCreateOptions.user.id.toString());
|
||||
const challenge = u8arr(credentialCreateOptions.challenge.toString());
|
||||
|
||||
const transformedCredentialCreateOptions = Object.assign(
|
||||
{}, credentialCreateOptions,
|
||||
{ challenge, user });
|
||||
|
||||
return transformedCredentialCreateOptions;
|
||||
}
|
||||
|
||||
export interface Assertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
attObj: string;
|
||||
clientData: string;
|
||||
registrationClientExtensions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the binary data in the credential into base64 strings
|
||||
* for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
export function transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
|
||||
const attObj = new Uint8Array(
|
||||
(<AuthenticatorAttestationResponse>newAssertion.response).attestationObject);
|
||||
const clientDataJSON = new Uint8Array(
|
||||
newAssertion.response.clientDataJSON);
|
||||
const rawId = new Uint8Array(
|
||||
newAssertion.rawId);
|
||||
|
||||
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
attObj: b64enc(attObj),
|
||||
clientData: b64enc(clientDataJSON),
|
||||
registrationClientExtensions: JSON.stringify(registrationClientExtensions)
|
||||
};
|
||||
}
|
||||
|
||||
function u8arr(input: string): Uint8Array {
|
||||
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function transformCredentialRequestOptions(credentialRequestOptions: PublicKeyCredentialRequestOptions): PublicKeyCredentialRequestOptions {
|
||||
const challenge = u8arr(credentialRequestOptions.challenge.toString());
|
||||
|
||||
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(credentialDescriptor => {
|
||||
const id = u8arr(credentialDescriptor.id.toString());
|
||||
return Object.assign({}, credentialDescriptor, { id });
|
||||
});
|
||||
|
||||
const transformedCredentialRequestOptions = Object.assign(
|
||||
{},
|
||||
credentialRequestOptions,
|
||||
{ challenge, allowCredentials });
|
||||
|
||||
return transformedCredentialRequestOptions;
|
||||
}
|
||||
|
||||
export interface AuthAssertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
clientData: string;
|
||||
authData: string;
|
||||
signature: string;
|
||||
assertionClientExtensions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the binary data in the assertion into strings for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
export function transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion{
|
||||
const response = <AuthenticatorAssertionResponse> newAssertion.response;
|
||||
const authData = new Uint8Array(response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
||||
const rawId = new Uint8Array(newAssertion.rawId);
|
||||
const sig = new Uint8Array(response.signature);
|
||||
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
||||
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
authData: b64RawEnc(authData),
|
||||
clientData: b64RawEnc(clientDataJSON),
|
||||
signature: hexEncode(sig),
|
||||
assertionClientExtensions: JSON.stringify(assertionClientExtensions)
|
||||
};
|
||||
}
|
||||
57
web/src/flows/stages/autosubmit/AutosubmitStage.ts
Normal file
57
web/src/flows/stages/autosubmit/AutosubmitStage.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../../../elements/Spinner";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export interface AutosubmitChallenge extends WithUserInfoChallenge {
|
||||
url: string;
|
||||
attrs: { [key: string]: string };
|
||||
}
|
||||
|
||||
@customElement("ak-stage-autosubmit")
|
||||
export class AutosubmitStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: AutosubmitChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
updated(): void {
|
||||
this.shadowRoot?.querySelectorAll("form").forEach((form) => {form.submit();});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" action="${this.challenge.url}" method="POST">
|
||||
${Object.entries(this.challenge.attrs).map(([ key, value ]) => {
|
||||
return html`<input type="hidden" name="${key}" value="${value}">`;
|
||||
})}
|
||||
<ak-spinner></ak-spinner>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
21
web/src/flows/stages/base.ts
Normal file
21
web/src/flows/stages/base.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { LitElement } from "lit-element";
|
||||
|
||||
export interface StageHost {
|
||||
submit<T>(formData?: T): Promise<void>;
|
||||
}
|
||||
|
||||
export class BaseStage extends LitElement {
|
||||
|
||||
host?: StageHost;
|
||||
|
||||
submitForm(e: Event): void {
|
||||
e.preventDefault();
|
||||
const object: {
|
||||
[key: string]: unknown;
|
||||
} = {};
|
||||
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
|
||||
form.forEach((value, key) => object[key] = value);
|
||||
this.host?.submit(object);
|
||||
}
|
||||
|
||||
}
|
||||
88
web/src/flows/stages/captcha/CaptchaStage.ts
Normal file
88
web/src/flows/stages/captcha/CaptchaStage.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { SpinnerSize } from "../../../elements/Spinner";
|
||||
import { BaseStage } from "../base";
|
||||
import "../form";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export interface CaptchaChallenge extends WithUserInfoChallenge {
|
||||
site_key: string;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-captcha")
|
||||
export class CaptchaStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: CaptchaChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
submitFormAlt(token: string): void {
|
||||
const form = new FormData();
|
||||
form.set("token", token);
|
||||
this.host?.submit(form);
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://www.google.com/recaptcha/api.js";//?render=${this.challenge?.site_key}`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
const captchaContainer = document.createElement("div");
|
||||
document.body.appendChild(captchaContainer);
|
||||
script.onload = () => {
|
||||
console.debug("authentik/stages/captcha: script loaded");
|
||||
grecaptcha.ready(() => {
|
||||
if (!this.challenge?.site_key) return;
|
||||
console.debug("authentik/stages/captcha: ready");
|
||||
const captchaId = grecaptcha.render(captchaContainer, {
|
||||
sitekey: this.challenge.site_key,
|
||||
callback: (token) => {
|
||||
this.submitFormAlt(token);
|
||||
},
|
||||
size: "invisible",
|
||||
});
|
||||
grecaptcha.execute(captchaId);
|
||||
});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
|
||||
${this.challenge.pending_user}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ak-loading">
|
||||
<ak-spinner size=${SpinnerSize.XLarge}></ak-spinner>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
78
web/src/flows/stages/consent/ConsentStage.ts
Normal file
78
web/src/flows/stages/consent/ConsentStage.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export interface Permission {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ConsentChallenge extends WithUserInfoChallenge {
|
||||
|
||||
header_text: string;
|
||||
permissions?: Permission[];
|
||||
|
||||
}
|
||||
|
||||
@customElement("ak-stage-consent")
|
||||
export class ConsentStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: ConsentChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
|
||||
${this.challenge.pending_user}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group">
|
||||
<p id="header-text">
|
||||
${this.challenge.header_text}
|
||||
</p>
|
||||
<p>${gettext("Application requires following permissions")}</p>
|
||||
<ul class="pf-c-list" id="permmissions">
|
||||
${(this.challenge.permissions || []).map((permission) => {
|
||||
return html`<li data-permission-code="${permission.id}">${permission.name}</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
50
web/src/flows/stages/email/EmailStage.ts
Normal file
50
web/src/flows/stages/email/EmailStage.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { Challenge } from "../../../api";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export type EmailChallenge = Challenge;
|
||||
|
||||
@customElement("ak-stage-email")
|
||||
export class EmailStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: EmailChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
${gettext("Check your Emails for a password reset link.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Send Email again.")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
50
web/src/flows/stages/form.ts
Normal file
50
web/src/flows/stages/form.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { customElement, LitElement, CSSResult, property, css } from "lit-element";
|
||||
import { TemplateResult, html } from "lit-html";
|
||||
import { Error } from "../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../common/styles";
|
||||
|
||||
@customElement("ak-form-element")
|
||||
export class FormElement extends LitElement {
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES.concat(
|
||||
css`
|
||||
slot {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property()
|
||||
label?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
errors?: Error[];
|
||||
|
||||
updated(): void {
|
||||
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach(input => {
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text">${this.label}</span>
|
||||
${this.required ? html`<span class="pf-c-form__label-required" aria-hidden="true">*</span>` : html``}
|
||||
</label>
|
||||
<slot></slot>
|
||||
${(this.errors || []).map((error) => {
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error">${error.string}</p>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
}
|
||||
116
web/src/flows/stages/identification/IdentificationStage.ts
Normal file
116
web/src/flows/stages/identification/IdentificationStage.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../form";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
import { Challenge } from "../../../api/Flows";
|
||||
|
||||
export interface IdentificationChallenge extends Challenge {
|
||||
|
||||
input_type: string;
|
||||
primary_action: string;
|
||||
sources?: UILoginButton[];
|
||||
|
||||
application_pre?: string;
|
||||
|
||||
enroll_url?: string;
|
||||
recovery_url?: string;
|
||||
|
||||
}
|
||||
|
||||
export interface UILoginButton {
|
||||
name: string;
|
||||
url: string;
|
||||
icon_url?: string;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-identification")
|
||||
export class IdentificationStage extends BaseStage {
|
||||
|
||||
@property({attribute: false})
|
||||
challenge?: IdentificationChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
renderSource(source: UILoginButton): TemplateResult {
|
||||
let icon = html`<i class="pf-icon pf-icon-arrow" title="${source.name}"></i>`;
|
||||
if (source.icon_url) {
|
||||
icon = html`<img src="${source.icon_url}" alt="${source.name}">`;
|
||||
}
|
||||
return html`<li class="pf-c-login__main-footer-links-item">
|
||||
<a href="${source.url}" class="pf-c-login__main-footer-links-item-link">
|
||||
${icon}
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
renderFooter(): TemplateResult {
|
||||
if (!this.challenge?.enroll_url && !this.challenge?.recovery_url) {
|
||||
return html``;
|
||||
}
|
||||
return html`<div class="pf-c-login__main-footer-band">
|
||||
${this.challenge.enroll_url ? html`
|
||||
<p class="pf-c-login__main-footer-band-item">
|
||||
${gettext("Need an account?")}
|
||||
<a id="enroll" href="${this.challenge.enroll_url}">${gettext("Sign up.")}</a>
|
||||
</p>` : html``}
|
||||
${this.challenge.recovery_url ? html`
|
||||
<p class="pf-c-login__main-footer-band-item">
|
||||
${gettext("Need an account?")}
|
||||
<a id="recovery" href="${this.challenge.recovery_url}">${gettext("Forgot username or password?")}</a>
|
||||
</p>` : html``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
|
||||
${this.challenge.application_pre ?
|
||||
html`<p>
|
||||
${gettext(`Login to continue to ${this.challenge.application_pre}.`)}
|
||||
</p>`:
|
||||
html``}
|
||||
|
||||
<ak-form-element
|
||||
label="${gettext("Email or Username")}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.response_errors || {})["uid_field"]}>
|
||||
<input type="text"
|
||||
name="uid_field"
|
||||
placeholder="Email or Username"
|
||||
autofocus=""
|
||||
autocomplete="username"
|
||||
class="pf-c-form-control"
|
||||
required="">
|
||||
</ak-form-element>
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${this.challenge.primary_action}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
${(this.challenge.sources || []).map((source) => {
|
||||
return this.renderSource(source);
|
||||
})}
|
||||
</ul>
|
||||
${this.renderFooter()}
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
77
web/src/flows/stages/password/PasswordStage.ts
Normal file
77
web/src/flows/stages/password/PasswordStage.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../form";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
|
||||
export interface PasswordChallenge extends WithUserInfoChallenge {
|
||||
recovery_url?: string;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-password")
|
||||
export class PasswordStage extends BaseStage {
|
||||
|
||||
@property({attribute: false})
|
||||
challenge?: PasswordChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="left">
|
||||
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
|
||||
${this.challenge.pending_user}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="/flows/-/cancel/">${gettext("Not you?")}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ak-form-element
|
||||
label="${gettext("Password")}"
|
||||
?required="${true}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.response_errors || {})["password"]}>
|
||||
<input type="password"
|
||||
name="password"
|
||||
placeholder="${gettext("Please enter your password")}"
|
||||
autofocus=""
|
||||
autocomplete="current-password"
|
||||
class="pf-c-form-control"
|
||||
required="">
|
||||
</ak-form-element>
|
||||
|
||||
${this.challenge.recovery_url ?
|
||||
html`<a href="${this.challenge.recovery_url}">
|
||||
${gettext("Forgot password?")}</a>` : ""}
|
||||
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
147
web/src/flows/stages/prompt/PromptStage.ts
Normal file
147
web/src/flows/stages/prompt/PromptStage.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { gettext } from "django";
|
||||
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { unsafeHTML } from "lit-html/directives/unsafe-html";
|
||||
import { COMMON_STYLES } from "../../../common/styles";
|
||||
import { BaseStage } from "../base";
|
||||
import "../form";
|
||||
import "../../../elements/utils/LoadingState";
|
||||
import { Challenge } from "../../../api/Flows";
|
||||
|
||||
export interface Prompt {
|
||||
field_key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
placeholder: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface PromptChallenge extends Challenge {
|
||||
fields: Prompt[];
|
||||
}
|
||||
|
||||
@customElement("ak-stage-prompt")
|
||||
export class PromptStage extends BaseStage {
|
||||
|
||||
@property({attribute: false})
|
||||
challenge?: PromptChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return COMMON_STYLES;
|
||||
}
|
||||
|
||||
renderPromptInner(prompt: Prompt): string {
|
||||
switch (prompt.type) {
|
||||
case "text":
|
||||
return `<input
|
||||
type="text"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="">`;
|
||||
case "username":
|
||||
return `<input
|
||||
type="text"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="username"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="">`;
|
||||
case "email":
|
||||
return `<input
|
||||
type="email"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="">`;
|
||||
case "password":
|
||||
return `<input
|
||||
type="password"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="new-password"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}>`;
|
||||
case "number":
|
||||
return `<input
|
||||
type="number"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}>`;
|
||||
case "checkbox":
|
||||
return `<input
|
||||
type="checkbox"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}>`;
|
||||
case "date":
|
||||
return `<input
|
||||
type="date"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}>`;
|
||||
case "date-time":
|
||||
return `<input
|
||||
type="datetime"
|
||||
name="${prompt.field_key}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}>`;
|
||||
case "separator":
|
||||
return "<hr>";
|
||||
case "hidden":
|
||||
return `<input
|
||||
type="hidden"
|
||||
name="${prompt.field_key}"
|
||||
value="${prompt.placeholder}"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}>`;
|
||||
case "static":
|
||||
return `<p
|
||||
class="pf-c-form-control">${prompt.placeholder}
|
||||
</p>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-loading-state></ak-loading-state>`;
|
||||
}
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${this.challenge.title}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
|
||||
${this.challenge.fields.map((prompt) => {
|
||||
return html`<ak-form-element
|
||||
label="${prompt.label}"
|
||||
?required="${prompt.required}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.response_errors || {})[prompt.field_key]}>
|
||||
${unsafeHTML(this.renderPromptInner(prompt))}
|
||||
</ak-form-element>`;
|
||||
})}
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${gettext("Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user