web/admin: Text and Textarea Fields that "hide" their contents until prompted (#15024)

* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web/admin: Provide `hidden` text and textarea components

## Details

This commit provides two new elements (technically, since they're API-unaware), one for `<input
type="text">`, and one for `<textarea>`, that provide for the ability to create fields that are (or
can be) hidden. A new boolean attribute, `revealed`, shows the state of the component (the content
is therefore *not* revealed by default).

It also includes a third new element, `ak-visibility-toggle`, that creates a hide/show toggle with
all the right icons, styling, and eventing.  It's straightforward, and isolating it improved the
DX of everything that uses that feature by quite a bit.

Storybook stories (with autodoc documentation) have been provided for `ak-hidden-text-input`,
`ak-hidden-textarea-input`, and `ak-visibility-toggle`.

## Maintenance Notice

As a maintenance detail, the field `ak-private-text` has been renamed `ak-secret-text` to reflect
its usage, and the places where it was used have all been changed to reflect that update.

* web/component: embed styling (for now) to handle the lightDom/shadowDom/slot conflicts in HorizontalLightComponent and HorizontalFormElement

* Comments and Types. I really shouldn't have to catch this stuff with my eyeballs.

* fix typo

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg
2025-06-13 16:48:34 -07:00
committed by GitHub
parent 2aceed285e
commit 3c2ce40afd
19 changed files with 841 additions and 50 deletions

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-textarea-input.js"; import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -46,7 +46,7 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-textarea-input <ak-secret-textarea-input
label=${msg("Certificate")} label=${msg("Certificate")}
name="certificateData" name="certificateData"
input-hint="code" input-hint="code"
@ -54,8 +54,8 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
required required
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
help=${msg("PEM-encoded Certificate data.")} help=${msg("PEM-encoded Certificate data.")}
></ak-private-textarea-input> ></ak-secret-textarea-input>
<ak-private-textarea-input <ak-secret-textarea-input
label=${msg("Private Key")} label=${msg("Private Key")}
name="keyData" name="keyData"
input-hint="code" input-hint="code"
@ -63,7 +63,7 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
help=${msg( help=${msg(
"Optional Private Key. If this is set, you can use this keypair for encryption.", "Optional Private Key. If this is set, you can use this keypair for encryption.",
)} )}
></ak-private-textarea-input>`; ></ak-secret-textarea-input>`;
} }
} }

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/common/constants"; import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/common/constants";
import "@goauthentik/components/ak-private-textarea-input.js"; import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -62,13 +62,13 @@ export class EnterpriseLicenseForm extends ModelForm<License, string> {
value="${ifDefined(this.installID)}" value="${ifDefined(this.installID)}"
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-textarea-input <ak-secret-textarea-input
name="key" name="key"
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
label=${msg("License key")} label=${msg("License key")}
input-hint="code" input-hint="code"
> >
</ak-private-textarea-input>`; </ak-secret-textarea-input>`;
} }
} }

View File

@ -7,8 +7,8 @@ import {
UserMatchingModeToLabel, UserMatchingModeToLabel,
} from "@goauthentik/admin/sources/oauth/utils"; } from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/components/ak-private-textarea-input.js"; import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/components/ak-textarea-input";
@ -248,22 +248,22 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm<Ke
value=${ifDefined(this.instance?.syncPrincipal)} value=${ifDefined(this.instance?.syncPrincipal)}
help=${msg("Principal used to authenticate to the KDC for syncing.")} help=${msg("Principal used to authenticate to the KDC for syncing.")}
></ak-text-input> ></ak-text-input>
<ak-private-text-input <ak-secret-text-input
name="syncPassword" name="syncPassword"
label=${msg("Sync password")} label=${msg("Sync password")}
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
help=${msg( help=${msg(
"Password used to authenticate to the KDC for syncing. Optional if Sync keytab or Sync credentials cache is provided.", "Password used to authenticate to the KDC for syncing. Optional if Sync keytab or Sync credentials cache is provided.",
)} )}
></ak-private-text-input> ></ak-secret-text-input>
<ak-private-textarea-input <ak-secret-textarea-input
name="syncKeytab" name="syncKeytab"
label=${msg("Sync keytab")} label=${msg("Sync keytab")}
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
help=${msg( help=${msg(
"Keytab used to authenticate to the KDC for syncing. Optional if Sync password or Sync credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.", "Keytab used to authenticate to the KDC for syncing. Optional if Sync password or Sync credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.",
)} )}
></ak-private-textarea-input> ></ak-secret-textarea-input>
<ak-text-input <ak-text-input
name="syncCcache" name="syncCcache"
label=${msg("Sync credentials cache")} label=${msg("Sync credentials cache")}
@ -285,14 +285,14 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm<Ke
"Force the use of a specific server name for SPNEGO. Must be in the form HTTP@domain", "Force the use of a specific server name for SPNEGO. Must be in the form HTTP@domain",
)} )}
></ak-text-input> ></ak-text-input>
<ak-private-textarea-input <ak-secret-textarea-input
name="spnegoKeytab" name="spnegoKeytab"
label=${msg("SPNEGO keytab")} label=${msg("SPNEGO keytab")}
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
help=${msg( help=${msg(
"Keytab used for SPNEGO. Optional if SPNEGO credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.", "Keytab used for SPNEGO. Optional if SPNEGO credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.",
)} )}
></ak-private-textarea-input> ></ak-secret-textarea-input>
<ak-text-input <ak-text-input
name="spnegoCcache" name="spnegoCcache"
label=${msg("SPNEGO credentials cache")} label=${msg("SPNEGO credentials cache")}

View File

@ -2,7 +2,7 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search";
import { placeholderHelperText } from "@goauthentik/admin/helperText"; import { placeholderHelperText } from "@goauthentik/admin/helperText";
import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -260,11 +260,11 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
class="pf-c-form-control" class="pf-c-form-control"
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-text-input <ak-secret-text-input
label=${msg("Bind Password")} label=${msg("Bind Password")}
name="bindPassword" name="bindPassword"
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
></ak-private-text-input> ></ak-secret-text-input>
<ak-form-element-horizontal label=${msg("Base DN")} required name="baseDn"> <ak-form-element-horizontal label=${msg("Base DN")} required name="baseDn">
<input <input
type="text" type="text"

View File

@ -8,8 +8,8 @@ import {
UserMatchingModeToLabel, UserMatchingModeToLabel,
} from "@goauthentik/admin/sources/oauth/utils"; } from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-textarea-input.js";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
@ -441,14 +441,14 @@ export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm<OAuth
/> />
<p class="pf-c-form__helper-text">${msg("Also known as Client ID.")}</p> <p class="pf-c-form__helper-text">${msg("Also known as Client ID.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-textarea-input <ak-secret-textarea-input
label=${msg("Consumer secret")} label=${msg("Consumer secret")}
name="consumerSecret" name="consumerSecret"
input-hint="code" input-hint="code"
help=${msg("Also known as Client Secret.")} help=${msg("Also known as Client Secret.")}
required required
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
></ak-private-textarea-input> ></ak-secret-textarea-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="additionalScopes"> <ak-form-element-horizontal label=${msg("Scopes")} name="additionalScopes">
<input <input
type="text" type="text"

View File

@ -1,7 +1,7 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/forms/SearchSelect";
@ -95,13 +95,13 @@ export class AuthenticatorDuoStageForm extends BaseStageForm<AuthenticatorDuoSta
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-text-input <ak-secret-text-input
name="clientSecret" name="clientSecret"
label=${msg("Secret key")} label=${msg("Secret key")}
input-hint="code" input-hint="code"
required required
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
></ak-private-text-input> ></ak-secret-text-input>
</div> </div>
</ak-form-group> </ak-form-group>
<ak-form-group> <ak-form-group>
@ -125,12 +125,12 @@ export class AuthenticatorDuoStageForm extends BaseStageForm<AuthenticatorDuoSta
spellcheck="false" spellcheck="false"
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-text-input <ak-secret-text-input
name="adminSecretKey" name="adminSecretKey"
label=${msg("Secret key")} label=${msg("Secret key")}
input-hint="code" input-hint="code"
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
></ak-private-text-input> ></ak-secret-text-input>
</div> </div>
</ak-form-group> </ak-form-group>
<ak-form-group expanded> <ak-form-group expanded>

View File

@ -1,7 +1,7 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/Radio";
@ -77,11 +77,11 @@ export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmai
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-text-input <ak-secret-text-input
name="password" name="password"
label=${msg("SMTP Password")} label=${msg("SMTP Password")}
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
></ak-private-text-input> ></ak-secret-text-input>
<ak-form-element-horizontal name="useTls"> <ak-form-element-horizontal name="useTls">
<label class="pf-c-switch"> <label class="pf-c-switch">

View File

@ -1,7 +1,7 @@
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-number-input"; import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-private-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -70,7 +70,7 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-text-input <ak-secret-text-input
name="privateKey" name="privateKey"
label=${msg("Private Key")} label=${msg("Private Key")}
input-hint="code" input-hint="code"
@ -79,7 +79,7 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
help=${msg( help=${msg(
"Private key, acquired from https://www.google.com/recaptcha/intro/v3.html.", "Private key, acquired from https://www.google.com/recaptcha/intro/v3.html.",
)} )}
></ak-private-text-input> ></ak-secret-text-input>
<ak-switch-input <ak-switch-input
name="interactive" name="interactive"

View File

@ -1,6 +1,6 @@
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js"; import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/utils/TimeDeltaHelp"; import "@goauthentik/elements/utils/TimeDeltaHelp";
@ -73,11 +73,11 @@ export class EmailStageForm extends BaseStageForm<EmailStage> {
class="pf-c-form-control" class="pf-c-form-control"
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-private-text-input <ak-secret-text-input
label=${msg("SMTP Password")} label=${msg("SMTP Password")}
name="password" name="password"
?revealed=${this.instance === undefined} ?revealed=${this.instance === undefined}
></ak-private-text-input> ></ak-secret-text-input>
<ak-form-element-horizontal name="useTls"> <ak-form-element-horizontal name="useTls">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -1,4 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement, type AKElementProps } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/HorizontalFormElement.js"; import "@goauthentik/elements/forms/HorizontalFormElement.js";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html, nothing } from "lit";
@ -6,6 +6,19 @@ import { property } from "lit/decorators.js";
type HelpType = TemplateResult | typeof nothing; type HelpType = TemplateResult | typeof nothing;
export interface HorizontalLightComponentProps<T> extends AKElementProps {
name: string;
label?: string;
required?: boolean;
help?: string;
bighelp?: TemplateResult | TemplateResult[];
hidden?: boolean;
invalid?: boolean;
errorMessages?: string[];
value?: T;
inputHint?: string;
}
export class HorizontalLightComponent<T> extends AKElement { export class HorizontalLightComponent<T> extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to // we're not actually using that and, for the meantime, we need the form handlers to be able to
@ -18,37 +31,81 @@ export class HorizontalLightComponent<T> extends AKElement {
return this; return this;
} }
/**
* The name attribute for the form element
* @property
* @attribute
*/
@property({ type: String, reflect: true }) @property({ type: String, reflect: true })
name!: string; name!: string;
/**
* The label for the input control
* @property
* @attribute
*/
@property({ type: String, reflect: true }) @property({ type: String, reflect: true })
label = ""; label = "";
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
required = false; required = false;
/**
* Help text to display below the form element. Optional
* @property
* @attribute
*/
@property({ type: String, reflect: true }) @property({ type: String, reflect: true })
help = ""; help = "";
/**
* Extended help content. Optional. Expects to be a TemplateResult
* @property
*/
@property({ type: Object }) @property({ type: Object })
bighelp?: TemplateResult | TemplateResult[]; bighelp?: TemplateResult | TemplateResult[];
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
hidden = false; hidden = false;
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
invalid = false; invalid = false;
/**
* @property
*/
@property({ attribute: false }) @property({ attribute: false })
errorMessages: string[] = []; errorMessages: string[] = [];
/**
* @attribute
* @property
*/
@property({ attribute: false }) @property({ attribute: false })
value?: T; value?: T;
/**
* Input hint.
* - `code`: uses a monospace font and disables spellcheck & autocomplete
* @property
* @attribute
*/
@property({ type: String, attribute: "input-hint" }) @property({ type: String, attribute: "input-hint" })
inputHint = ""; inputHint = "";
renderControl() { protected renderControl() {
throw new Error("Must be implemented in a subclass"); throw new Error("Must be implemented in a subclass");
} }

View File

@ -0,0 +1,159 @@
import { bound } from "#elements/decorators/bound";
import { msg } from "@lit/localize";
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
HorizontalLightComponent,
HorizontalLightComponentProps,
} from "./HorizontalLightComponent";
import "./ak-visibility-toggle.js";
import type { VisibilityToggleProps } from "./ak-visibility-toggle.js";
type BaseProps = HorizontalLightComponentProps<string> &
Pick<VisibilityToggleProps, "showMessage" | "hideMessage">;
export interface AkHiddenTextInputProps extends BaseProps {
revealed: boolean;
placeholder?: string;
}
export type InputLike = HTMLTextAreaElement | HTMLInputElement;
/**
* @element ak-hidden-text-input
* @class AkHiddenTextInput
*
* A text-input field with a visibility control, so you can show/hide sensitive fields.
*
* ## CSS Parts
* @csspart container - The main container div
* @csspart input - The input element
* @csspart toggle - The visibility toggle button
*
*/
@customElement("ak-hidden-text-input")
export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
extends HorizontalLightComponent<string>
implements AkHiddenTextInputProps
{
public static get styles() {
return [
css`
main {
display: flex;
}
`,
];
}
/**
* @property
* @attribute
*/
@property({ type: String, reflect: true })
public value = "";
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
public revealed = false;
/**
* Text for when the input has no set value
*
* @property
* @attribute
*/
@property({ type: String })
public placeholder?: string;
/**
* Specify kind of help the browser should try to provide
*
* @property
* @attribute
*/
@property({ type: String })
public autocomplete?: "none" | AutoFill;
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "show-message" })
public showMessage = msg("Show field content");
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "hide-message" })
public hideMessage = msg("Hide field content");
@query("#main > input")
protected inputField!: T;
@bound
private handleToggleVisibility() {
this.revealed = !this.revealed;
// Maintain focus on input after toggle
this.updateComplete.then(() => {
if (this.inputField && document.activeElement === this) {
this.inputField.focus();
}
});
}
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
// in the LightDom so the inner components actually inherit styling, the normal `css` options
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
// refresh.
protected renderInputField(setValue: (ev: InputEvent) => void, code: boolean) {
return html` <input
style="flex: 1 1 auto; min-width: 0;"
part="input"
type=${this.revealed ? "text" : "password"}
@input=${setValue}
value=${ifDefined(this.value)}
placeholder=${ifDefined(this.placeholder)}
class="${classMap({
"pf-c-form-control": true,
"pf-m-monospace": code,
})}"
spellcheck=${code ? "false" : "true"}
?required=${this.required}
/>`;
}
protected override renderControl() {
const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => {
this.value = (ev.target as T).value;
};
return html` <div style="display: flex; gap: 0.25rem">
${this.renderInputField(setValue, code)}
<!-- -->
<ak-visibility-toggle
part="toggle"
style="flex: 0 0 auto; align-self: flex-start"
?open=${this.revealed}
show-message=${this.showMessage}
hide-message=${this.hideMessage}
@click=${() => (this.revealed = !this.revealed)}
></ak-visibility-toggle>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-hidden-text-input": AkHiddenTextInput;
}
}

View File

@ -0,0 +1,128 @@
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { AkHiddenTextInput, type AkHiddenTextInputProps } from "./ak-hidden-text-input.js";
export interface AkHiddenTextAreaInputProps extends AkHiddenTextInputProps {
/**
* Number of visible text lines (rows)
*/
rows?: number;
/**
* Number of visible character width (cols)
*/
cols?: number;
/**
* How the textarea can be resized
*/
resize?: "none" | "both" | "horizontal" | "vertical";
/**
* Whether text should wrap
*/
wrap?: "soft" | "hard" | "off";
}
/**
* @element ak-hidden-text-input
* @class AkHiddenTextInput
*
* A text-input field with a visibility control, so you can show/hide sensitive fields.
*
* ## CSS Parts
* @csspart container - The main container div
* @csspart input - The input element
* @csspart toggle - The visibility toggle button
*
*/
@customElement("ak-hidden-textarea-input")
export class AkHiddenTextAreaInput
extends AkHiddenTextInput<HTMLTextAreaElement>
implements AkHiddenTextAreaInputProps
{
/* These are mostly just forwarded to the textarea component. */
/**
* @property
* @attribute
*/
@property({ type: Number })
rows?: number = 4;
/**
* @property
* @attribute
*/
@property({ type: Number })
cols?: number;
/**
* @property
* @attribute
*
* You want `resize=true` so that the resize value is visible in the component tag, activating
* the CSS associated with these values.
*/
@property({ type: String, reflect: true })
resize?: "none" | "both" | "horizontal" | "vertical" = "vertical";
/**
* @property
* @attribute
*/
@property({ type: String })
wrap?: "soft" | "hard" | "off" = "soft";
@query("#main > textarea")
protected inputField!: HTMLTextAreaElement;
get displayValue() {
const value = this.value ?? "";
if (this.revealed) {
return value;
}
return value
.split("\n")
.reduce((acc: string[], line: string) => [...acc, "*".repeat(line.length)], [])
.join("\n");
}
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
// in the LightDom so the inner components actually inherit styling, the normal `css` options
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
// refresh.
protected override renderInputField(setValue: (ev: InputEvent) => void, code: boolean) {
const wrap = this.revealed ? this.wrap : "soft";
return html`
<textarea
style="flex: 1 1 auto; min-width: 0;"
part="textarea"
@input=${setValue}
placeholder=${ifDefined(this.placeholder)}
rows=${ifDefined(this.rows)}
cols=${ifDefined(this.cols)}
wrap=${ifDefined(wrap)}
class=${classMap({
"pf-c-form-control": true,
"pf-m-monospace": code,
})}
spellcheck=${code ? "false" : "true"}
?required=${this.required}
>
${this.displayValue}</textarea
>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-hidden-textarea-input": AkHiddenTextAreaInput;
}
}

View File

@ -8,8 +8,8 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { HorizontalLightComponent } from "./HorizontalLightComponent"; import { HorizontalLightComponent } from "./HorizontalLightComponent";
@customElement("ak-private-text-input") @customElement("ak-secret-text-input")
export class AkPrivateTextInput extends HorizontalLightComponent<string> { export class AkSecretTextInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true }) @property({ type: String, reflect: true })
public value = ""; public value = "";
@ -23,7 +23,7 @@ export class AkPrivateTextInput extends HorizontalLightComponent<string> {
this.revealed = true; this.revealed = true;
} }
#renderPrivateInput() { #renderSecretInput() {
return html`<div class="pf-c-form__horizontal-group" @click=${() => this.#onReveal()}> return html`<div class="pf-c-form__horizontal-group" @click=${() => this.#onReveal()}>
<input <input
class="pf-c-form-control" class="pf-c-form-control"
@ -60,14 +60,14 @@ export class AkPrivateTextInput extends HorizontalLightComponent<string> {
} }
public override renderControl() { public override renderControl() {
return this.revealed ? this.renderVisibleInput() : this.#renderPrivateInput(); return this.revealed ? this.renderVisibleInput() : this.#renderSecretInput();
} }
} }
export default AkPrivateTextInput; export default AkSecretTextInput;
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ak-private-text-input": AkPrivateTextInput; "ak-secret-text-input": AkSecretTextInput;
} }
} }

View File

@ -5,10 +5,10 @@ import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { AkPrivateTextInput } from "./ak-private-text-input.js"; import { AkSecretTextInput } from "./ak-secret-text-input.js";
@customElement("ak-private-textarea-input") @customElement("ak-secret-textarea-input")
export class AkPrivateTextAreaInput extends AkPrivateTextInput { export class AkSecretTextAreaInput extends AkSecretTextInput {
protected override renderVisibleInput() { protected override renderVisibleInput() {
const code = this.inputHint === "code"; const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => { const setValue = (ev: InputEvent) => {
@ -34,10 +34,10 @@ export class AkPrivateTextAreaInput extends AkPrivateTextInput {
} }
} }
export default AkPrivateTextAreaInput; export default AkSecretTextAreaInput;
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ak-private-textarea-input": AkPrivateTextAreaInput; "ak-secret-textarea-input": AkSecretTextAreaInput;
} }
} }

View File

@ -0,0 +1,89 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface VisibilityToggleProps {
open: boolean;
disabled: boolean;
showMessage: string;
hideMessage: string;
}
/**
* @component ak-visibility-toggle
* @class VisibilityToggle
*
* A straightforward two-state iconic button we use in a few places as way of telling users to hide
* or show something secret, such as a password or private key. Expects the client to manage its
* state.
*
* @events
* - click: when the toggle is clicked.
*/
@customElement("ak-visibility-toggle")
export class VisibilityToggle extends AKElement implements VisibilityToggleProps {
static get styles() {
return [PFBase, PFButton];
}
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
disabled = false;
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "show-message" })
showMessage = msg("Show field content");
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "hide-message" })
hideMessage = msg("Hide field content");
render() {
const [label, icon] = this.open
? [this.hideMessage, "fa-eye"]
: [this.showMessage, "fa-eye-slash"];
const onClick = (ev: PointerEvent) => {
ev.stopPropagation();
this.dispatchEvent(new PointerEvent(ev.type, ev));
};
return html`<button
aria-label=${label}
title=${label}
@click=${onClick}
?disabled=${this.disabled}
class="pf-c-button pf-m-control"
type="button"
>
<i class="fas ${icon}" aria-hidden="true"></i>
</button>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-visibility-toggle": VisibilityToggle;
}
}

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../ak-hidden-text-input";
import { type AkHiddenTextInput, type AkHiddenTextInputProps } from "../ak-hidden-text-input.js";
const metadata: Meta<AkHiddenTextInputProps> = {
title: "Components / <ak-hidden-text-input>",
component: "ak-hidden-text-input",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
# Hidden Text Input Component
A text-input field with a visibility control, so you can show/hide sensitive fields.
`,
},
},
layout: "padded",
},
argTypes: {
label: {
control: "text",
description: "Label text for the input field",
},
value: {
control: "text",
description: "Current value of the input",
},
revealed: {
control: "boolean",
description: "Whether the text is currently visible",
},
placeholder: {
control: "text",
description: "Placeholder text for the input",
},
required: {
control: "boolean",
description: "Whether the input is required",
},
inputHint: {
control: "select",
options: ["text", "code"],
description: "Input type hint for styling and behavior",
},
showMessage: {
control: "text",
description: "Custom message for show action",
},
hideMessage: {
control: "text",
description: "Custom message for hide action",
},
},
};
export default metadata;
type Story = StoryObj<AkHiddenTextInput>;
const Template: Story = {
args: {
label: "Hidden Text Input",
value: "",
revealed: false,
},
render: (args) => html`
<ak-hidden-text-input
label=${ifDefined(args.label)}
value=${ifDefined(args.value)}
?revealed=${args.revealed}
placeholder=${ifDefined(args.placeholder)}
?required=${args.required}
input-hint=${ifDefined(args.inputHint)}
show-message=${ifDefined(args.showMessage)}
hide-message=${ifDefined(args.hideMessage)}
></ak-hidden-text-input>
`,
};
export const Password: Story = {
...Template,
args: {
label: "Password",
placeholder: "Enter your password",
required: true,
},
};

View File

@ -0,0 +1,140 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../ak-hidden-textarea-input";
import {
type AkHiddenTextAreaInput,
type AkHiddenTextAreaInputProps,
} from "../ak-hidden-textarea-input.js";
const metadata: Meta<AkHiddenTextAreaInputProps> = {
title: "Components / <ak-hidden-textarea-input>",
component: "ak-hidden-textarea-input",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
# Hidden Textarea Input Component
A textarea input field with a visibility control, so you can show/hide sensitive fields.
`,
},
},
layout: "padded",
},
argTypes: {
label: {
control: "text",
description: "Label text for the input field",
},
value: {
control: "text",
description: "Current value of the input",
},
revealed: {
control: "boolean",
description: "Whether the text is currently visible",
},
placeholder: {
control: "text",
description: "Placeholder text for the input",
},
required: {
control: "boolean",
description: "Whether the input is required",
},
inputHint: {
control: "select",
options: ["text", "code"],
description: "Input type hint for styling and behavior",
},
showMessage: {
control: "text",
description: "Custom message for show action",
},
hideMessage: {
control: "text",
description: "Custom message for hide action",
},
rows: {
control: { type: "number", min: 1, max: 50 },
description: "Number of visible text lines",
},
cols: {
control: { type: "number", min: 10, max: 200 },
description: "Number of visible character width",
},
resize: {
control: "select",
options: ["none", "both", "horizontal", "vertical"],
description: "How the textarea can be resized",
},
wrap: {
control: "select",
options: ["soft", "hard", "off"],
description: "Text wrapping behavior",
},
},
};
export default metadata;
type Story = StoryObj<AkHiddenTextAreaInput>;
const Template: Story = {
args: {
label: "Hidden Textarea Input",
value: "",
revealed: false,
rows: 4,
},
render: (args) => html`
<ak-hidden-textarea-input
label=${ifDefined(args.label)}
value=${ifDefined(args.value)}
?revealed=${args.revealed}
placeholder=${ifDefined(args.placeholder)}
rows=${ifDefined(args.rows)}
cols=${ifDefined(args.cols)}
resize=${ifDefined(args.resize)}
wrap=${ifDefined(args.wrap)}
?required=${args.required}
input-hint=${ifDefined(args.inputHint)}
show-message=${ifDefined(args.showMessage)}
hide-message=${ifDefined(args.hideMessage)}
></ak-hidden-textarea-input>
`,
};
export const SslCertificate: Story = {
...Template,
args: {
label: "SSL Certificate",
value: `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTcwNTEwMTk0MDA2WhcNMTgwNTEwMTk0MDA2WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMB4XDTE3MDUxMDE5NDAwNloXDTE4MDUxMDE5
NDAwNlowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNV
BAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBALdUlNS31SzxwoFShahGfjHj6GgpcVbzL1Siq0Pqnf82T6M2
EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggE
BAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqn
f82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgM
BAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeM
Hyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDu
neMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJ
kPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAE=
-----END CERTIFICATE-----`,
inputHint: "code",
rows: 15,
resize: "vertical",
showMessage: "Show certificate content",
hideMessage: "Hide certificate content",
autocomplete: "off",
},
};

View File

@ -0,0 +1,121 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../ak-visibility-toggle";
import { type VisibilityToggle, type VisibilityToggleProps } from "../ak-visibility-toggle.js";
const metadata: Meta<VisibilityToggleProps> = {
title: "Elements/<ak-visibility-toggle>",
component: "ak-visibility-toggle",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
# Visibility Toggle Component
A straightforward two-state iconic button for toggling the visibility of sensitive content such as passwords, private keys, or other secret information.
- Use for sensitive content that users might want to temporarily reveal
- There are default hide/show messages for screen readers, but they can be overridden
- Clients always handle the state
- The \`open\` state is false by default; we assume you want sensitive content hidden at start
`,
},
},
layout: "padded",
},
argTypes: {
open: {
control: "boolean",
description: "Whether the toggle is in the 'show' state (true) or 'hide' state (false)",
},
showMessage: {
control: "text",
description:
'Message for screen readers when in hide state (default: "Show field content")',
},
hideMessage: {
control: "text",
description:
'Message for screen readers when in show state (default: "Hide field content")',
},
disabled: {
control: "boolean",
description: "Whether the button should be disabled (for demo purposes)",
},
},
};
export default metadata;
type Story = StoryObj<VisibilityToggle>;
const Template: Story = {
args: {
open: false,
showMessage: "Show field content",
hideMessage: "Hide field content",
},
render: (args) => html`
<ak-visibility-toggle
?open=${args.open}
show-message=${ifDefined(args.showMessage)}
hide-message=${ifDefined(args.hideMessage)}
@click=${(e: Event) => {
const target = e.target as VisibilityToggle;
target.open = !target.open;
// In a real application, you would also toggle the visibility
// of the associated content here
}}
></ak-visibility-toggle>
`,
};
// Password field integration example
export const PasswordFieldExample: Story = {
args: {
showMessage: "Reveal password",
hideMessage: "Conceal password",
},
render: () => {
let isVisible = false;
const toggleVisibility = (e: Event) => {
isVisible = !isVisible;
const toggle = e.target as VisibilityToggle;
const passwordField = document.querySelector("#demo-password") as HTMLInputElement;
toggle.open = isVisible;
if (passwordField) {
passwordField.type = isVisible ? "text" : "password";
}
};
return html`
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 300px;">
<label for="demo-password" style="font-weight: bold;">Password:</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input
id="demo-password"
type="password"
value="supersecretpassword123"
style="flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px;"
readonly
/>
<ak-visibility-toggle
?open=${isVisible}
show-message="Show password"
hide-message="Hide password"
@click=${toggleVisibility}
></ak-visibility-toggle>
</div>
<p style="font-size: 0.875rem; color: #666;">
Click the eye icon to toggle password visibility
</p>
</div>
`;
},
};

View File

@ -16,8 +16,12 @@ import { property } from "lit/decorators.js";
import { UiThemeEnum } from "@goauthentik/api"; import { UiThemeEnum } from "@goauthentik/api";
export interface AKElementProps {
activeTheme: ResolvedUITheme;
}
@localized() @localized()
export class AKElement extends LitElement { export class AKElement extends LitElement implements AKElementProps {
//#region Static Properties //#region Static Properties
public static styles?: Array<CSSResult | CSSModule>; public static styles?: Array<CSSResult | CSSModule>;