web: Fix stale clipboard tokens, untranslated labels (#23063)

* web: Fix stale clipboard tokens, untranslated labels.

* Fix tooltip.

* Fix type error.

* Update types.

* Fix types. Clean up composite.

* Fix label names.

* Fix broken HTML.

* Fix labels, formatters.

* Clean up properties, lifecyle.
This commit is contained in:
Teffen Ellis
2026-06-16 20:58:23 +02:00
committed by GitHub
parent 52674afa8a
commit fadc14eddc
33 changed files with 640 additions and 261 deletions
+23
View File
@@ -0,0 +1,23 @@
export type Token = unknown;
export type LexerAction = (
this: Lexer,
match: string,
...captures: string[]
) => Token | Token[] | null | void;
export type DefunctHandler = (this: Lexer, chr: string) => Token | Token[] | null | void;
export class Lexer {
state: number;
index: number;
input: string;
reject: boolean;
constructor(defunct?: DefunctHandler);
addRule(pattern: RegExp, action: LexerAction, start?: number[]): this;
setInput(input: string): this;
lex(): Token | null;
}
export default Lexer;
+5 -4
View File
@@ -174,10 +174,11 @@ export class Lexer {
this.reject = false;
this.#remove++;
let token = match.action.apply(
this,
/** @type {string[]} */ (/** @type {unknown} */ (result)),
);
// TypeScript Native's assessment is correct.
// but TypeScript Node can't make the connection between regex and the array.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Remove after TypeScript Native is mainline.
let token = match.action.apply(this, result);
if (this.reject) {
this.index = result.index;
+2
View File
@@ -5,8 +5,10 @@
"license": "MIT",
"private": true,
"type": "module",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js"
}
}
@@ -22,6 +22,9 @@ import { customElement } from "lit/decorators.js";
@customElement("ak-endpoints-connectors-list")
export class ConnectorsListPage extends TablePage<Connector> {
public static override verboseName = msg("Connector");
public static override verboseNamePlural = msg("Connectors");
public override searchPlaceholder = msg("Search connectors by name or type...");
public override pageIcon = "pf-icon pf-icon-data-source";
public override pageTitle = msg("Connectors");
@@ -26,6 +26,9 @@ import { customElement, property } from "lit/decorators.js";
export class EnrollmentTokenListPage extends Table<EnrollmentToken> {
#api = aki(EndpointsApi);
public static override verboseName = msg("Enrollment Token");
public static override verboseNamePlural = msg("Enrollment Tokens");
protected override searchEnabled = true;
protected emptyStateMessage = msg("No enrollment tokens found for this connector.");
@@ -64,7 +64,7 @@ export class DeviceListPage extends TablePage<EndpointDevice> {
${inner
? inner
: html`<ak-empty-state icon=${this.pageIcon}
><span>${msg("No objects found.")}</span>
><span>${this.formatEmptyStateMessage()}</span>
<div slot="body">
${this.search ? this.renderEmptyClearSearch() : nothing}
<p>
@@ -244,7 +244,7 @@ export class ObjectReviewIteration extends Table<Review> {
protected override renderEmpty(): SlottedTemplateResult {
return super.renderEmpty(
html` <ak-empty-state icon="pf-icon-task"
><span>${this.emptyStateMessage}</span></ak-empty-state
><span>${this.formatEmptyStateMessage()}</span></ak-empty-state
>`,
);
}
@@ -18,6 +18,9 @@ import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList
@customElement("ak-rac-connection-token-list")
export class ConnectionTokenListPage extends Table<ConnectionToken> {
public static override verboseName = msg("Connection Token");
public static override verboseNamePlural = msg("Connection Tokens");
checkbox = true;
clearOnRefresh = true;
@@ -30,7 +30,7 @@ export class InvitationWizard extends AKElement implements TransclusionChildElem
protected override render(): SlottedTemplateResult {
return html`<ak-wizard
entity-singular=${msg("Invitation")}
verbose-name=${msg("Invitation")}
description=${msg("Create a new invitation with an enrollment flow.")}
.initialSteps=${["flow-step", "details-step", "success-step"]}
>
+13 -3
View File
@@ -16,7 +16,7 @@ import { CoreApi, CoreUsersListRequest, IntentEnum, Token, User } from "@goauthe
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
const EXPIRATION_DURATION = 30 * 60 * 1000; // 30 minutes
@@ -25,6 +25,12 @@ export class TokenForm extends ModelForm<Token, string> {
public static override verboseName = msg("Token");
public static override verboseNamePlural = msg("Tokens");
/**
* Pre-selected user for new tokens, e.g. when creating a token from a user's detail page.
*/
@property({ attribute: false })
public defaultUser: User | null = null;
protected expirationMinimumDate = new Date();
@state()
@@ -114,7 +120,7 @@ export class TokenForm extends ModelForm<Token, string> {
}
const users = await aki(CoreApi).coreUsersList(args);
const instanceUser = this.instance?.userObj;
const instanceUser = this.instance?.userObj ?? this.defaultUser;
if (!instanceUser) {
return users.results;
@@ -136,7 +142,11 @@ export class TokenForm extends ModelForm<Token, string> {
return user?.pk;
}}
.selected=${(user: User): boolean => {
return this.instance?.user === user.pk;
if (this.instance) {
return this.instance.user === user.pk;
}
return this.defaultUser?.pk === user.pk;
}}
>
</ak-search-select>
+3 -3
View File
@@ -8,7 +8,7 @@ import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { aki } from "#common/api/client";
import { intentToLabel } from "#common/labels";
import { formatIntentLabel } from "#common/labels";
import { IconTokenCopyButton } from "#elements/buttons/IconTokenCopyButton";
import { IconEditButton, ModalInvokerButton } from "#elements/dialogs";
@@ -94,7 +94,7 @@ export class TokenListPage extends TablePage<Token> {
html`<a href="#/identity/users/${item.userObj?.pk}">${item.userObj?.username}</a>`,
html`<ak-status-label type="warning" ?good=${item.expiring}></ak-status-label>`,
Timestamp(item.expires && item.expiring ? item.expires : null),
html`${intentToLabel(item.intent ?? IntentEnum.Api)}`,
html`${formatIntentLabel(item.intent ?? IntentEnum.Api)}`,
html`<div class="ak-c-table__actions">
${!item.managed
? IconEditButton(TokenForm, item.identifier, item.identifier)
@@ -110,7 +110,7 @@ export class TokenListPage extends TablePage<Token> {
model: ModelEnum.AuthentikCoreToken,
objectPk: item.pk,
})}
${IconTokenCopyButton(item.identifier)}
${IconTokenCopyButton(item)}
</div>`,
];
}
+3
View File
@@ -16,6 +16,9 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-user-device-table")
export class UserDeviceTable extends Table<Device> {
public static override verboseName = msg("Device");
public static override verboseNamePlural = msg("Devices");
@property({ type: Number })
userId?: number;
+172
View File
@@ -0,0 +1,172 @@
import "#admin/rbac/ObjectPermissionModal";
import "#admin/tokens/TokenForm";
import "#components/ak-status-label";
import "#elements/buttons/Dropdown";
import "#elements/buttons/TokenCopyButton/index";
import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { aki } from "#common/api/client";
import { formatIntentLabel } from "#common/labels";
import { IconTokenCopyButton } from "#elements/buttons/IconTokenCopyButton";
import { IconEditButton, ModalInvokerButton } from "#elements/dialogs";
import { IconPermissionButton } from "#elements/dialogs/components/IconPermissionButton";
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { TokenForm } from "#admin/tokens/TokenForm";
import { CoreApi, IntentEnum, ModelEnum, Token, User } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing, PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-admin-user-token-list")
export class AdminUserTokenList extends Table<Token> {
public static override verboseName = msg("Token");
public static override verboseNamePlural = msg("Tokens");
@property({ type: Number, attribute: "user-id", useDefault: true })
public userID: number | null = null;
@property({ attribute: false, useDefault: true })
public user: User | null = null;
protected override searchEnabled = true;
protected override rowLabel(item: Token): string | null {
return item.identifier;
}
public override checkbox = true;
public override clearOnRefresh = true;
public override order = "expires";
//#region Lifecycle
protected override async apiEndpoint(): Promise<PaginatedResponse<Token>> {
if (!this.user) {
await this.refresh();
}
if (!this.user) {
throw new TypeError("User is not set, cannot fetch tokens.");
}
return aki(CoreApi).coreTokensList({
...(await this.defaultEndpointConfig()),
userUsername: this.user.username,
});
}
public refresh = () => {
if (!this.userID) {
return;
}
return aki(CoreApi)
.coreUsersRetrieve({
id: this.userID!,
})
.then((user) => {
this.user = user;
})
.catch(showAPIErrorMessage);
};
protected override updated(changed: PropertyValues<this>) {
super.updated(changed);
if (changed.has("userID") && this.userID !== null) {
this.refresh();
}
}
//#region
//#endregion
//#region Rendering
protected override renderObjectCreate(): SlottedTemplateResult {
if (!this.user) {
return null;
}
return ModalInvokerButton(TokenForm, { defaultUser: this.user });
}
protected columns: TableColumn[] = [
[msg("Identifier"), "identifier"],
[msg("Expires?"), "expiring"],
[msg("Expiry date"), "expires"],
[msg("Intent"), "intent"],
[msg("Actions"), null, msg("Row Actions")],
];
protected override renderToolbarSelected(): SlottedTemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
object-label=${msg("Token(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Token) => {
return [{ key: msg("Identifier"), value: item.identifier }];
}}
.usedBy=${(item: Token) => {
return aki(CoreApi).coreTokensUsedByList({
identifier: item.identifier,
});
}}
.delete=${(item: Token) => {
return aki(CoreApi).coreTokensDestroy({
identifier: item.identifier,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${msg("Delete")}
</button>
</ak-forms-delete-bulk>`;
}
protected override row(item: Token): SlottedTemplateResult[] {
return [
html`<div>${item.identifier}</div>
${item.managed
? html`<small>${msg("Token is managed by authentik.")}</small>`
: nothing}`,
html`<ak-status-label type="warning" ?good=${item.expiring}></ak-status-label>`,
Timestamp(item.expires && item.expiring ? item.expires : null),
html`${formatIntentLabel(item.intent ?? IntentEnum.Api)}`,
html`<div class="ak-c-table__actions">
${!item.managed
? IconEditButton(TokenForm, item.identifier, item.identifier)
: html`<button class="pf-c-button pf-m-plain" disabled type="button">
<pf-tooltip
position="top"
content=${msg("Editing is disabled for managed tokens")}
>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>`}
${IconPermissionButton(item.identifier, {
model: ModelEnum.AuthentikCoreToken,
objectPk: item.pk,
})}
${IconTokenCopyButton(item)}
</div>`,
];
}
//#endregion
}
declare global {
interface HTMLElementTagNameMap {
"ak-admin-user-token-list": AdminUserTokenList;
}
}
+141 -136
View File
@@ -8,6 +8,7 @@ import "#admin/users/UserChart";
import "#admin/users/UserForm";
import "#admin/users/UserImpersonateForm";
import "#admin/users/UserPasswordForm";
import "#admin/users/UserTokenList";
import "#admin/users/oauth/UserAccessTokenList";
import "#admin/users/oauth/UserRefreshTokenList";
import "#components/DescriptionList";
@@ -245,116 +246,123 @@ export class UserViewPage extends WithLicenseSummary(
}
protected renderTabCredentialsToken(user: User): TemplateResult {
return html`
<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
<div
role="tabpanel"
tabindex="0"
slot="page-sessions"
id="page-sessions"
aria-label=${msg("Sessions")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-session-list targetUser=${user.username}>
</ak-user-session-list>
</div>
return html`<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
<div
role="tabpanel"
tabindex="0"
slot="page-sessions"
id="page-sessions"
aria-label=${msg("Sessions")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-session-list targetUser=${user.username}></ak-user-session-list>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-reputation"
id="page-reputation"
aria-label=${msg("Reputation scores")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-reputation-list
targetUsername=${user.username}
targetEmail=${ifDefined(user.email)}
>
</ak-user-reputation-list>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-tokens"
id="page-tokens"
aria-label=${msg("Tokens")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-admin-user-token-list .user=${user}></ak-admin-user-token-list>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-consent"
id="page-consent"
aria-label=${msg("Explicit Consent")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-consent-list userId=${user.pk}> </ak-user-consent-list>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-reputation"
id="page-reputation"
aria-label=${msg("Reputation scores")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-reputation-list
targetUsername=${user.username}
targetEmail=${ifDefined(user.email)}
></ak-user-reputation-list>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-oauth-access"
id="page-oauth-access"
aria-label=${msg("OAuth Access Tokens")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-oauth-access-token-list userId=${user.pk}>
</ak-user-oauth-access-token-list>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-consent"
id="page-consent"
aria-label=${msg("Explicit Consent")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-consent-list userId=${user.pk}></ak-user-consent-list>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-oauth-refresh"
id="page-oauth-refresh"
aria-label=${msg("OAuth Refresh Tokens")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-oauth-refresh-token-list userId=${user.pk}>
</ak-user-oauth-refresh-token-list>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-oauth-access"
id="page-oauth-access"
aria-label=${msg("OAuth Access Tokens")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-oauth-access-token-list
userId=${user.pk}
></ak-user-oauth-access-token-list>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-mfa-authenticators"
id="page-mfa-authenticators"
aria-label=${msg("MFA Authenticators")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-device-table userId=${user.pk}> </ak-user-device-table>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-oauth-refresh"
id="page-oauth-refresh"
aria-label=${msg("OAuth Refresh Tokens")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-oauth-refresh-token-list
userId=${user.pk}
></ak-user-oauth-refresh-token-list>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-source-connections"
id="page-source-connections"
aria-label=${msg("Connected services")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-settings-source user-id=${user.pk}>
</ak-user-settings-source>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-mfa-authenticators"
id="page-mfa-authenticators"
aria-label=${msg("MFA Authenticators")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-device-table userId=${user.pk}></ak-user-device-table>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-rac-connection-tokens"
id="page-rac-connection-tokens"
aria-label=${msg("RAC Connections")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-rac-connection-token-list userId=${user.pk}>
</ak-rac-connection-token-list>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-source-connections"
id="page-source-connections"
aria-label=${msg("Connected services")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-user-settings-source user-id=${user.pk}></ak-user-settings-source>
</div>
</ak-tabs>
</main>
`;
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-rac-connection-tokens"
id="page-rac-connection-tokens"
aria-label=${msg("RAC Connections")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-rac-connection-token-list userId=${user.pk}></ak-rac-connection-token-list>
</div>
</div>
</ak-tabs>`;
}
protected renderTabApplications(user: User): TemplateResult {
@@ -364,37 +372,35 @@ export class UserViewPage extends WithLicenseSummary(
}
protected renderTabRoles(user: User): TemplateResult {
return html`
<ak-tabs pageIdentifier="userRoles" vertical>
<div
role="tabpanel"
tabindex="0"
slot="page-assigned-roles"
id="page-assigned-roles"
aria-label=${msg("Assigned Roles")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-related-role-table .targetUser=${user}></ak-related-role-table>
</div>
return html`<ak-tabs pageIdentifier="userRoles" vertical>
<div
role="tabpanel"
tabindex="0"
slot="page-assigned-roles"
id="page-assigned-roles"
aria-label=${msg("Assigned Roles")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-related-role-table .targetUser=${user}></ak-related-role-table>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-all-roles"
id="page-all-roles"
aria-label=${msg("All Roles")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-related-role-table
.targetUser=${user}
showInherited
></ak-related-role-table>
</div>
</div>
<div
role="tabpanel"
tabindex="0"
slot="page-all-roles"
id="page-all-roles"
aria-label=${msg("All Roles")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-related-role-table
.targetUser=${user}
showInherited
></ak-related-role-table>
</div>
</ak-tabs>
`;
</div>
</ak-tabs> `;
}
protected override render() {
@@ -425,7 +431,7 @@ export class UserViewPage extends WithLicenseSummary(
${msg("Actions over the last week (per 8 hours)")}
</div>
<div class="pf-c-card__body">
<ak-charts-user username=${this.user.username}> </ak-charts-user>
<ak-charts-user username=${this.user.username}></ak-charts-user>
</div>
</div>
<div
@@ -451,8 +457,7 @@ export class UserViewPage extends WithLicenseSummary(
<ak-object-changelog
targetModelPk=${this.user.pk}
targetModelName=${ModelEnum.AuthentikCoreUser}
>
</ak-object-changelog>
></ak-object-changelog>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<ak-object-attributes-card
@@ -470,7 +475,7 @@ export class UserViewPage extends WithLicenseSummary(
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-group-related-list .targetUser=${this.user}> </ak-group-related-list>
<ak-group-related-list .targetUser=${this.user}></ak-group-related-list>
</div>
</div>
<div
@@ -491,7 +496,7 @@ export class UserViewPage extends WithLicenseSummary(
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<ak-events-user targetUser=${this.user.username}> </ak-events-user>
<ak-events-user targetUser=${this.user.username}></ak-events-user>
</div>
</div>
<div
@@ -18,6 +18,9 @@ import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
@customElement("ak-user-oauth-access-token-list")
export class UserOAuthAccessTokenList extends Table<TokenModel> {
public static override verboseName = msg("Access Token");
public static override verboseNamePlural = msg("Access Tokens");
expandable = true;
@property({ type: Number })
@@ -18,6 +18,9 @@ import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
@customElement("ak-user-oauth-refresh-token-list")
export class UserOAuthRefreshTokenList extends Table<TokenModel> {
public static override verboseName = msg("Refresh Token");
public static override verboseNamePlural = msg("Refresh Tokens");
expandable = true;
@property({ type: Number })
+2 -1
View File
@@ -2,6 +2,7 @@ import { MessageLevel } from "#common/messages";
import { isPromiseLike } from "#common/promises";
import { showMessage } from "#elements/messages/MessageContainer";
import { SlottedTemplateResult } from "#elements/types";
import { msg, str } from "@lit/localize";
@@ -47,7 +48,7 @@ export async function doWriteToClipboard(...data: ClipboardItemSource[]): Promis
export function writeToClipboard(
data?: ClipboardItemSource | ClipboardItemSource[] | null,
entityLabel?: string,
description?: string,
description?: SlottedTemplateResult,
): Promise<boolean> {
if (!data || (Array.isArray(data) && data.length === 0)) {
console.warn("Cannot write empty data to clipboard");
+80 -61
View File
@@ -1,3 +1,9 @@
/**
* @file Contains various label maps for API enums and other values that we want to display in the UI.
*/
import { MessageFormatter } from "#common/ui/locale/format";
import {
Device,
DeviceChallenge,
@@ -10,66 +16,75 @@ import {
import { msg, str } from "@lit/localize";
/* Various tables in the API for which we need to supply labels */
const IntentLabelRecord: Record<IntentEnum, MessageFormatter<string>> = {
[IntentEnum.Api]: () => msg("API Access"),
[IntentEnum.AppPassword]: () => msg("App password"),
[IntentEnum.Recovery]: () => msg("Recovery"),
[IntentEnum.Verification]: () => msg("Verification"),
[IntentEnum.UnknownDefaultOpenApi]: () => msg("Unknown intent"),
};
export const intentEnumToLabel = new Map<IntentEnum, string>([
[IntentEnum.Api, msg("API Access")],
[IntentEnum.AppPassword, msg("App password")],
[IntentEnum.Recovery, msg("Recovery")],
[IntentEnum.Verification, msg("Verification")],
[IntentEnum.UnknownDefaultOpenApi, msg("Unknown intent")],
]);
export function formatIntentLabel(intent: IntentEnum = IntentEnum.Api): string {
return IntentLabelRecord[intent]();
}
export const intentToLabel = (intent: IntentEnum) => intentEnumToLabel.get(intent);
export const eventActionToLabel = new Map<EventActions | undefined, string>([
[EventActions.Login, msg("Login")],
[EventActions.LoginFailed, msg("Failed login")],
[EventActions.Logout, msg("Logout")],
[EventActions.UserWrite, msg("User was written to")],
[EventActions.SuspiciousRequest, msg("Suspicious request")],
[EventActions.PasswordSet, msg("Password set")],
[EventActions.SecretView, msg("Secret was viewed")],
[EventActions.SecretRotate, msg("Secret was rotated")],
[EventActions.InvitationUsed, msg("Invitation used")],
[EventActions.AuthorizeApplication, msg("Application authorized")],
[EventActions.SourceLinked, msg("Source linked")],
[EventActions.ImpersonationStarted, msg("Impersonation started")],
[EventActions.ImpersonationEnded, msg("Impersonation ended")],
[EventActions.FlowExecution, msg("Flow execution")],
export const EventActionLabelRecord: Record<EventActions, MessageFormatter<string>> = {
[EventActions.Login]: () => msg("Login"),
[EventActions.LoginFailed]: () => msg("Failed login"),
[EventActions.Logout]: () => msg("Logout"),
[EventActions.UserWrite]: () => msg("User was written to"),
[EventActions.SuspiciousRequest]: () => msg("Suspicious request"),
[EventActions.PasswordSet]: () => msg("Password set"),
[EventActions.SecretView]: () => msg("Secret was viewed"),
[EventActions.SecretRotate]: () => msg("Secret was rotated"),
[EventActions.InvitationUsed]: () => msg("Invitation used"),
[EventActions.AuthorizeApplication]: () => msg("Application authorized"),
[EventActions.SourceLinked]: () => msg("Source linked"),
[EventActions.ImpersonationStarted]: () => msg("Impersonation started"),
[EventActions.ImpersonationEnded]: () => msg("Impersonation ended"),
[EventActions.FlowExecution]: () => msg("Flow execution"),
// These are different: look closely.
[EventActions.PolicyExecution, msg("Policy execution")],
[EventActions.PolicyException, msg("Policy exception")],
[EventActions.PropertyMappingException, msg("Property Mapping exception")],
[EventActions.PolicyExecution]: () => msg("Policy execution"),
[EventActions.PolicyException]: () => msg("Policy exception"),
[EventActions.PropertyMappingException]: () => msg("Property Mapping exception"),
// These are different: look closely.
[EventActions.SystemTaskExecution, msg("System task execution")],
[EventActions.SystemTaskException, msg("System task exception")],
[EventActions.SystemException, msg("General system exception")],
[EventActions.ConfigurationError, msg("Configuration error")],
[EventActions.ConfigurationWarning, msg("Configuration warning")],
[EventActions.ModelCreated, msg("Model created")],
[EventActions.ModelUpdated, msg("Model updated")],
[EventActions.ModelDeleted, msg("Model deleted")],
[EventActions.EmailSent, msg("Email sent")],
[EventActions.UpdateAvailable, msg("Update available")],
[EventActions.ExportReady, msg("Data export ready")],
[EventActions.ReviewInitiated, msg("Review initiated")],
[EventActions.ReviewOverdue, msg("Review overdue")],
[EventActions.ReviewAttested, msg("Review attested")],
[EventActions.ReviewCompleted, msg("Review completed")],
]);
[EventActions.SystemTaskExecution]: () => msg("System task execution"),
[EventActions.SystemTaskException]: () => msg("System task exception"),
[EventActions.SystemException]: () => msg("General system exception"),
[EventActions.ConfigurationError]: () => msg("Configuration error"),
[EventActions.ConfigurationWarning]: () => msg("Configuration warning"),
[EventActions.ModelCreated]: () => msg("Model created"),
[EventActions.ModelUpdated]: () => msg("Model updated"),
[EventActions.ModelDeleted]: () => msg("Model deleted"),
[EventActions.EmailSent]: () => msg("Email sent"),
[EventActions.UpdateAvailable]: () => msg("Update available"),
[EventActions.ExportReady]: () => msg("Data export ready"),
[EventActions.ReviewInitiated]: () => msg("Review initiated"),
[EventActions.ReviewOverdue]: () => msg("Review overdue"),
[EventActions.ReviewAttested]: () => msg("Review attested"),
[EventActions.ReviewCompleted]: () => msg("Review completed"),
[EventActions.UnknownDefaultOpenApi]: () => msg("Unknown action"),
[EventActions.Custom]: () => msg("Custom action"),
};
export const actionToLabel = (action?: EventActions): string =>
eventActionToLabel.get(action) ?? action ?? "";
export function actionToLabel(action?: EventActions): string {
const formatter = action ? EventActionLabelRecord[action] : null;
export const severityEnumToLabel = new Map<SeverityEnum | null | undefined, string>([
[SeverityEnum.Alert, msg("Alert")],
[SeverityEnum.Notice, msg("Notice")],
[SeverityEnum.Warning, msg("Warning")],
]);
return formatter?.() || "";
}
export const severityToLabel = (severity: SeverityEnum | null | undefined) =>
severityEnumToLabel.get(severity) ?? msg("Unknown severity");
const SeverityEnumLabelRecord: Record<SeverityEnum, MessageFormatter<string>> = {
[SeverityEnum.Alert]: () => msg("Alert"),
[SeverityEnum.Notice]: () => msg("Notice"),
[SeverityEnum.Warning]: () => msg("Warning"),
[SeverityEnum.UnknownDefaultOpenApi]: () => msg("Unknown severity"),
};
export function severityToLabel(severity: SeverityEnum | null | undefined): string {
const formatter = SeverityEnumLabelRecord[severity ?? SeverityEnum.UnknownDefaultOpenApi];
return formatter();
}
export function severityToLevel(severity?: SeverityEnum | null): string {
switch (severity) {
@@ -113,12 +128,16 @@ export function formatDeviceChallengeMessage(deviceChallenge?: DeviceChallenge |
return msg("Enter the code from your authenticator device.");
}
const _userTypeToLabel = new Map<UserTypeEnum | undefined, string>([
[UserTypeEnum.Internal, msg("Internal")],
[UserTypeEnum.External, msg("External")],
[UserTypeEnum.ServiceAccount, msg("Service account")],
[UserTypeEnum.InternalServiceAccount, msg("Service account (internal)")],
]);
const UserTypeLabelRecord: Record<UserTypeEnum, MessageFormatter<string>> = {
[UserTypeEnum.Internal]: () => msg("Internal"),
[UserTypeEnum.External]: () => msg("External"),
[UserTypeEnum.ServiceAccount]: () => msg("Service account"),
[UserTypeEnum.InternalServiceAccount]: () => msg("Service account (internal)"),
[UserTypeEnum.UnknownDefaultOpenApi]: () => msg("Unknown user type"),
};
export const userTypeToLabel = (type?: UserTypeEnum): string =>
_userTypeToLabel.get(type) ?? type ?? "";
export function userTypeToLabel(type?: UserTypeEnum): string {
const formatter = type ? UserTypeLabelRecord[type] : null;
return formatter?.() || "";
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { TemplateResult } from "lit";
import { SlottedTemplateResult } from "#elements/types";
export enum MessageLevel {
error = "error",
@@ -18,7 +18,7 @@ export enum MessageLevel {
export interface APIMessage {
level: MessageLevel;
message: string;
description?: string | TemplateResult;
description?: SlottedTemplateResult;
icon?: string;
/**
* An optional key to determine uniqueness of the message.
+9 -1
View File
@@ -8,7 +8,15 @@ import {
} from "#common/ui/locale/definitions";
import { safeParseLocale } from "#common/ui/locale/utils";
import { msg, str } from "@lit/localize";
import { msg, str, TemplateLike } from "@lit/localize";
/**
* A Lit Localize callback function which returns a translated result.
*/
export type MessageFormatter<
R extends TemplateLike = TemplateLike,
Args extends unknown[] = never[],
> = (...args: Args) => R;
/**
* Safely get a minimized locale ID, with fallback for older browsers.
+2 -2
View File
@@ -115,7 +115,7 @@ export class SimpleTable
*
* Overrides the static `verboseName` property for this instance.
*/
@property({ type: String, attribute: "entity-singular" })
@property({ type: String, attribute: "verbose-name" })
public set verboseName(value: string | null) {
this.#verboseName = value;
@@ -135,7 +135,7 @@ export class SimpleTable
*
* Overrides the static `verboseNamePlural` property for this instance.
*/
@property({ type: String, attribute: "entity-plural" })
@property({ type: String, attribute: "verbose-name-plural" })
public set verboseNamePlural(value: string | null) {
this.#verboseNamePlural = value;
+4 -3
View File
@@ -14,7 +14,7 @@ export interface IconCopyButtonProps {
buttonLabel?: string;
tooltipLabel?: string;
entityLabel?: string;
description?: string;
description?: SlottedTemplateResult;
}
export function IconCopyButton({
@@ -52,7 +52,8 @@ export function IconCopyButton({
@click=${doCopy}
aria-label=${buttonLabel}
>
<i class="fas fa-copy" aria-hidden="true"></i>
<pf-tooltip position="top" content=${tooltipLabel}> </pf-tooltip>
<pf-tooltip position="top" content=${tooltipLabel}>
<i class="fas fa-copy" aria-hidden="true"></i>
</pf-tooltip>
</button>`;
}
@@ -1,15 +1,32 @@
import { aki } from "#common/api/client";
import { formatIntentLabel } from "#common/labels";
import { IconCopyButton } from "#elements/buttons/IconCopyButton";
import { SlottedTemplateResult } from "#elements/types";
import { CoreApi } from "@goauthentik/api";
import { CoreApi, Token } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { guard } from "lit-html/directives/guard.js";
export function IconTokenCopyButton(identifier?: string | null): SlottedTemplateResult {
return guard([], () => {
export function IconTokenCopyButton(tokenLike?: Token | string | null): SlottedTemplateResult {
return guard([tokenLike], () => {
if (!tokenLike) {
return null;
}
const { identifier, userObj, intent } =
typeof tokenLike === "string"
? { identifier: tokenLike, userObj: null, intent: null }
: tokenLike;
const description = userObj?.username
? msg(str`${formatIntentLabel(intent)} token for ${userObj.username}`, {
id: "tokens.clipboard-copy.description",
desc: "Description for a clipboard copy action for tokens, with the token intent and username as variables.",
})
: undefined;
const fetchTokenViewKey = (): Promise<Blob> => {
if (!identifier) {
console.warn("No identifier provided for IconTokenCopyButton");
@@ -23,8 +40,15 @@ export function IconTokenCopyButton(identifier?: string | null): SlottedTemplate
return IconCopyButton({
source: fetchTokenViewKey,
buttonLabel: msg("Copy token"),
entityLabel: msg("Token"),
buttonLabel: msg("Copy token", {
id: "tokens.copy-button.label",
desc: "Label for a button that copies a token to the clipboard.",
}),
entityLabel: msg("Token", {
id: "tokens.copy-button.entity-label",
desc: "Label for a token entity, used in clipboard copy success messages.",
}),
description,
});
});
}
+2 -2
View File
@@ -289,7 +289,7 @@ export class Form<T = Record<string, unknown>, D = T>
*
* Overrides the static `verboseName` property for this instance.
*/
@property({ type: String, attribute: "entity-singular" })
@property({ type: String, attribute: "verbose-name" })
public set verboseName(value: string | null) {
this.#verboseName = value;
@@ -309,7 +309,7 @@ export class Form<T = Record<string, unknown>, D = T>
*
* Overrides the static `verboseNamePlural` property for this instance.
*/
@property({ type: String, attribute: "entity-plural" })
@property({ type: String, attribute: "verbose-name-plural" })
public set verboseNamePlural(value: string | null) {
this.#verboseNamePlural = value;
+105 -17
View File
@@ -14,11 +14,17 @@ import { type PaginatedResponse } from "#common/api/responses";
import { EVENT_REFRESH } from "#common/constants";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { AKRefreshEvent } from "#common/events";
import { truncateWords } from "#common/strings";
import { GroupResult } from "#common/utils";
import { AKElement } from "#elements/Base";
import { intersectionObserver } from "#elements/decorators/intersection-observer";
import { type TransclusionChildElement, TransclusionChildSymbol } from "#elements/dialogs/shared";
import {
EntityDescriptorElement,
isTransclusionParentElement,
type TransclusionChildElement,
TransclusionChildSymbol,
} from "#elements/dialogs/shared";
import { WithSession } from "#elements/mixins/session";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import Styles from "#elements/table/Table.css";
@@ -84,6 +90,8 @@ export abstract class Table<T extends object, D = T>
extends WithSession(AKElement)
implements TableLike, TransclusionChildElement
{
declare ["constructor"]: EntityDescriptorElement;
static styles: CSSResult[] = [
PFTable,
PFBullseye,
@@ -95,6 +103,9 @@ export abstract class Table<T extends object, D = T>
Styles,
];
public static verboseName: string = msg("Object");
public static verboseNamePlural: string = msg("Objects");
public [TransclusionChildSymbol] = true;
//#region Abstract members
@@ -124,10 +135,51 @@ export abstract class Table<T extends object, D = T>
//#region Protected Properties
#verboseName: string | null = null;
/**
* Customize the "No objects found" message.
* Optional singular label for the type of entity this form creates/edits.
*
* Overrides the static `verboseName` property for this instance.
*/
protected emptyStateMessage = msg("No objects found.");
@property({ type: String, attribute: "verbose-name" })
public set verboseName(value: string | null) {
this.#verboseName = value;
if (isTransclusionParentElement(this.parentElement)) {
this.parentElement.slottedElementUpdatedAt = new Date();
}
}
public get verboseName(): string | null {
return this.#verboseName || this.constructor.verboseName || null;
}
#verboseNamePlural: string | null = null;
/**
* Optional plural label for the type of entity this form creates/edits.
*
* Overrides the static `verboseNamePlural` property for this instance.
*/
@property({ type: String, attribute: "verbose-name-plural" })
public set verboseNamePlural(value: string | null) {
this.#verboseNamePlural = value;
if (isTransclusionParentElement(this.parentElement)) {
this.parentElement.slottedElementUpdatedAt = new Date();
}
}
public get verboseNamePlural(): string | null {
return this.#verboseNamePlural || this.constructor.verboseNamePlural || null;
}
/**
* An optional message to display when the table is empty and no search is applied.
* If not provided, a default message will be used.
*/
protected emptyStateMessage: string | null = null;
/**
* Whether the table is currently fetching data.
@@ -324,6 +376,44 @@ export abstract class Table<T extends object, D = T>
return this.fetch();
};
/**
* An overridable method for formatting the empty state message when no objects are found.
*/
public formatEmptyStateMessage(): string {
if (this.searchEnabled && this.search) {
const singularNoun = this.verboseName?.toLocaleLowerCase() || msg("object");
return msg(str`No ${singularNoun} matches "${truncateWords(this.search, 50)}"`, {
id: "table.emptyState.search",
desc: "Empty state message when no objects match the search query, where the entity singular is interpolated, followed by the search query truncated to 50 characters.",
});
}
if (this.emptyStateMessage) {
return this.emptyStateMessage;
}
const pluralNoun = this.verboseNamePlural?.toLocaleLowerCase() || msg("objects");
return msg(str`No ${pluralNoun} found.`, {
id: "table.emptyState.default",
desc: "Empty state message when no objects are found, where the entity plural is interpolated.",
});
}
public formatSearchPlaceholder(): string {
if (this.searchPlaceholder) {
return this.searchPlaceholder;
}
const pluralNoun = this.verboseNamePlural?.toLocaleLowerCase() || msg("objects");
return msg(str`Search for ${pluralNoun}...`, {
id: "table.search.placeholder",
desc: "Placeholder text for the search input, where the entity plural is interpolated.",
});
}
//#endregion
//#region Lifecycle
@@ -512,19 +602,17 @@ export abstract class Table<T extends object, D = T>
}
protected renderEmpty(inner?: SlottedTemplateResult): SlottedTemplateResult {
return html`
<tr role="presentation">
<td role="presentation" colspan=${this.columnCount}>
<div class="pf-l-bullseye">
${inner ??
html`<ak-empty-state
><span>${this.emptyStateMessage}</span>
<div slot="primary">${this.renderObjectCreate()}</div>
</ak-empty-state>`}
</div>
</td>
</tr>
`;
return html`<tr role="presentation">
<td role="presentation" colspan=${this.columnCount}>
<div class="pf-l-bullseye">
${inner ??
html`<ak-empty-state
><span>${this.formatEmptyStateMessage()}</span>
<div slot="primary">${this.renderObjectCreate()}</div>
</ak-empty-state>`}
</div>
</td>
</tr>`;
}
/**
@@ -902,7 +990,7 @@ export abstract class Table<T extends object, D = T>
part="toolbar-search"
.defaultValue=${this.search}
label=${ifPresent(this.searchLabel)}
placeholder=${ifPresent(this.searchPlaceholder)}
placeholder=${ifPresent(this.formatSearchPlaceholder())}
.onSearch=${this.#searchListener}
.supportsQL=${this.supportsQL}
.apiResponse=${this.data}
+1 -1
View File
@@ -93,7 +93,7 @@ export abstract class TablePage<T extends object> extends Table<T> {
${inner
? inner
: html`<ak-empty-state icon=${this.pageIcon}
><span>${this.emptyStateMessage}</span>
><span>${this.formatEmptyStateMessage()}</span>
<div slot="body">
${this.searchEnabled ? this.renderEmptyClearSearch() : nothing}
</div>
+3
View File
@@ -15,6 +15,9 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-user-session-list")
export class AuthenticatedSessionList extends Table<AuthenticatedSession> {
public static override verboseName = msg("Session");
public static override verboseNamePlural = msg("Sessions");
@property()
targetUser!: string;
+3
View File
@@ -15,6 +15,9 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-user-consent-list")
export class UserConsentList extends Table<UserConsent> {
public static override verboseName = msg("Consent");
public static override verboseNamePlural = msg("Consents");
@property({ type: Number })
userId?: number;
@@ -15,6 +15,9 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-user-reputation-list")
export class UserReputationList extends Table<Reputation> {
public static override verboseName = msg("Reputation score");
public static override verboseNamePlural = msg("Reputation scores");
@property()
targetUsername!: string;
+4 -4
View File
@@ -101,7 +101,7 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
*
* Overrides the static `verboseName` property for this instance.
*/
@property({ type: String, attribute: "entity-singular" })
@property({ type: String, attribute: "verbose-name" })
public set verboseName(value: string | null) {
this.#verboseName = value;
@@ -121,7 +121,7 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
*
* Overrides the static `verboseNamePlural` property for this instance.
*/
@property({ type: String, attribute: "entity-plural" })
@property({ type: String, attribute: "verbose-name-plural" })
public set verboseNamePlural(value: string | null) {
this.#verboseNamePlural = value;
@@ -309,8 +309,8 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
return html`<ak-wizard
${ref(this.wizardRef)}
entity-singular=${ifPresent(this.verboseName)}
entity-plural=${ifPresent(this.verboseNamePlural)}
verbose-name=${ifPresent(this.verboseName)}
verbose-name-plural=${ifPresent(this.verboseNamePlural)}
description=${ifPresent(this.description)}
part="main"
.initialSteps=${this.initialSteps}
+4 -4
View File
@@ -77,7 +77,7 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
public formatARIALabel(verboseName = this.verboseName): string {
return verboseName
? msg(str`New ${verboseName} Wizard`, {
id: "wizard.ariaLabel.entity-singular",
id: "wizard.ariaLabel.verbose-name.one",
desc: "ARIA label for the creation wizard, where the entity singular is interpolated.",
})
: msg("Wizard", {
@@ -92,7 +92,7 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
public formatHeader(verboseName = this.verboseName): string {
if (verboseName) {
return msg(str`Create New ${verboseName}`, {
id: "wizard.header.entity-singular",
id: "wizard.header.verbose-name.one",
desc: "Header for the creation wizard, where the entity singular is interpolated.",
});
}
@@ -130,7 +130,7 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
*
* Overrides the static `verboseName` property for this instance.
*/
@property({ type: String, attribute: "entity-singular" })
@property({ type: String, attribute: "verbose-name" })
public set verboseName(value: string | null) {
this.#verboseName = value;
@@ -146,7 +146,7 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
/**
* Optional plural label for the type of entity this wizard creates, used in messages and the like.
*/
@property({ type: String, attribute: "entity-plural" })
@property({ type: String, attribute: "verbose-name-plural" })
public verboseNamePlural: string | null = null;
/**
@@ -8,7 +8,7 @@ import "#user/user-settings/tokens/UserTokenForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { aki } from "#common/api/client";
import { intentToLabel } from "#common/labels";
import { formatIntentLabel } from "#common/labels";
import { formatElapsedTime } from "#common/temporal";
import { IconTokenCopyButton } from "#elements/buttons/IconTokenCopyButton";
@@ -132,7 +132,7 @@ export class UserTokenList extends Table<Token> {
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${intentToLabel(item.intent ?? IntentEnum.Api)}
${formatIntentLabel(item.intent ?? IntentEnum.Api)}
</div>
</dd>
</div>
@@ -175,7 +175,7 @@ export class UserTokenList extends Table<Token> {
</pf-tooltip>
</button>
</ak-forms-modal>
${IconTokenCopyButton(item.identifier)}
${IconTokenCopyButton(item)}
`,
];
}
+3 -5
View File
@@ -3,10 +3,7 @@
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"ignoreDeprecations": "6.0",
// Nothing references the web package, so it does not need to act as a
// composite project. Disabling composite lets us exclude `packages/`
// (each subpackage owns its own tsconfig + build) without TS6307.
"composite": false,
"composite": true,
"types": ["node"],
"checkJs": true,
"allowJs": true,
@@ -75,7 +72,8 @@
// Workspace subpackages each own their tsconfig and build. Including
// them here pulls ~1k files (notably the OpenAPI client in
// packages/client-ts) into every `tsc -p .` for no benefit.
"packages",
"packages/client-ts",
"packages/sfe",
// TODO: @lit/localize-tools v0.8.0 has a nullish coalescing typing error.
// Remove when we upgrade past that.
"scripts/pseudolocalize.mjs",