diff --git a/web/docs/Changelog.md b/web/docs/Changelog.md new file mode 100644 index 000000000000..cba5b39992a2 --- /dev/null +++ b/web/docs/Changelog.md @@ -0,0 +1,17 @@ +### 2024-03-26T09:25:06-0700 + +Split the tsconfig file into a base and build variant. + +Lesson: This lesson is stored here and not in a comment in tsconfig.json because +JSON doesn't like comments. Doug Crockford's purity requirement has doomed an +entire generation to keeping its human-facing meta somewhere other than in the +file where it belongs. + +Lesson: The `extend` command of tsconfig has an unexpected behavior. It is +neither a merge or a replace, but some mixture of the two. The buildfile's +`compilerOptions` is not a full replacement; instead, each of _its_ top-level +fields is a replacement for what is found in the basefile. So while you don't +need to include _everything_ in a `compilerOptions` field if you want to change +one thing, if you want to modify _one_ path in `compilerOptions.path`, you must +include the entire `compilerOptions.path` collection in your buildfile. +g diff --git a/web/src/common/api/config.ts b/web/src/common/api/config.ts index dd1a2c1b75e9..52b2f8f55ce4 100644 --- a/web/src/common/api/config.ts +++ b/web/src/common/api/config.ts @@ -3,7 +3,7 @@ import { EventMiddleware, LoggingMiddleware, } from "@goauthentik/common/api/middleware"; -import { EVENT_LOCALE_REQUEST, EVENT_REFRESH, VERSION } from "@goauthentik/common/constants"; +import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api"; @@ -86,13 +86,4 @@ export function AndNext(url: string): string { return `?next=${encodeURIComponent(url)}`; } -window.addEventListener(EVENT_REFRESH, () => { - // Upon global refresh, disregard whatever was pre-hydrated and - // actually load info from API - globalConfigPromise = undefined; - globalBrandPromise = undefined; - config(); - brand(); -}); - console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); diff --git a/web/src/elements/AuthentikContexts.ts b/web/src/elements/AuthentikContexts.ts index 4b05f68b622d..df38f6fcf51d 100644 --- a/web/src/elements/AuthentikContexts.ts +++ b/web/src/elements/AuthentikContexts.ts @@ -1,9 +1,11 @@ import { createContext } from "@lit/context"; -import type { Config, CurrentBrand, LicenseSummary } from "@goauthentik/api"; +import type { Config, CurrentBrand, LicenseSummary, SessionUser } from "@goauthentik/api"; export const authentikConfigContext = createContext(Symbol("authentik-config-context")); +export const authentikUserContext = createContext(Symbol("authentik-user-context")); + export const authentikEnterpriseContext = createContext( Symbol("authentik-enterprise-context"), ); diff --git a/web/src/elements/Interface/BrandContextController.ts b/web/src/elements/Interface/BrandContextController.ts new file mode 100644 index 000000000000..216d930bf0d8 --- /dev/null +++ b/web/src/elements/Interface/BrandContextController.ts @@ -0,0 +1,52 @@ +import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; + +import { ContextProvider } from "@lit/context"; +import { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { CurrentBrand } from "@goauthentik/api"; +import { CoreApi } from "@goauthentik/api"; + +import type { AkInterface } from "./Interface"; + +type ReactiveElementHost = Partial & AkInterface; + +export class BrandContextController implements ReactiveController { + host!: ReactiveElementHost; + + context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; + + constructor(host: ReactiveElementHost) { + this.host = host; + this.context = new ContextProvider(this.host, { + context: authentikBrandContext, + initialValue: undefined, + }); + this.fetch = this.fetch.bind(this); + this.fetch(); + } + + fetch() { + new CoreApi(DEFAULT_CONFIG).coreBrandsCurrentRetrieve().then((brand) => { + this.context.setValue(brand); + this.host.brand = brand; + }); + } + + hostConnected() { + window.addEventListener(EVENT_REFRESH, this.fetch); + } + + hostDisconnected() { + window.removeEventListener(EVENT_REFRESH, this.fetch); + } + + hostUpdate() { + // If the Interface changes its brand information for some reason, + // we should notify all users of the context of that change. doesn't + if (this.host.brand !== this.context.value) { + this.context.setValue(this.host.brand); + } + } +} diff --git a/web/src/elements/Interface/ConfigContextController.ts b/web/src/elements/Interface/ConfigContextController.ts new file mode 100644 index 000000000000..b9cf088f819f --- /dev/null +++ b/web/src/elements/Interface/ConfigContextController.ts @@ -0,0 +1,53 @@ +import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; + +import { ContextProvider } from "@lit/context"; +import { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { Config } from "@goauthentik/api"; +import { RootApi } from "@goauthentik/api"; + +import type { AkInterface } from "./Interface"; + +type ReactiveElementHost = Partial & AkInterface; + +export class ConfigContextController implements ReactiveController { + host!: ReactiveElementHost; + + context!: ContextProvider<{ __context__: Config | undefined }>; + + constructor(host: ReactiveElementHost) { + this.host = host; + this.context = new ContextProvider(this.host, { + context: authentikConfigContext, + initialValue: undefined, + }); + this.fetch = this.fetch.bind(this); + this.fetch(); + } + + fetch() { + new RootApi(DEFAULT_CONFIG).rootConfigRetrieve().then((config) => { + this.context.setValue(config); + this.host.config = config; + }); + } + + hostConnected() { + window.addEventListener(EVENT_REFRESH, this.fetch); + } + + hostDisconnected() { + window.removeEventListener(EVENT_REFRESH, this.fetch); + } + + hostUpdate() { + // If the Interface changes its config information, we should notify all + // users of the context of that change, without creating an infinite + // loop of resets. + if (this.host.config !== this.context.value) { + this.context.setValue(this.host.config); + } + } +} diff --git a/web/src/elements/Interface/EnterpriseContextController.ts b/web/src/elements/Interface/EnterpriseContextController.ts new file mode 100644 index 000000000000..30871a75a48c --- /dev/null +++ b/web/src/elements/Interface/EnterpriseContextController.ts @@ -0,0 +1,53 @@ +import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/authentik/common/constants"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { authentikEnterpriseContext } from "@goauthentik/elements/AuthentikContexts"; + +import { ContextProvider } from "@lit/context"; +import { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { LicenseSummary } from "@goauthentik/api"; +import { EnterpriseApi } from "@goauthentik/api"; + +import type { AkEnterpriseInterface } from "./Interface"; + +type ReactiveElementHost = Partial & AkEnterpriseInterface; + +export class EnterpriseContextController implements ReactiveController { + host!: ReactiveElementHost; + + context!: ContextProvider<{ __context__: LicenseSummary | undefined }>; + + constructor(host: ReactiveElementHost) { + this.host = host; + this.context = new ContextProvider(this.host, { + context: authentikEnterpriseContext, + initialValue: undefined, + }); + this.fetch = this.fetch.bind(this); + this.fetch(); + } + + fetch() { + new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((enterprise) => { + this.context.setValue(enterprise); + this.host.licenseSummary = enterprise; + }); + } + + hostConnected() { + window.addEventListener(EVENT_REFRESH_ENTERPRISE, this.fetch); + } + + hostDisconnected() { + window.removeEventListener(EVENT_REFRESH_ENTERPRISE, this.fetch); + } + + hostUpdate() { + // If the Interface changes its config information, we should notify all + // users of the context of that change, without creating an infinite + // loop of resets. + if (this.host.licenseSummary !== this.context.value) { + this.context.setValue(this.host.licenseSummary); + } + } +} diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts index 38099a564f53..8da9454603f6 100644 --- a/web/src/elements/Interface/Interface.ts +++ b/web/src/elements/Interface/Interface.ts @@ -1,77 +1,47 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { brand, config } from "@goauthentik/common/api/config"; -import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/common/constants"; import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; -import { - authentikBrandContext, - authentikConfigContext, - authentikEnterpriseContext, -} from "@goauthentik/elements/AuthentikContexts"; import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; -import { ContextProvider } from "@lit/context"; import { state } from "lit/decorators.js"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import type { Config, CurrentBrand, LicenseSummary } from "@goauthentik/api"; -import { EnterpriseApi, UiThemeEnum } from "@goauthentik/api"; +import { UiThemeEnum } from "@goauthentik/api"; import { AKElement } from "../Base"; +import { BrandContextController } from "./BrandContextController"; +import { ConfigContextController } from "./ConfigContextController"; +import { EnterpriseContextController } from "./EnterpriseContextController"; -type AkInterface = HTMLElement & { +export type AkInterface = HTMLElement & { getTheme: () => Promise; brand?: CurrentBrand; uiConfig?: UIConfig; config?: Config; }; +const brandContext = Symbol("brandContext"); +const configContext = Symbol("configContext"); + export class Interface extends AKElement implements AkInterface { @state() uiConfig?: UIConfig; - _configContext = new ContextProvider(this, { - context: authentikConfigContext, - initialValue: undefined, - }); + [brandContext]!: BrandContextController; - _config?: Config; + [configContext]!: ConfigContextController; @state() - set config(c: Config) { - this._config = c; - this._configContext.setValue(c); - this.requestUpdate(); - } - - get config(): Config | undefined { - return this._config; - } - - _brandContext = new ContextProvider(this, { - context: authentikBrandContext, - initialValue: undefined, - }); - - _brand?: CurrentBrand; + config?: Config; @state() - set brand(c: CurrentBrand) { - this._brand = c; - this._brandContext.setValue(c); - this.requestUpdate(); - } - - get brand(): CurrentBrand | undefined { - return this._brand; - } + brand?: CurrentBrand; constructor() { super(); document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; - brand().then((brand) => (this.brand = brand)); - config().then((config) => (this.config = config)); - + this[brandContext] = new BrandContextController(this); + this[configContext] = new ConfigContextController(this); this.dataset.akInterfaceRoot = "true"; } @@ -88,37 +58,20 @@ export class Interface extends AKElement implements AkInterface { } } -export class EnterpriseAwareInterface extends Interface { - _licenseSummaryContext = new ContextProvider(this, { - context: authentikEnterpriseContext, - initialValue: undefined, - }); +export type AkEnterpriseInterface = AkInterface & { + licenseSummary?: LicenseSummary; +}; - _licenseSummary?: LicenseSummary; +const enterpriseContext = Symbol("enterpriseContext"); - @state() - set licenseSummary(c: LicenseSummary) { - this._licenseSummary = c; - this._licenseSummaryContext.setValue(c); - this.requestUpdate(); - } +export class EnterpriseAwareInterface extends Interface { + [enterpriseContext]!: EnterpriseContextController; - get licenseSummary(): LicenseSummary | undefined { - return this._licenseSummary; - } + @state() + licenseSummary?: LicenseSummary; constructor() { super(); - const refreshStatus = () => { - new EnterpriseApi(DEFAULT_CONFIG) - .enterpriseLicenseSummaryRetrieve() - .then((enterprise) => { - this.licenseSummary = enterprise; - }); - }; - refreshStatus(); - window.addEventListener(EVENT_REFRESH_ENTERPRISE, () => { - refreshStatus(); - }); + this[enterpriseContext] = new EnterpriseContextController(this); } } diff --git a/web/src/user/LibraryPage/ApplicationEmptyState.ts b/web/src/user/LibraryPage/ApplicationEmptyState.ts index 11871c1396c6..bcd62611aa43 100644 --- a/web/src/user/LibraryPage/ApplicationEmptyState.ts +++ b/web/src/user/LibraryPage/ApplicationEmptyState.ts @@ -1,5 +1,4 @@ import { docLink } from "@goauthentik/common/global"; -import { adaptCSS } from "@goauthentik/common/utils"; import { AKElement } from "@goauthentik/elements/Base"; import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; @@ -20,23 +19,23 @@ import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; * administrator, provide a link to the "Create a new application" page. */ -const styles = adaptCSS([ - 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; + static get styles() { + return [ + PFBase, + PFEmptyState, + PFButton, + PFContent, + PFSpacing, + css` + .cta { + display: inline-block; + font-weight: bold; + } + `, + ]; + } @property({ attribute: "isadmin", type: Boolean }) isAdmin = false; diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ApplicationList.ts index 7b680d8cacfe..c51acce659aa 100644 --- a/web/src/user/LibraryPage/ApplicationList.ts +++ b/web/src/user/LibraryPage/ApplicationList.ts @@ -31,22 +31,22 @@ const LAYOUTS = new Map([ ], ]); -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; + static get styles() { + return [ + PFBase, + PFEmptyState, + PFContent, + PFGrid, + css` + .app-group-header { + margin-bottom: 1em; + margin-top: 1.2em; + } + `, + ]; + } @property({ attribute: true }) layout = "row" as LayoutType; diff --git a/web/src/user/LibraryPage/ApplicationSearch.ts b/web/src/user/LibraryPage/ApplicationSearch.ts index b780e2ef54ed..5d13930dfdae 100644 --- a/web/src/user/LibraryPage/ApplicationSearch.ts +++ b/web/src/user/LibraryPage/ApplicationSearch.ts @@ -18,24 +18,26 @@ import { customEvent } from "./helpers"; @customElement("ak-library-list-search") export class LibraryPageApplicationList extends AKElement { - static styles = [ - PFBase, - PFDisplay, - css` - input { - width: 30ch; - box-sizing: border-box; - border: 0; - border-bottom: 1px solid; - border-bottom-color: var(--ak-accent); - background-color: transparent; - font-size: 1.5rem; - } - input:focus { - outline: 0; - } - `, - ]; + static get styles() { + return [ + PFBase, + PFDisplay, + css` + input { + width: 30ch; + box-sizing: border-box; + border: 0; + border-bottom: 1px solid; + border-bottom-color: var(--ak-accent); + background-color: transparent; + font-size: 1.5rem; + } + input:focus { + outline: 0; + } + `, + ]; + } @property({ attribute: false }) set apps(value: Application[]) { diff --git a/web/src/user/LibraryPage/LibraryPageImpl.ts b/web/src/user/LibraryPage/LibraryPageImpl.ts index 79fc8d609f11..76d82793c169 100644 --- a/web/src/user/LibraryPage/LibraryPageImpl.ts +++ b/web/src/user/LibraryPage/LibraryPageImpl.ts @@ -35,7 +35,9 @@ import type { AppGroupList, PageUIConfig } from "./types"; @customElement("ak-library-impl") export class LibraryPage extends AKElement { - static styles = styles; + static get styles() { + return styles; + } @property({ attribute: "isadmin", type: Boolean }) isAdmin = false; diff --git a/web/tsconfig.base.json b/web/tsconfig.base.json new file mode 100644 index 000000000000..028fbd00f768 --- /dev/null +++ b/web/tsconfig.base.json @@ -0,0 +1,54 @@ +{ + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "esModuleInterop": true, + "paths": { + "@goauthentik/docs/*": ["../website/docs/*"] + }, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "sourceMap": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "lib": [ + "ES5", + "ES2015", + "ES2016", + "ES2017", + "ES2018", + "ES2019", + "ES2020", + "ESNext", + "DOM", + "DOM.Iterable", + "WebWorker" + ], + "noUnusedLocals": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "useDefineForClassFields": false, + "alwaysStrict": true, + "noImplicitAny": true, + "plugins": [ + { + "name": "ts-lit-plugin", + "strict": true, + "rules": { + "no-unknown-tag-name": "off", + "no-missing-import": "off", + "no-incompatible-type-binding": "off", + "no-unknown-property": "off", + "no-unknown-attribute": "off" + } + } + ] + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 6c52309f7788..887178d6d36a 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,6 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "strict": true, "paths": { "@goauthentik/authentik/*": ["src/*"], "@goauthentik/admin/*": ["src/admin/*"], @@ -14,51 +14,5 @@ "@goauthentik/standalone/*": ["src/standalone/*"], "@goauthentik/user/*": ["src/user/*"] }, - "baseUrl": ".", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "experimentalDecorators": true, - "sourceMap": true, - "target": "esnext", - "module": "esnext", - "moduleResolution": "node", - "lib": [ - "ES5", - "ES2015", - "ES2016", - "ES2017", - "ES2018", - "ES2019", - "ES2020", - "ESNext", - "DOM", - "DOM.Iterable", - "WebWorker" - ], - "noUnusedLocals": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "useDefineForClassFields": false, - "alwaysStrict": true, - "noImplicitAny": true, - "plugins": [ - { - "name": "ts-lit-plugin", - "strict": true, - "rules": { - "no-unknown-tag-name": "off", - "no-missing-import": "off", - "no-incompatible-type-binding": "off", - "no-unknown-property": "off", - "no-unknown-attribute": "off" - } - } - ] } }