diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 25915b64a..6ef77aa51 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -14,6 +14,11 @@ import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { userTypeToLabel } from "@goauthentik/common/labels"; +import "@goauthentik/components/DescriptionList"; +import { + type DescriptionPair, + renderDescriptionList, +} from "@goauthentik/components/DescriptionList"; import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/UserEvents"; @@ -137,165 +142,91 @@ export class UserViewPage extends AKElement { const user = this.user; - const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - this.user.pk !== this.me?.user.pk; + // prettier-ignore + const userInfo: DescriptionPair[] = [ + [msg("Username"), user.username], + [msg("Name"), user.name], + [msg("Email"), user.email || "-"], + [msg("Last login"), user.lastLogin?.toLocaleString()], + [msg("Active"), html``], + [msg("Type"), userTypeToLabel(user.type)], + [msg("Superuser"), html``], + [msg("Actions"), this.renderActionButtons(user)], + [msg("Recovery"), this.renderRecoveryButtons(user)], + ]; return html`
${msg("User Info")}
-
-
-
-
- ${msg("Username")} -
-
-
${user.username}
-
-
-
-
- ${msg("Name")} -
-
-
${user.name}
-
-
-
-
- ${msg("Email")} -
-
-
${user.email || "-"}
-
-
-
-
- ${msg("Last login")} -
-
-
- ${user.lastLogin?.toLocaleString()} -
-
-
-
-
- ${msg("Active")} -
-
-
- -
-
-
-
-
- ${msg("Type")} -
-
-
- ${userTypeToLabel(user.type)} -
-
-
-
-
- ${msg("Superuser")} -
-
-
- -
-
-
-
-
- ${msg("Actions")} -
-
-
- - ${msg("Update")} - ${msg("Update User")} - - - - - { - return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ - id: user.pk, - patchedUserRequest: { - isActive: !user.isActive, - }, - }); - }} - > - - - ${canImpersonate - ? html` - { - return new CoreApi(DEFAULT_CONFIG) - .coreUsersImpersonateCreate({ - id: user.pk, - }) - .then(() => { - window.location.href = "/"; - }); - }} - > - - ${msg("Impersonate")} - - - ` - : nothing} -
-
-
-
-
- ${msg("Recovery")} -
-
-
+
${renderDescriptionList(userInfo)}
+ `; + } + + renderActionButtons(user: User) { + const canImpersonate = + rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && + user.pk !== this.me?.user.pk; + + return html`
+ + ${msg("Update")} + ${msg("Update User")} + + + + { + return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ + id: user.pk, + patchedUserRequest: { + isActive: !user.isActive, + }, + }); + }} + > + + + ${canImpersonate + ? html` + { + return new CoreApi(DEFAULT_CONFIG) + .coreUsersImpersonateCreate({ + id: user.pk, + }) + .then(() => { + window.location.href = "/"; + }); + }} + > + + ${msg("Impersonate")} + + + ` + : nothing} +
`; + } + + renderRecoveryButtons(user: User) { + return html`
${msg("Update password")} ${msg("Update password")} diff --git a/web/src/components/DescriptionList.ts b/web/src/components/DescriptionList.ts new file mode 100644 index 000000000..cfeee4640 --- /dev/null +++ b/web/src/components/DescriptionList.ts @@ -0,0 +1,94 @@ +import { TemplateResult, html, nothing } from "lit"; +import { classMap } from "lit/directives/class-map.js"; +import { map } from "lit/directives/map.js"; + +export type DescriptionDesc = string | TemplateResult | undefined | typeof nothing; +export type DescriptionPair = [string, DescriptionDesc]; +export type DescriptionRecord = { term: string; desc: DescriptionDesc }; + +interface DescriptionConfig { + horizontal: boolean; + compact: boolean; + twocolumn: boolean; + threecolumn: boolean; +} + +const isDescriptionRecordCollection = (v: Array): v is DescriptionRecord[] => + v.length > 0 && typeof v[0] === "object" && !Array.isArray(v[0]); + +function renderDescriptionGroup([term, description]: DescriptionPair) { + return html`
+
+ ${term} +
+
+
${description ?? nothing}
+
+
`; +} + +function recordToPair({ term, desc }: DescriptionRecord): DescriptionPair { + return [term, desc]; +} + +function alignTermType(terms: DescriptionRecord[] | DescriptionPair[] = []) { + if (isDescriptionRecordCollection(terms)) { + return terms.map(recordToPair); + } + return terms ?? []; +} + +/** + * renderDescriptionList + * + * This function renders the most common form of the PatternFly description list used in our code. + * It expects either an array of term/description pairs or an array of `{ term: string, description: + * string | TemplateResult }`. + * + * An optional dictionary of configuration options is available. These enable the Patternfly + * "horizontal," "compact", "2 column on large," or "3 column on large" layouts that are (so far) + * the layouts used in Authentik's (and Gravity's, for that matter) code. + * + * This is not a web component and it does not bring its own styling ; calling code will still have + * to provide the styling necessary. It is only a function to replace the repetitious boilerplate of + * routine description lists. Its output is a standard TemplateResult that will be fully realized + * within the context of the DOM or ShadowDOM in which it is called. + */ + +const defaultConfig = { + horizontal: false, + compact: false, + twocolumn: false, + threecolumn: false, +}; + +export function renderDescriptionList( + terms: DescriptionRecord[], + config?: DescriptionConfig, +): TemplateResult; + +export function renderDescriptionList( + terms: DescriptionPair[], + config?: DescriptionConfig, +): TemplateResult; + +export function renderDescriptionList( + terms: DescriptionRecord[] | DescriptionPair[] = [], + config: DescriptionConfig = defaultConfig, +) { + const checkedTerms = alignTermType(terms); + const classes = classMap({ + "pf-m-horizontal": config.horizontal, + "pf-m-compact": config.compact, + "pf-m-2-col-on-lg": config.twocolumn, + "pf-m-3-col-on-lg": config.threecolumn, + }); + + return html` +
+ ${map(checkedTerms, renderDescriptionGroup)} +
+ `; +} + +export default renderDescriptionList;