diff --git a/web/.storybook/authentikTheme.ts b/web/.storybook/authentikTheme.ts index d8f9a45919..0e9a24061b 100644 --- a/web/.storybook/authentikTheme.ts +++ b/web/.storybook/authentikTheme.ts @@ -1,7 +1,9 @@ import { create } from "@storybook/theming/create"; +const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + export default create({ - base: "light", + base: isDarkMode ? "dark" : "light", brandTitle: "authentik Storybook", brandUrl: "https://goauthentik.io", brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg", diff --git a/web/.storybook/main.ts b/web/.storybook/main.ts index c3f042282c..51d7818424 100644 --- a/web/.storybook/main.ts +++ b/web/.storybook/main.ts @@ -19,6 +19,20 @@ const config: StorybookConfig = { "@jeysal/storybook-addon-css-user-preferences", "storybook-addon-mock", ], + staticDirs: [ + { + from: "../node_modules/@patternfly/patternfly/patternfly-base.css", + to: "@patternfly/patternfly/patternfly-base.css", + }, + { + from: "../src/common/styles/authentik.css", + to: "@goauthentik/common/styles/authentik.css", + }, + { + from: "../src/common/styles/theme-dark.css", + to: "@goauthentik/common/styles/theme-dark.css", + }, + ], framework: { name: "@storybook/web-components-vite", options: {}, diff --git a/web/.storybook/preview-head.html b/web/.storybook/preview-head.html new file mode 100644 index 0000000000..169a8492e2 --- /dev/null +++ b/web/.storybook/preview-head.html @@ -0,0 +1,60 @@ + + + diff --git a/web/.storybook/preview.ts b/web/.storybook/preview.ts index 258afd2152..d84453f6a4 100644 --- a/web/.storybook/preview.ts +++ b/web/.storybook/preview.ts @@ -9,6 +9,11 @@ import "@patternfly/patternfly/patternfly-base.css"; const preview: Preview = { parameters: { + options: { + storySort: { + method: "alphabetical", + }, + }, actions: { argTypesRegex: "^on[A-Z].*" }, cssUserPrefs: { "prefers-color-scheme": "light", diff --git a/web/package.json b/web/package.json index aa4e0b3f9f..7dbf008e52 100644 --- a/web/package.json +++ b/web/package.json @@ -10,12 +10,12 @@ "build-locales:repair": "prettier --write ./src/locale-codes.ts", "esbuild:build": "node build.mjs", "esbuild:build-proxy": "node build.mjs --proxy", - "esbuild:watch": "node build.mjs --watch", + "esbuild:watch": "bun build.mjs --watch", "build": "run-s build-locales esbuild:build", "build-proxy": "run-s build-locales esbuild:build-proxy", "watch": "run-s build-locales esbuild:watch", "lint": "cross-env NODE_OPTIONS='--max_old_space_size=65536' eslint . --max-warnings 0 --fix", - "lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=65536' node scripts/eslint-precommit.mjs", + "lint:precommit": "bun scripts/eslint-precommit.mjs", "lint:spelling": "node scripts/check-spelling.mjs", "lit-analyse": "lit-analyzer src", "precommit": "npm-run-all --parallel tsc lit-analyse lint:spelling --sequential lint:precommit prettier", diff --git a/web/scripts/eslint-precommit.mjs b/web/scripts/eslint-precommit.mjs index c65f953e21..5f7140adc6 100644 --- a/web/scripts/eslint-precommit.mjs +++ b/web/scripts/eslint-precommit.mjs @@ -29,6 +29,7 @@ const eslintConfig = { sourceType: "module", }, plugins: ["@typescript-eslint", "lit", "custom-elements", "sonarjs"], + ignorePatterns: ["!./.storybook/**/*.ts"], rules: { "indent": "off", "linebreak-style": ["error", "unix"], @@ -60,9 +61,14 @@ const modified = (s) => isModified.test(s); const isCheckable = /\.(ts|js|mjs)$/; const checkable = (s) => isCheckable.test(s); +const ignored = /\/\.storybook\//; +const notIgnored = (s) => !ignored.test(s); + const updated = statuses.reduce( (acc, [status, filename]) => - modified(status) && checkable(filename) ? [...acc, path.join(projectRoot, filename)] : acc, + modified(status) && checkable(filename) && notIgnored(filename) + ? [...acc, path.join(projectRoot, filename)] + : acc, [], ); @@ -72,5 +78,6 @@ const formatter = await eslint.loadFormatter("stylish"); const resultText = formatter.format(results); const errors = results.reduce((acc, result) => acc + result.errorCount, 0); +// eslint-disable-next-line no-console console.log(resultText); process.exit(errors > 1 ? 1 : 0); diff --git a/web/scripts/eslint.mjs b/web/scripts/eslint.mjs new file mode 100644 index 0000000000..ed5142f74e --- /dev/null +++ b/web/scripts/eslint.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node --max_old_space_size=65536 +import { execFileSync } from "child_process"; +import { ESLint } from "eslint"; +import path from "path"; +import process from "process"; + +// Code assumes this script is in the './web/scripts' folder. +const projectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).replace("\n", ""); +process.chdir(path.join(projectRoot, "./web")); + +const eslintConfig = { + fix: true, + overrideConfig: { + env: { + browser: true, + es2021: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:lit/recommended", + "plugin:custom-elements/recommended", + "plugin:storybook/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + project: true, + }, + plugins: ["@typescript-eslint", "lit", "custom-elements"], + ignorePatterns: ["authentik-live-tests/**"], + rules: { + "indent": "off", + "linebreak-style": ["error", "unix"], + "quotes": ["error", "double", { avoidEscape: true }], + "semi": ["error", "always"], + "@typescript-eslint/ban-ts-comment": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "no-console": ["error", { allow: ["debug", "warn", "error"] }], + }, + }, +}; + +const eslint = new ESLint(eslintConfig); +const results = await eslint.lintFiles("."); +const formatter = await eslint.loadFormatter("stylish"); +const resultText = formatter.format(results); +const errors = results.reduce((acc, result) => acc + result.errorCount, 0); + +// eslint-disable-next-line no-console +console.log(resultText); +process.exit(errors > 1 ? 1 : 0); diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index d24d17814d..d524d71cae 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -12,6 +12,8 @@ import { me } from "@goauthentik/common/users"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/cards/AggregatePromiseCard"; +import "@goauthentik/elements/cards/QuickActionsCard.js"; +import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js"; import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; import { msg, str } from "@lit/localize"; @@ -20,7 +22,6 @@ import { customElement, state } from "lit/decorators.js"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFDivider from "@patternfly/patternfly/components/Divider/divider.css"; -import PFList from "@patternfly/patternfly/components/List/list.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; @@ -33,6 +34,11 @@ export function versionFamily(): string { return parts.join("."); } +const RELEASE = `${VERSION.split(".").slice(0, -1).join(".")}#fixed-in-${VERSION.replaceAll( + ".", + "", +)}`; + @customElement("ak-admin-overview") export class AdminOverviewPage extends AKElement { static get styles(): CSSResult[] { @@ -41,7 +47,6 @@ export class AdminOverviewPage extends AKElement { PFGrid, PFPage, PFContent, - PFList, PFDivider, css` .pf-l-grid__item { @@ -64,6 +69,14 @@ export class AdminOverviewPage extends AKElement { ]; } + quickActions: QuickAction[] = [ + [msg("Create a new application"), paramURL("/core/applications", { createForm: true })], + [msg("Check the logs"), paramURL("/events/log")], + [msg("Explore integrations"), "https://goauthentik.io/integrations/", true], + [msg("Manage users"), paramURL("/identity/users")], + [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true], + ]; + @state() user?: SessionUser; @@ -83,56 +96,8 @@ export class AdminOverviewPage extends AKElement { class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl pf-l-grid pf-m-gutter" >
- - - + +
${this.renderHeaderLink()}
-
+
${this.renderInner()} ${this.subtext ? html`

${this.subtext}

` : html``}
@@ -88,3 +96,9 @@ export class AggregateCard extends AKElement {
`; } } + +declare global { + interface HTMLElementTagNameMap { + "ak-aggregate-card": AggregateCard; + } +} diff --git a/web/src/elements/cards/AggregatePromiseCard.ts b/web/src/elements/cards/AggregatePromiseCard.ts index f3ea049210..3e52700b3f 100644 --- a/web/src/elements/cards/AggregatePromiseCard.ts +++ b/web/src/elements/cards/AggregatePromiseCard.ts @@ -1,22 +1,34 @@ import { PFSize } from "@goauthentik/common/enums.js"; import "@goauthentik/elements/Spinner"; -import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard"; +import { AggregateCard, type IAggregateCard } from "@goauthentik/elements/cards/AggregateCard"; -import { TemplateResult, html } from "lit"; +import { msg } from "@lit/localize"; +import { TemplateResult, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; import { until } from "lit/directives/until.js"; +export interface IAggregatePromiseCard extends IAggregateCard { + promise?: Promise>; +} + @customElement("ak-aggregate-card-promise") -export class AggregatePromiseCard extends AggregateCard { +export class AggregatePromiseCard extends AggregateCard implements IAggregatePromiseCard { @property({ attribute: false }) promise?: Promise>; - async promiseProxy(): Promise { - if (!this.promise) { - return html``; + async promiseProxy(): Promise { + try { + if (!this.promise) { + return nothing; + } + const value = await this.promise; + return html` ${value.toString()}`; + } catch (error: unknown) { + console.warn(error); + return html` ${msg( + "Operation failed to complete", + )}`; } - const value = await this.promise; - return html` ${value.toString()}`; } renderInner(): TemplateResult { @@ -25,3 +37,9 @@ export class AggregatePromiseCard extends AggregateCard {

`; } } + +declare global { + interface HTMLElementTagNameMap { + "ak-aggregate-card-promise": AggregatePromiseCard; + } +} diff --git a/web/src/elements/cards/QuickActionsCard.ts b/web/src/elements/cards/QuickActionsCard.ts new file mode 100644 index 0000000000..745e9f7594 --- /dev/null +++ b/web/src/elements/cards/QuickActionsCard.ts @@ -0,0 +1,73 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/cards/AggregateCard.js"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { map } from "lit/directives/map.js"; + +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +export type QuickAction = [label: string, url: string, isExternal?: boolean]; + +/** + * class QuickActionsCard + * element ak-quick-actions-card + * + * Specialized card for navigation. + */ + +export interface IQuickActionsCard { + title: string; + actions: QuickAction[]; +} + +@customElement("ak-quick-actions-card") +export class QuickActionsCard extends AKElement implements IQuickActionsCard { + static get styles() { + return [PFBase, PFList]; + } + + /** + * Card title + * + * @attr + */ + @property() + title = msg("Quick actions"); + + /** + * Card contents. An array of [label, url, isExternal]. External links will + * be rendered with an external link icon and will always open in a new tab. + * + * @attr + */ + @property({ type: Array }) + actions: QuickAction[] = []; + + render() { + const renderItem = ([label, url, external]: QuickAction) => + html`
  • + + ${external + ? html`${label} ` + : label} + +
  • `; + + return html` +
      + ${map(this.actions, renderItem)} +
    +
    `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-quick-actions-card": QuickActionsCard; + } +} diff --git a/web/src/elements/cards/stories/AggregateCard.stories.ts b/web/src/elements/cards/stories/AggregateCard.stories.ts new file mode 100644 index 0000000000..1e1b4cf9e1 --- /dev/null +++ b/web/src/elements/cards/stories/AggregateCard.stories.ts @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; + +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { AggregateCard, type IAggregateCard } from "../AggregateCard.js"; +import "../AggregateCard.js"; + +const metadata: Meta = { + title: "Elements/", + component: "ak-aggregate-card", + parameters: { + docs: { + description: "A specialized card for displaying collections", + }, + }, + argTypes: { + icon: { control: "text" }, + header: { control: "text" }, + headerLink: { control: "text" }, + subtext: { control: "text" }, + leftJustified: { control: "boolean" }, + }, +}; + +export default metadata; + +export const DefaultStory: StoryObj = { + args: { + icon: undefined, + header: "Default", + headerLink: undefined, + subtext: undefined, + isCenter: false, + }, + render: ({ icon, header, headerLink, subtext, leftJustified }: IAggregateCard) => { + return html`
    + + +

    + Form without content style without meaning quick-win, for that is a good problem + to have, so this is our north star design. Can you champion this cross sabers + run it up the flagpole, ping the boss and circle back race without a finish line + in an ideal world. Price point innovation is hot right now, nor it's not hard + guys, but race without a finish line, nor thought shower. +

    +
    +
    `; + }, +}; diff --git a/web/src/elements/cards/stories/AggregatePromiseCard.stories.ts b/web/src/elements/cards/stories/AggregatePromiseCard.stories.ts new file mode 100644 index 0000000000..016f28929c --- /dev/null +++ b/web/src/elements/cards/stories/AggregatePromiseCard.stories.ts @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; + +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { AggregatePromiseCard, type IAggregatePromiseCard } from "../AggregatePromiseCard.js"; +import "../AggregatePromiseCard.js"; + +const metadata: Meta = { + title: "Elements/", + component: "ak-aggregate-card-promise", + parameters: { + docs: { + description: "A specialized card for displaying information after a fetch", + }, + }, + argTypes: { + icon: { control: "text" }, + header: { control: "text" }, + headerLink: { control: "text" }, + subtext: { control: "text" }, + leftJustified: { control: "boolean" }, + }, +}; + +export default metadata; + +const text = + "Curl up and sleep on the freshly laundered towels mew, but make meme, make cute face growl at dogs in my sleep. Scratch me there, elevator butt humans,humans, humans oh how much they love us felines we are the center of attention they feed, they clean hopped up on catnip mice. Kitty time flop over, for see owner, run in terror"; + +export const DefaultStory: StoryObj = { + args: { + icon: undefined, + header: "Default", + headerLink: undefined, + subtext: "Demo has an eight second delay until resolution", + leftJustified: false, + }, + render: ({ icon, header, headerLink, subtext, leftJustified }: IAggregatePromiseCard) => { + const runThis = (timeout: number, value: string) => + new Promise((resolve) => setTimeout(resolve, timeout, value)); + + return html`
    + + + +
    `; + }, +}; + +export const PromiseRejected: StoryObj = { + args: { + icon: undefined, + header: "Default", + headerLink: undefined, + subtext: "Demo has an eight second delay until rejection", + leftJustified: false, + }, + render: ({ icon, header, headerLink, subtext, leftJustified }: IAggregatePromiseCard) => { + const runThis = (timeout: number, value: string) => + new Promise((_resolve, reject) => setTimeout(reject, timeout, value)); + + return html`
    + + + +
    `; + }, +}; diff --git a/web/src/elements/cards/stories/QuickActionCard.stories.ts b/web/src/elements/cards/stories/QuickActionCard.stories.ts new file mode 100644 index 0000000000..be99f56ecb --- /dev/null +++ b/web/src/elements/cards/stories/QuickActionCard.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; + +import { html } from "lit"; + +import "../QuickActionsCard.js"; +import { QuickAction, QuickActionsCard } from "../QuickActionsCard.js"; + +const ACTIONS: QuickAction[] = [ + ["Create a new application", "/core/applications"], + ["Check the logs", "/events/log"], + ["Explore integrations", "https://goauthentik.io/integrations/", true], + ["Manage users", "/identity/users"], + ["Check the release notes", "https://goauthentik.io/docs/releases/", true], +]; + +const metadata: Meta = { + title: "Elements/", + component: "ak-quick-action-card", + parameters: { + docs: { + description: "A specialized card for a list of navigation links", + }, + }, + argTypes: { + title: { control: "text" }, + }, +}; + +export default metadata; + +export const DefaultStory: StoryObj = { + args: { + title: "Quick actions", + }, + render: ({ title }) => { + return html`
    + + +
    `; + }, +}; diff --git a/web/tsconfig.json b/web/tsconfig.json index 887178d6d3..9793212610 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -2,17 +2,17 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "paths": { - "@goauthentik/authentik/*": ["src/*"], - "@goauthentik/admin/*": ["src/admin/*"], - "@goauthentik/common/*": ["src/common/*"], - "@goauthentik/components/*": ["src/components/*"], + "@goauthentik/authentik/*": ["./src/*"], + "@goauthentik/admin/*": ["./src/admin/*"], + "@goauthentik/common/*": ["./src/common/*"], + "@goauthentik/components/*": ["./src/components/*"], "@goauthentik/docs/*": ["../website/docs/*"], - "@goauthentik/elements/*": ["src/elements/*"], - "@goauthentik/flow/*": ["src/flow/*"], - "@goauthentik/locales/*": ["src/locales/*"], - "@goauthentik/polyfill/*": ["src/polyfill/*"], - "@goauthentik/standalone/*": ["src/standalone/*"], - "@goauthentik/user/*": ["src/user/*"] - }, + "@goauthentik/elements/*": ["./src/elements/*"], + "@goauthentik/flow/*": ["./src/flow/*"], + "@goauthentik/locales/*": ["./src/locales/*"], + "@goauthentik/polyfill/*": ["./src/polyfill/*"], + "@goauthentik/standalone/*": ["./src/standalone/*"], + "@goauthentik/user/*": ["./src/user/*"] + } } }