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:
Jens L
2021-03-08 11:14:00 +01:00
committed by GitHub
parent 1c6d498621
commit 2852fa3c5e
146 changed files with 1593 additions and 1882 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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