diff --git a/web/src/user/LibraryPage.ts b/web/src/user/LibraryPage.ts deleted file mode 100644 index 878c6248a..000000000 --- a/web/src/user/LibraryPage.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { LayoutType } from "@goauthentik/common/ui/config"; -import { groupBy } from "@goauthentik/common/utils"; -import { AKElement, rootInterface } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/EmptyState"; -import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; -import { PaginatedResponse } from "@goauthentik/elements/table/Table"; -import "@goauthentik/user/LibraryApplication"; -import Fuse from "fuse.js"; - -import { t } from "@lingui/macro"; - -import { CSSResult, TemplateResult, css, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; - -import PFContent from "@patternfly/patternfly/components/Content/content.css"; -import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; -import PFPage from "@patternfly/patternfly/components/Page/page.css"; -import PFGallery from "@patternfly/patternfly/layouts/Gallery/gallery.css"; -import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; - -import { Application, CoreApi } from "@goauthentik/api"; - -export function loading(v: T, actual: TemplateResult): TemplateResult { - if (!v) { - return html` `; - } - return actual; -} - -@customElement("ak-library") -export class LibraryPage extends AKElement { - @property({ attribute: false }) - apps?: PaginatedResponse; - - @state() - selectedApp?: Application; - - @state() - filteredApps: Application[] = []; - - @property() - query = getURLParam("search", undefined); - - fuse: Fuse; - - constructor() { - super(); - this.fuse = new Fuse([], { - keys: [ - { name: "name", weight: 3 }, - "slug", - "group", - { name: "metaDescription", weight: 0.5 }, - { name: "metaPublisher", weight: 0.5 }, - ], - findAllMatches: true, - includeScore: true, - shouldSort: true, - ignoreFieldNorm: true, - useExtendedSearch: true, - threshold: 0.5, - }); - new CoreApi(DEFAULT_CONFIG).coreApplicationsList({}).then((apps) => { - this.apps = apps; - this.filteredApps = apps.results; - this.fuse.setCollection(apps.results); - if (!this.query) return; - const matchingApps = this.fuse.search(this.query); - if (matchingApps.length < 1) return; - this.selectedApp = matchingApps[0].item; - this.filteredApps = matchingApps.map((a) => a.item); - }); - } - - pageTitle(): string { - return t`My Applications`; - } - - static get styles(): CSSResult[] { - return [PFBase, PFDisplay, PFEmptyState, PFPage, PFContent, PFGrid, PFGallery].concat(css` - :host, - main { - padding: 3% 5%; - } - .header { - display: flex; - flex-direction: row; - justify-content: space-between; - } - .header input { - width: 30ch; - box-sizing: border-box; - border: 0; - border-bottom: 1px solid; - border-bottom-color: #fd4b2d; - background-color: transparent; - font-size: 1.5rem; - } - .header input:focus { - outline: 0; - } - .pf-c-page__main { - overflow: hidden; - } - .pf-c-page__main-section { - background-color: transparent; - } - .app-group-header { - margin-bottom: 1em; - margin-top: 1.2em; - } - `); - } - - renderEmptyState(): TemplateResult { - return html`
-
- -

${t`No Applications available.`}

-
- ${t`Either no applications are defined, or you don't have access to any.`} -
-
-
`; - } - - filterApps(apps: Application[]): Application[] { - return apps.filter((app) => { - if (app.launchUrl && app.launchUrl !== "") { - // If the launch URL is a full URL, only show with http or https - if (app.launchUrl.indexOf("://") !== -1) { - return app.launchUrl.startsWith("http"); - } - // If the URL doesn't include a protocol, assume its a relative path - return true; - } - return false; - }); - } - - getApps(): [string, Application[]][] { - return groupBy(this.filterApps(this.filteredApps), (app) => app.group || ""); - } - - renderApps(): TemplateResult { - let groupClass = ""; - let groupGrid = ""; - const uiConfig = rootInterface()?.uiConfig; - switch (uiConfig?.layout.type) { - case LayoutType.row: - groupClass = "pf-m-12-col"; - groupGrid = - "pf-m-all-6-col-on-sm pf-m-all-4-col-on-md pf-m-all-5-col-on-lg pf-m-all-2-col-on-xl"; - break; - case LayoutType.column_2: - groupClass = "pf-m-6-col"; - groupGrid = - "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-4-col-on-lg pf-m-all-4-col-on-xl"; - break; - case LayoutType.column_3: - groupClass = "pf-m-4-col"; - groupGrid = - "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-6-col-on-lg pf-m-all-6-col-on-xl"; - break; - } - return html`
- ${this.getApps().map(([group, apps]) => { - return html`
-
-

${group}

-
-
- ${apps.map((app) => { - return html``; - })} -
-
`; - })} -
`; - } - - resetSearch(): void { - const searchInput = this.shadowRoot?.querySelector("input"); - if (searchInput) { - searchInput.value = ""; - } - this.query = ""; - updateURLParams({ - search: this.query, - }); - this.selectedApp = undefined; - this.filteredApps = this.apps?.results || []; - } - - render(): TemplateResult { - return html`
-
-

${t`My applications`}

- ${rootInterface()?.uiConfig?.enabledFeatures.search - ? html` { - this.query = (ev.target as HTMLInputElement).value; - if (this.query === "") { - return this.resetSearch(); - } - updateURLParams({ - search: this.query, - }); - const apps = this.fuse.search(this.query); - if (apps.length < 1) return; - this.selectedApp = apps[0].item; - this.filteredApps = apps.map((a) => a.item); - }} - @keydown=${(ev: KeyboardEvent) => { - if (ev.key === "Enter" && this.selectedApp?.launchUrl) { - window.location.assign(this.selectedApp.launchUrl); - } else if (ev.key === "Escape") { - this.resetSearch(); - } - }} - type="text" - class="pf-u-display-none pf-u-display-block-on-md" - autofocus - placeholder=${t`Search...`} - value=${ifDefined(this.query)} - />` - : html``} -
-
- ${loading( - this.apps, - html`${this.filterApps(this.filteredApps).length > 0 - ? this.renderApps() - : this.renderEmptyState()}`, - )} -
-
`; - } -} diff --git a/web/src/user/LibraryPage/ApplicationEmptyState.ts b/web/src/user/LibraryPage/ApplicationEmptyState.ts new file mode 100644 index 000000000..d6d774059 --- /dev/null +++ b/web/src/user/LibraryPage/ApplicationEmptyState.ts @@ -0,0 +1,77 @@ +import { docLink } from "@goauthentik/common/global"; +import { AKElement } from "@goauthentik/elements/Base"; +import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; + +import { t } from "@lingui/macro"; + +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; + +/** + * Library Page Application List Empty + * + * Display a message if there are no applications defined in the current instance. If the user is an + * administrator, provide a link to the "Create a new application" page. + */ + +const styles = [ + PFBase, + PFEmptyState, + PFButton, + PFContent, + PFSpacing, + css` + .cta { + display: inline-block; + font-weight: bold; + } + `, +]; + +@customElement("ak-library-application-empty-list") +export class LibraryPageApplicationEmptyList extends AKElement { + static styles = styles; + + @property({ attribute: "isadmin", type: Boolean }) + isAdmin = false; + + renderNewAppButton() { + const href = paramURL("/core/applications", { + createForm: true, + }); + return html` + + + `; + } + + render() { + return html`
+
+ +

${t`No Applications available.`}

+
+ ${t`Either no applications are defined, or you don’t have access to any.`} +
+ ${this.isAdmin ? this.renderNewAppButton() : html``} +
+
`; + } +} diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ApplicationList.ts new file mode 100644 index 000000000..3df49c2fe --- /dev/null +++ b/web/src/user/LibraryPage/ApplicationList.ts @@ -0,0 +1,95 @@ +import { LayoutType } from "@goauthentik/common/ui/config"; +import { AKElement } from "@goauthentik/elements/Base"; + +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import type { Application } from "@goauthentik/api"; + +import type { AppGroupEntry, AppGroupList } from "./types"; + +type Pair = [string, string]; + +// prettier-ignore +const LAYOUTS = new Map([ + [ + "row", + ["pf-m-12-col", "pf-m-all-6-col-on-sm pf-m-all-4-col-on-md pf-m-all-5-col-on-lg pf-m-all-2-col-on-xl"]], + [ + "2-column", + ["pf-m-6-col", "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-4-col-on-lg pf-m-all-4-col-on-xl"], + ], + [ + "3-column", + ["pf-m-4-col", "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-6-col-on-lg pf-m-all-6-col-on-xl"], + ], +]); + +const styles = [ + PFBase, + PFEmptyState, + PFContent, + PFGrid, + css` + .app-group-header { + margin-bottom: 1em; + margin-top: 1.2em; + } + `, +]; + +@customElement("ak-library-application-list") +export class LibraryPageApplicationList extends AKElement { + static styles = styles; + + @property({ attribute: true }) + layout = "row" as LayoutType; + + @property({ attribute: true }) + background: string | undefined = undefined; + + @property({ attribute: true }) + selected = ""; + + @property() + apps: AppGroupList = []; + + get currentLayout(): Pair { + const layout = LAYOUTS.get(this.layout); + if (!layout) { + console.warn(`Unrecognized layout: ${this.layout || "-undefined-"}`); + return LAYOUTS.get("row") as Pair; + } + return layout; + } + + render() { + const [groupClass, groupGrid] = this.currentLayout; + + return html`
+ ${this.apps.map(([group, apps]: AppGroupEntry) => { + return html`
+
+

${group}

+
+
+ ${apps.map((app: Application) => { + return html``; + })} +
+
`; + })} +
`; + } +} diff --git a/web/src/user/LibraryPage/ApplicationSearch.ts b/web/src/user/LibraryPage/ApplicationSearch.ts new file mode 100644 index 000000000..a37975f67 --- /dev/null +++ b/web/src/user/LibraryPage/ApplicationSearch.ts @@ -0,0 +1,127 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; +import Fuse from "fuse.js"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; + +import type { Application } from "@goauthentik/api"; + +import { SEARCH_ITEM_SELECTED, SEARCH_UPDATED } from "./constants"; +import { customEvent } from "./helpers"; + +function fuseToApps(apps: Fuse.FuseResult[]): Application[] { + return apps.map((item) => item.item); +} + +@customElement("ak-library-list-search") +export class LibraryPageApplicationList extends AKElement { + static styles = [PFBase, PFDisplay]; + + @property() + apps: Application[] = []; + + @property() + query = getURLParam("search", undefined); + + @query("input") + searchInput?: HTMLInputElement; + + fuse: Fuse; + + constructor() { + super(); + this.fuse = new Fuse([], { + keys: [ + { name: "name", weight: 3 }, + "slug", + "group", + { name: "metaDescription", weight: 0.5 }, + { name: "metaPublisher", weight: 0.5 }, + ], + findAllMatches: true, + includeScore: true, + shouldSort: true, + ignoreFieldNorm: true, + useExtendedSearch: true, + threshold: 0.5, + }); + } + + onSelected(apps: Fuse.FuseResult[]) { + const items = fuseToApps(apps); + this.dispatchEvent( + customEvent(SEARCH_UPDATED, { + selectedApp: items[0], + filteredApps: items, + }), + ); + } + + connectedCallback() { + this.fuse.setCollection(this.apps); + if (!this.query) { + return; + } + const matchingApps = this.fuse.search(this.query); + if (matchingApps.length < 1) { + return; + } + this.onSelected(matchingApps); + } + + resetSearch(): void { + if (this.searchInput) { + this.searchInput.value = ""; + } + this.query = ""; + updateURLParams({ + search: this.query, + }); + this.onSelected([]); + } + + onInput(ev: InputEvent) { + this.query = (ev.target as HTMLInputElement).value; + if (this.query === "") { + return this.resetSearch(); + } + updateURLParams({ + search: this.query, + }); + const apps = this.fuse.search(this.query); + if (apps.length < 1) return; + this.onSelected(apps); + } + + onKeyDown(ev: KeyboardEvent) { + switch (ev.key) { + case "Escape": { + this.resetSearch(); + return; + } + case "Enter": { + this.dispatchEvent(customEvent(SEARCH_ITEM_SELECTED)); + return; + } + } + } + + render() { + return html``; + } +} diff --git a/web/src/user/LibraryPage/LibraryPage.ts b/web/src/user/LibraryPage/LibraryPage.ts new file mode 100644 index 000000000..e0fbfc83b --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPage.ts @@ -0,0 +1,96 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { me } from "@goauthentik/common/users"; +import { AKElement, rootInterface } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/EmptyState"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { Application, CoreApi } from "@goauthentik/api"; + +import "./LibraryPageImpl"; +import type { PageUIConfig } from "./types"; + +/** + * List of Applications available + * + * Properties: + * apps: a list of the applications available to the user. + * + * Aggregates two functions: + * - Display the list of applications available to the user + * - Filter that list using the search bar + * + */ + +@customElement("ak-library") +export class LibraryPage extends AKElement { + @state() + ready = false; + + @state() + isAdmin = false; + + @state() + apps!: PaginatedResponse; + + @state() + uiConfig: PageUIConfig; + + constructor() { + super(); + const applicationListFetch = new CoreApi(DEFAULT_CONFIG).coreApplicationsList({}); + const meFetch = me(); + const uiConfig = rootInterface()?.uiConfig; + if (!uiConfig) { + throw new Error("Could not retrieve uiConfig. Reason: unknown. Check logs."); + } + + this.uiConfig = { + layout: uiConfig.layout.type, + background: uiConfig.theme.cardBackground, + searchEnabled: uiConfig.enabledFeatures.search, + }; + + Promise.allSettled([applicationListFetch, meFetch]).then( + ([applicationListStatus, meStatus]) => { + if (meStatus.status === "rejected") { + throw new Error( + `Could not determine status of user. Reason: ${meStatus.reason}`, + ); + } + if (applicationListStatus.status === "rejected") { + throw new Error( + `Could not retrieve list of applications. Reason: ${applicationListStatus.reason}`, + ); + } + this.isAdmin = meStatus.value.user.isSuperuser; + this.apps = applicationListStatus.value; + this.ready = true; + }, + ); + } + + pageTitle(): string { + return t`My Applications`; + } + + loading() { + return html` `; + } + + running() { + return html``; + } + + render() { + return this.ready ? this.running() : this.loading(); + } +} diff --git a/web/src/user/LibraryPage/LibraryPageImpl.css.ts b/web/src/user/LibraryPage/LibraryPageImpl.css.ts new file mode 100644 index 000000000..821eb7bfc --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPageImpl.css.ts @@ -0,0 +1,39 @@ +import { css } from "lit"; + +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; + +export const styles = [PFBase, PFDisplay, PFEmptyState, PFPage, PFContent].concat(css` + :host { + display: block; + padding: 3% 5%; + } + .header { + display: flex; + flex-direction: row; + justify-content: space-between; + } + .header input { + width: 30ch; + box-sizing: border-box; + border: 0; + border-bottom: 1px solid; + border-bottom-color: #fd4b2d; + background-color: transparent; + font-size: 1.5rem; + } + .header input:focus { + outline: 0; + } + .pf-c-page__main { + overflow: hidden; + } + .pf-c-page__main-section { + background-color: transparent; + } +`); + +export default styles; diff --git a/web/src/user/LibraryPage/LibraryPageImpl.ts b/web/src/user/LibraryPage/LibraryPageImpl.ts new file mode 100644 index 000000000..ce75cc9f3 --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPageImpl.ts @@ -0,0 +1,147 @@ +import { groupBy } from "@goauthentik/common/utils"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/EmptyState"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import "@goauthentik/user/LibraryApplication"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import styles from "./LibraryPageImpl.css"; + +import type { Application } from "@goauthentik/api"; + +import "./ApplicationEmptyState"; +import "./ApplicationList"; +import "./ApplicationSearch"; +import { appHasLaunchUrl } from "./LibraryPageImpl.utils"; +import { SEARCH_ITEM_SELECTED, SEARCH_UPDATED } from "./constants"; +import { isCustomEvent, loading } from "./helpers"; +import type { AppGroupList, PageUIConfig } from "./types"; + +/** + * List of Applications available + * + * Properties: + * apps: a list of the applications available to the user. + * + * Aggregates two functions: + * - Display the list of applications available to the user + * - Filter that list using the search bar + * + */ + +@customElement("ak-library-impl") +export class LibraryPage extends AKElement { + static styles = styles; + + @property() + apps!: PaginatedResponse; + + @property({ attribute: "isadmin", type: Boolean }) + isAdmin = false; + + @property() + uiConfig!: PageUIConfig; + + @state() + selectedApp?: Application; + + @state() + filteredApps: Application[] = []; + + constructor() { + super(); + this.searchUpdated = this.searchUpdated.bind(this); + this.launchRequest = this.launchRequest.bind(this); + } + + pageTitle(): string { + return t`My Applications`; + } + + connectedCallback() { + super.connectedCallback(); + this.filteredApps = this.apps?.results; + if (this.filteredApps === undefined) { + throw new Error( + "Application.results should never be undefined when passed to the Library Page.", + ); + } + this.addEventListener(SEARCH_UPDATED, this.searchUpdated); + this.addEventListener(SEARCH_ITEM_SELECTED, this.launchRequest); + } + + disconnectedCallback() { + this.removeEventListener(SEARCH_UPDATED, this.searchUpdated); + this.removeEventListener(SEARCH_ITEM_SELECTED, this.launchRequest); + super.disconnectedCallback(); + } + + searchUpdated(event: Event) { + if (!isCustomEvent(event)) { + throw new Error("ak-library-search-updated must send a custom event."); + } + event.stopPropagation(); + this.selectedApp = event.detail.apps[0]; + this.filteredApps = event.detail.apps; + } + + launchRequest(event: Event) { + if (!isCustomEvent(event)) { + throw new Error("ak-library-item-selected must send a custom event"); + } + event.stopPropagation(); + const location = this.selectedApp?.launchUrl; + if (location) { + window.location.assign(location); + } + } + + getApps(): AppGroupList { + return groupBy(this.filteredApps.filter(appHasLaunchUrl), (app) => app.group || ""); + } + + renderEmptyState() { + return html``; + } + + renderApps() { + const selected = this.selectedApp?.slug; + const apps = this.getApps(); + const layout = this.uiConfig.layout as string; + const background = this.uiConfig.background; + + return html``; + } + + renderSearch() { + return html``; + } + + render() { + return html`
+
+

${t`My applications`}

+ ${this.uiConfig.searchEnabled ? this.renderSearch() : html``} +
+
+ ${loading( + this.apps, + html`${this.filteredApps.find(appHasLaunchUrl) + ? this.renderApps() + : this.renderEmptyState()}`, + )} +
+
`; + } +} diff --git a/web/src/user/LibraryPage/LibraryPageImpl.utils.ts b/web/src/user/LibraryPage/LibraryPageImpl.utils.ts new file mode 100644 index 000000000..bac9186e8 --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPageImpl.utils.ts @@ -0,0 +1,11 @@ +import type { Application } from "@goauthentik/api"; + +const isFullUrlRe = new RegExp("://"); +const isHttpRe = new RegExp("http(s?)://"); +const isNotFullUrl = (url: string) => !isFullUrlRe.test(url); +const isHttp = (url: string) => isHttpRe.test(url); + +export const appHasLaunchUrl = (app: Application) => { + const url = app.launchUrl; + return !!(typeof url === "string" && url !== "" && (isHttp(url) || isNotFullUrl(url))); +}; diff --git a/web/src/user/LibraryPage/constants.ts b/web/src/user/LibraryPage/constants.ts new file mode 100644 index 000000000..cfb650c7e --- /dev/null +++ b/web/src/user/LibraryPage/constants.ts @@ -0,0 +1,2 @@ +export const SEARCH_UPDATED = "authentik.search-updated"; +export const SEARCH_ITEM_SELECTED = "authentik.search-item-selected"; diff --git a/web/src/user/LibraryPage/helpers.ts b/web/src/user/LibraryPage/helpers.ts new file mode 100644 index 000000000..35b0f9561 --- /dev/null +++ b/web/src/user/LibraryPage/helpers.ts @@ -0,0 +1,23 @@ +import "@goauthentik/elements/EmptyState"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +export const customEvent = (name: string, details = {}) => + new CustomEvent(name as string, { + composed: true, + bubbles: true, + detail: details, + }); + +// "Unknown" seems to violate some obscure Typescript rule and doesn't work here, although it +// should. +// +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isCustomEvent = (v: any): v is CustomEvent => + v instanceof CustomEvent && "detail" in v; + +export const loading = (v: T, actual: TemplateResult) => + v ? actual : html` `; diff --git a/web/src/user/LibraryPage/types.ts b/web/src/user/LibraryPage/types.ts new file mode 100644 index 000000000..1fecd584c --- /dev/null +++ b/web/src/user/LibraryPage/types.ts @@ -0,0 +1,12 @@ +import type { LayoutType } from "@goauthentik/common/ui/config"; + +import type { Application } from "@goauthentik/api"; + +export type AppGroupEntry = [string, Application[]]; +export type AppGroupList = AppGroupEntry[]; + +export type PageUIConfig = { + layout: LayoutType; + background?: string; + searchEnabled: boolean; +}; diff --git a/web/src/user/Routes.ts b/web/src/user/Routes.ts index 8908f4ecb..72738e182 100644 --- a/web/src/user/Routes.ts +++ b/web/src/user/Routes.ts @@ -1,5 +1,5 @@ import { Route } from "@goauthentik/elements/router/Route"; -import "@goauthentik/user/LibraryPage"; +import "@goauthentik/user/LibraryPage/LibraryPage"; import { html } from "lit";