diff --git a/.github/renovate.json b/.github/renovate.json index 776c66af68e..b5c43cc1d39 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -4,13 +4,9 @@ "enabledManagers": ["cargo", "github-actions", "npm"], "packageRules": [ { - "groupName": "gh minor", + "groupName": "github-action minor", "matchManagers": ["github-actions"], - "matchUpdateTypes": ["minor", "patch"] - }, - { - "matchManagers": ["github-actions"], - "commitMessagePrefix": "[deps] BRE:" + "matchUpdateTypes": ["minor"] }, { "matchManagers": ["cargo"], @@ -73,6 +69,29 @@ "commitMessagePrefix": "[deps] Auth:", "reviewers": ["team:team-auth-dev"] }, + { + "matchPackageNames": [ + "@angular-eslint/eslint-plugin-template", + "@angular-eslint/eslint-plugin", + "@angular-eslint/schematics", + "@angular-eslint/template-parser", + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "eslint-config-prettier", + "eslint-import-resolver-typescript", + "eslint-plugin-import", + "eslint-plugin-rxjs-angular", + "eslint-plugin-rxjs", + "eslint-plugin-storybook", + "eslint-plugin-tailwindcss", + "eslint", + "husky", + "lint-staged" + ], + "description": "Architecture owned dependencies", + "commitMessagePrefix": "[deps] Architecture:", + "reviewers": ["team:dept-architecture"] + }, { "matchPackageNames": [ "@emotion/css", @@ -86,6 +105,7 @@ "prettier", "prettier-plugin-tailwindcss", "rimraf", + "@storybook/web-components-webpack5", "tabbable", "tldts", "wait-on" @@ -193,28 +213,11 @@ }, { "matchPackageNames": [ - "@angular-eslint/eslint-plugin", - "@angular-eslint/eslint-plugin-template", - "@angular-eslint/schematics", - "@angular-eslint/template-parser", - "@angular/elements", "@types/jest", - "@typescript-eslint/eslint-plugin", - "@typescript-eslint/parser", - "eslint", - "eslint-config-prettier", - "eslint-import-resolver-typescript", - "eslint-plugin-import", - "eslint-plugin-rxjs", - "eslint-plugin-rxjs-angular", - "eslint-plugin-storybook", - "eslint-plugin-tailwindcss", - "husky", - "jest-extended", "jest-junit", "jest-mock-extended", "jest-preset-angular", - "lint-staged", + "jest-diff", "ts-jest" ], "description": "Secrets Manager owned dependencies", diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index 91250a443f2..4ec3af3be97 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -1,4 +1,5 @@ name: Staged Rollout Desktop +run-name: Staged Rollout Desktop - ${{ inputs.rollout_percentage }}% on: workflow_dispatch: diff --git a/angular.json b/angular.json index 7053050262e..665d810cf4e 100644 --- a/angular.json +++ b/angular.json @@ -147,6 +147,10 @@ "./tsconfig.json", "-e", "json", + "--disableInternal", + "--disableLifeCycleHooks", + "--disablePrivate", + "--disableProtected", "-d", ".", "--disableRoutesGraph" @@ -165,6 +169,10 @@ "./tsconfig.json", "-e", "json", + "--disableInternal", + "--disableLifeCycleHooks", + "--disablePrivate", + "--disableProtected", "-d", ".", "--disableRoutesGraph" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 51fb3a0a770..51e1203673b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4007,6 +4007,9 @@ "passkeyRemoved": { "message": "Passkey removed" }, + "autofillSuggestions": { + "message": "Autofill suggestions" + }, "itemSuggestions": { "message": "Suggested items" }, @@ -4586,12 +4589,6 @@ "textSends": { "message": "Text Sends" }, - "bitwardenNewLook": { - "message": "Bitwarden has a new look!" - }, - "bitwardenNewLookDesc": { - "message": "It's easier and more intuitive than ever to autofill and search from the Vault tab. Take a look around!" - }, "accountActions": { "message": "Account actions" }, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 0f2754b2bf2..8bc28c9754d 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -20,7 +20,7 @@ {{ biometricUnavailabilityReason }} - + + dirname(require.resolve(join(value, "package.json"))); + +const config: StorybookConfig = { + stories: ["../lit-stories/**/*.lit-stories.@(js|jsx|ts|tsx)"], + addons: [ + getAbsolutePath("@storybook/addon-links"), + getAbsolutePath("@storybook/addon-essentials"), + getAbsolutePath("@storybook/addon-a11y"), + getAbsolutePath("@storybook/addon-designs"), + getAbsolutePath("@storybook/addon-interactions"), + { + name: "@storybook/addon-docs", + options: { + mdxPluginOptions: { + mdxCompileOptions: { + remarkPlugins: [remarkGfm], + }, + }, + }, + }, + ], + framework: { + name: getAbsolutePath("@storybook/web-components-webpack5"), + options: { + legacyRootApi: true, + }, + }, + core: { + disableTelemetry: true, + }, + env: (existingConfig) => ({ + ...existingConfig, + FLAGS: JSON.stringify({}), + }), + webpackFinal: async (config) => { + if (config.resolve) { + config.resolve.plugins = [ + new TsconfigPathsPlugin({ + configFile: path.resolve(__dirname, "../../../../../tsconfig.json"), + }), + ] as any; + } + + if (config.module && config.module.rules) { + config.module.rules.push({ + test: /\.(ts|tsx)$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve("ts-loader"), + }, + ], + }); + } + return config; + }, + docs: {}, +}; + +export default config; diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts new file mode 100644 index 00000000000..aa53555d116 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts @@ -0,0 +1,34 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { ActionButton } from "../../buttons/action-button"; + +type Args = { + buttonText: string; + disabled: boolean; + theme: Theme; + buttonAction: (e: Event) => void; +}; + +export default { + title: "Components/Buttons/Action Button", + argTypes: { + buttonText: { control: "text" }, + disabled: { control: "boolean" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + buttonAction: { control: false }, + }, + args: { + buttonText: "Click Me", + disabled: false, + theme: ThemeTypes.Light, + buttonAction: () => alert("Clicked"), + }, +} as Meta; + +const Template = (args: Args) => ActionButton({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/badge-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/badge-button.lit-stories.ts new file mode 100644 index 00000000000..876a70eebc1 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/badge-button.lit-stories.ts @@ -0,0 +1,34 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { BadgeButton } from "../../buttons/badge-button"; + +type Args = { + buttonAction: (e: Event) => void; + buttonText: string; + disabled?: boolean; + theme: Theme; +}; + +export default { + title: "Components/Buttons/Badge Button", + argTypes: { + buttonText: { control: "text" }, + disabled: { control: "boolean" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + buttonAction: { control: false }, + }, + args: { + buttonText: "Click Me", + disabled: false, + theme: ThemeTypes.Light, + buttonAction: () => alert("Clicked"), + }, +} as Meta; + +const Template = (args: Args) => BadgeButton({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/close-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/close-button.lit-stories.ts new file mode 100644 index 00000000000..dc202f330ae --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/close-button.lit-stories.ts @@ -0,0 +1,29 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { CloseButton } from "../../buttons/close-button"; + +type Args = { + handleCloseNotification: (e: Event) => void; + theme: Theme; +}; +export default { + title: "Components/Buttons/Close Button", + argTypes: { + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + handleCloseNotification: { control: false }, + }, + args: { + theme: ThemeTypes.Light, + handleCloseNotification: () => { + alert("Close button clicked!"); + }, + }, +} as Meta; + +const Template = (args: Args) => CloseButton({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/edit-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/edit-button.lit-stories.ts new file mode 100644 index 00000000000..769fe475dd5 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/edit-button.lit-stories.ts @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { EditButton } from "../../buttons/edit-button"; + +type Args = { + buttonAction: (e: Event) => void; + buttonText: string; + disabled?: boolean; + theme: Theme; +}; +export default { + title: "Components/Buttons/Edit Button", + argTypes: { + buttonText: { control: "text" }, + disabled: { control: "boolean" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + buttonAction: { control: false }, + }, + args: { + buttonText: "Click Me", + disabled: false, + theme: ThemeTypes.Light, + buttonAction: () => alert("Clicked"), + }, +} as Meta; + +const Template = (args: Args) => EditButton({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts new file mode 100644 index 00000000000..e597cddabe6 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { NotificationTypes } from "../../../../notification/abstractions/notification-bar"; +import { CipherAction } from "../../cipher/cipher-action"; + +type Args = { + handleAction?: (e: Event) => void; + notificationType: typeof NotificationTypes.Change | typeof NotificationTypes.Add; + theme: Theme; +}; +export default { + title: "Components/Ciphers/Cipher Action", + argTypes: { + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + notificationType: { + control: "select", + options: [NotificationTypes.Change, NotificationTypes.Add], + }, + handleAction: { control: false }, + }, + args: { + theme: ThemeTypes.Light, + notificationType: NotificationTypes.Change, + handleAction: () => { + alert("Action triggered!"); + }, + }, +} as Meta; + +const Template = (args: Args) => CipherAction({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-icon.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-icon.lit-stories.ts new file mode 100644 index 00000000000..a8884f063de --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-icon.lit-stories.ts @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { CipherIcon } from "../../cipher/cipher-icon"; + +type Args = { + color: string; + size: string; + theme: Theme; + uri?: string; +}; + +export default { + title: "Components/Ciphers/Cipher Icon", + argTypes: { + color: { control: "color" }, + size: { control: "text" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + uri: { control: "text" }, + }, + args: { + size: "50px", + theme: ThemeTypes.Light, + uri: "", + }, +} as Meta; + +const Template = (args: Args) => { + return html` +
+ ${CipherIcon({ ...args })} +
+ `; +}; + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts new file mode 100644 index 00000000000..2d031fa3afd --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { CipherInfoIndicatorIcons } from "../../cipher/cipher-indicator-icons"; + +type Args = { + isBusinessOrg?: boolean; + isFamilyOrg?: boolean; + theme: Theme; +}; + +export default { + title: "Components/Ciphers/Cipher Indicator Icon", + argTypes: { + isBusinessOrg: { control: "boolean" }, + isFamilyOrg: { control: "boolean" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + }, + args: { + theme: ThemeTypes.Light, + isBusinessOrg: true, + isFamilyOrg: false, + }, +} as Meta; + +const Template: StoryObj["render"] = (args) => + html`
${CipherInfoIndicatorIcons({ ...args })}
`; + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts new file mode 100644 index 00000000000..20c88a59246 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts @@ -0,0 +1,66 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import * as Icons from "../../icons"; + +type Args = { + color?: string; + disabled?: boolean; + theme: Theme; + size: number; + iconLink: URL; +}; + +export default { + title: "Components/Icons/Icons", + argTypes: { + iconLink: { control: "text" }, + color: { control: "color" }, + disabled: { control: "boolean" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + size: { control: "number", min: 10, max: 100, step: 1 }, + }, + args: { + iconLink: new URL("https://bitwarden.com"), + disabled: false, + theme: ThemeTypes.Light, + size: 50, + }, +} as Meta; + +const Template = (args: Args, IconComponent: (props: Args) => ReturnType) => html` +
+ ${IconComponent({ ...args })} +
+`; + +const createIconStory = (iconName: keyof typeof Icons): StoryObj => { + const story = { + render: (args) => Template(args, Icons[iconName]), + } as StoryObj; + + if (iconName !== "BrandIconContainer") { + story.argTypes = { + iconLink: { table: { disable: true } }, + }; + } + + return story; +}; + +export const AngleDownIcon = createIconStory("AngleDown"); +export const BusinessIcon = createIconStory("Business"); +export const BrandIcon = createIconStory("BrandIconContainer"); +export const CloseIcon = createIconStory("Close"); +export const ExclamationTriangleIcon = createIconStory("ExclamationTriangle"); +export const FamilyIcon = createIconStory("Family"); +export const FolderIcon = createIconStory("Folder"); +export const GlobeIcon = createIconStory("Globe"); +export const PartyHornIcon = createIconStory("PartyHorn"); +export const PencilSquareIcon = createIconStory("PencilSquare"); +export const ShieldIcon = createIconStory("Shield"); +export const UserIcon = createIconStory("User"); diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts new file mode 100644 index 00000000000..00ea905a2f3 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts @@ -0,0 +1,53 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; + +import { NotificationType } from "../../../../notification/abstractions/notification-bar"; +import { CipherData } from "../../cipher/types"; +import { NotificationBody } from "../../notification/body"; + +type Args = { + ciphers: CipherData[]; + notificationType: NotificationType; + theme: Theme; +}; + +export default { + title: "Components/Notifications/Notification Body", + argTypes: { + ciphers: { control: "object" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + notificationType: { + control: "select", + options: ["add", "change", "unlock", "fileless-import"], + }, + }, + args: { + ciphers: [ + { + id: "1", + name: "Example Cipher", + type: CipherType.Login, + favorite: false, + reprompt: CipherRepromptType.None, + icon: { + imageEnabled: true, + image: "", + fallbackImage: "https://example.com/fallback.png", + icon: "icon-class", + }, + login: { username: "user@example.com", passkey: null }, + }, + ], + theme: ThemeTypes.Light, + notificationType: "change", + }, +} as Meta; + +const Template = (args: Args) => NotificationBody({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts new file mode 100644 index 00000000000..c8f30eb036f --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { NotificationType } from "../../../../notification/abstractions/notification-bar"; +import { NotificationFooter } from "../../notification/footer"; + +type Args = { + notificationType: NotificationType; + theme: Theme; +}; + +export default { + title: "Components/Notifications/Notification Footer", + argTypes: { + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + notificationType: { + control: "select", + options: ["add", "change", "unlock", "fileless-import"], + }, + }, + args: { + theme: ThemeTypes.Light, + notificationType: "add", + }, +} as Meta; + +const Template = (args: Args) => NotificationFooter({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/header.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/header.lit-stories.ts new file mode 100644 index 00000000000..fd8423f995e --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/notification/header.lit-stories.ts @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { NotificationHeader } from "../../notification/header"; + +type Args = { + message: string; + standalone: boolean; + theme: Theme; + handleCloseNotification: (e: Event) => void; +}; + +export default { + title: "Components/Notifications/Notification Header", + argTypes: { + message: { control: "text" }, + standalone: { control: "boolean" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + }, + args: { + message: "This is a notification message", + standalone: true, + theme: ThemeTypes.Light, + handleCloseNotification: () => alert("Close Clicked"), + }, +} as Meta; + +const Template = (args: Args) => NotificationHeader({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/rows/action-row.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/rows/action-row.lit-stories.ts new file mode 100644 index 00000000000..4b100764205 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/rows/action-row.lit-stories.ts @@ -0,0 +1,31 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { ActionRow } from "../../rows/action-row"; + +type Args = { + itemText: string; + handleAction: (e: Event) => void; + theme: Theme; +}; + +export default { + title: "Components/Rows/Action Row", + argTypes: { + itemText: { control: "text" }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + handleAction: { control: false }, + }, + args: { + itemText: "Action Item", + theme: ThemeTypes.Light, + handleAction: () => alert("Action triggered"), + }, +} as Meta; + +const Template = (args: Args) => ActionRow({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts new file mode 100644 index 00000000000..3283c2798a3 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from "@storybook/web-components"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { ButtonRow } from "../../rows/button-row"; + +type Args = { + theme: Theme; +}; + +export default { + title: "Components/Rows/Button Row", + argTypes: { + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + }, + args: { + theme: ThemeTypes.Light, + }, +} as Meta; + +const Template = (args: Args) => ButtonRow({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/rows/item-row.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/rows/item-row.lit-stories.ts new file mode 100644 index 00000000000..fbb65201986 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/rows/item-row.lit-stories.ts @@ -0,0 +1,28 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { TemplateResult } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { ItemRow } from "../../rows/item-row"; + +type Args = { + theme: Theme; + children: TemplateResult | TemplateResult[]; +}; + +export default { + title: "Components/Rows/Item Row", + argTypes: { + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + children: { control: "object" }, + }, + args: { + theme: ThemeTypes.Light, + }, +} as Meta; + +const Template = (args: Args) => ItemRow({ ...args }); + +export const Default: StoryObj = { + render: Template, +}; diff --git a/apps/browser/src/autofill/content/components/package.json b/apps/browser/src/autofill/content/components/package.json new file mode 100644 index 00000000000..8dbe9e7f516 --- /dev/null +++ b/apps/browser/src/autofill/content/components/package.json @@ -0,0 +1,7 @@ +{ + "name": "@bitwarden/lit-components", + "version": "2025.1.1", + "scripts": { + "storybook:lit": "storybook dev -p 6006 -c ./.lit-storybook" + } +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 98f3867e5ff..5d09122bbd6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -665,6 +665,7 @@ export default class MainBackground { this.logService, this.keyService, this.biometricStateService, + this.messagingService, ); this.appIdService = new AppIdService(this.storageService, this.logService); diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts index 4c4753c3f7f..3031134dc34 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; @@ -23,6 +24,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { private logService: LogService, private keyService: KeyService, private biometricStateService: BiometricStateService, + private messagingService: MessagingService, ) { super(); } @@ -96,8 +98,9 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { const userKey = new SymmetricCryptoKey(decodedUserkey) as UserKey; if (await this.keyService.validateUserKey(userKey, userId)) { await this.biometricStateService.setBiometricUnlockEnabled(true); - await this.biometricStateService.setFingerprintValidated(true); - this.keyService.setUserKey(userKey, userId); + await this.keyService.setUserKey(userKey, userId); + // to update badge and other things + this.messagingService.send("switchAccount", { userId }); return userKey; } } else { @@ -114,8 +117,9 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { const userKey = new SymmetricCryptoKey(decodedUserkey) as UserKey; if (await this.keyService.validateUserKey(userKey, userId)) { await this.biometricStateService.setBiometricUnlockEnabled(true); - await this.biometricStateService.setFingerprintValidated(true); - this.keyService.setUserKey(userKey, userId); + await this.keyService.setUserKey(userKey, userId); + // to update badge and other things + this.messagingService.send("switchAccount", { userId }); return userKey; } } else { diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 7b6e402a90f..9d4835889b9 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -137,8 +137,8 @@ export class AppComponent implements OnInit, OnDestroy { this.toastService._showToast(msg); } else if (msg.command === "reloadProcess") { if (this.platformUtilsService.isSafari()) { - window.setTimeout(() => { - this.biometricStateService.updateLastProcessReload(); + window.setTimeout(async () => { + await this.biometricStateService.updateLastProcessReload(); window.location.reload(); }, 2000); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index 047d168ecbb..eae8e2cc980 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -1,7 +1,7 @@ = + this.vaultPopupAutofillService.currentTabIsOnBlocklist$; + constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupAutofillService: VaultPopupAutofillService, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts index 38ec6056d19..1f67dd51c21 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts @@ -152,7 +152,7 @@ describe("VaultHeaderV2Component", () => { it("defaults the initial state to true", (done) => { // The initial value of the `state$` variable above is undefined component["initialDisclosureVisibility$"].subscribe((initialVisibility) => { - expect(initialVisibility).toBeTrue(); + expect(initialVisibility).toBe(true); done(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts deleted file mode 100644 index 20b39c5a88d..00000000000 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - ButtonModule, - DialogModule, - DialogService, - IconModule, - svgIcon, -} from "@bitwarden/components"; - -const announcementIcon = svgIcon` - - - - - - - - - - - - - - - - -`; - -@Component({ - standalone: true, - selector: "app-vault-ui-onboarding", - template: ` - -
- -
- - {{ "bitwardenNewLook" | i18n }} - - - {{ "bitwardenNewLookDesc" | i18n }} - - - - - - -
- `, - imports: [CommonModule, DialogModule, ButtonModule, JslibModule, IconModule], -}) -export class VaultUiOnboardingComponent { - icon = announcementIcon; - - static open(dialogService: DialogService) { - return dialogService.open(VaultUiOnboardingComponent); - } - - navigateToLink = async () => { - window.open( - "https://bitwarden.com/blog/bringing-intuitive-workflows-and-visual-updates-to-the-bitwarden-browser/", - "_blank", - ); - }; -} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index a0c54987357..7c21c7e6a0c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -19,7 +19,6 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; -import { VaultUiOnboardingService } from "../../services/vault-ui-onboarding.service"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { @@ -58,7 +57,6 @@ enum VaultState { VaultHeaderV2Component, DecryptionFailureDialogComponent, ], - providers: [VaultUiOnboardingService], }) export class VaultV2Component implements OnInit, OnDestroy { cipherType = CipherType; @@ -93,7 +91,6 @@ export class VaultV2Component implements OnInit, OnDestroy { constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupListFiltersService: VaultPopupListFiltersService, - private vaultUiOnboardingService: VaultUiOnboardingService, private destroyRef: DestroyRef, private cipherService: CipherService, private dialogService: DialogService, @@ -123,8 +120,6 @@ export class VaultV2Component implements OnInit, OnDestroy { } async ngOnInit() { - await this.vaultUiOnboardingService.showOnboardingDialog(); - this.cipherService.failedToDecryptCiphers$ .pipe( map((ciphers) => ciphers.filter((c) => !c.isDeleted)), diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 7ee15aa833b..526ab2e2579 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -179,7 +179,7 @@ describe("ViewV2Component", () => { flush(); // Resolve all promises - expect(doAutofill).toHaveBeenCalledOnce(); + expect(doAutofill).toHaveBeenCalledTimes(1); })); it('invokes `copy` when action="copy-username"', fakeAsync(() => { @@ -187,7 +187,7 @@ describe("ViewV2Component", () => { flush(); // Resolve all promises - expect(copy).toHaveBeenCalledOnce(); + expect(copy).toHaveBeenCalledTimes(1); })); it('invokes `copy` when action="copy-password"', fakeAsync(() => { @@ -195,7 +195,7 @@ describe("ViewV2Component", () => { flush(); // Resolve all promises - expect(copy).toHaveBeenCalledOnce(); + expect(copy).toHaveBeenCalledTimes(1); })); it('invokes `copy` when action="copy-totp"', fakeAsync(() => { @@ -203,7 +203,7 @@ describe("ViewV2Component", () => { flush(); // Resolve all promises - expect(copy).toHaveBeenCalledOnce(); + expect(copy).toHaveBeenCalledTimes(1); })); it("closes the popout after a load action", fakeAsync(() => { @@ -218,9 +218,9 @@ describe("ViewV2Component", () => { flush(); // Resolve all promises - expect(doAutofill).toHaveBeenCalledOnce(); + expect(doAutofill).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalledWith(99); - expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledTimes(1); })); }); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts index c0ac9c91e18..ff282d7a6d0 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts @@ -127,6 +127,8 @@ export class VaultPopupAutofillService { [currentTabHostname as string]: { bannerIsDismissed: true }, }); } + // FIXME: Remove when updating file. Eslint update + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { throw new Error( "There was a problem dismissing the blocked interaction URI notification banner", diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 0eb91c6cbe2..e1236be08f9 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -488,7 +488,7 @@ describe("VaultPopupListFiltersService", () => { state$.next(true); service.filterVisibilityState$.subscribe((filterVisibility) => { - expect(filterVisibility).toBeTrue(); + expect(filterVisibility).toBe(true); done(); }); }); @@ -496,7 +496,7 @@ describe("VaultPopupListFiltersService", () => { it("updates stored filter state", async () => { await service.updateFilterVisibility(false); - expect(update).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledTimes(1); // Get callback passed to `update` const updateCallback = update.mock.calls[0][0]; expect(updateCallback()).toBe(false); diff --git a/apps/browser/src/vault/popup/services/vault-ui-onboarding.service.ts b/apps/browser/src/vault/popup/services/vault-ui-onboarding.service.ts deleted file mode 100644 index f50d6ebc236..00000000000 --- a/apps/browser/src/vault/popup/services/vault-ui-onboarding.service.ts +++ /dev/null @@ -1,89 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Injectable } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { - GlobalState, - KeyDefinition, - StateProvider, - VAULT_BROWSER_UI_ONBOARDING, -} from "@bitwarden/common/platform/state"; -import { DialogService } from "@bitwarden/components"; - -import { VaultUiOnboardingComponent } from "../components/vault-v2/vault-ui-onboarding/vault-ui-onboarding.component"; - -// Key definition for the Vault UI onboarding state. -// This key is used to store the state of the new UI information dialog. -export const GLOBAL_VAULT_UI_ONBOARDING = new KeyDefinition( - VAULT_BROWSER_UI_ONBOARDING, - "dialogState", - { - deserializer: (obj) => obj, - }, -); - -@Injectable() -export class VaultUiOnboardingService { - private onboardingUiReleaseDate = new Date("2024-12-10"); - - private vaultUiOnboardingState: GlobalState = this.stateProvider.getGlobal( - GLOBAL_VAULT_UI_ONBOARDING, - ); - - private readonly vaultUiOnboardingState$ = this.vaultUiOnboardingState.state$.pipe( - map((x) => x ?? false), - ); - - constructor( - private stateProvider: StateProvider, - private dialogService: DialogService, - private apiService: ApiService, - ) {} - - /** - * Checks whether the onboarding dialog should be shown and opens it if necessary. - * The dialog is shown if the user has not previously viewed it and is not a new account. - */ - async showOnboardingDialog(): Promise { - const hasViewedDialog = await this.getVaultUiOnboardingState(); - - if (!hasViewedDialog && !(await this.isNewAccount())) { - await this.openVaultUiOnboardingDialog(); - } - } - - private async openVaultUiOnboardingDialog(): Promise { - const dialogRef = VaultUiOnboardingComponent.open(this.dialogService); - - const result = firstValueFrom(dialogRef.closed); - - // Update the onboarding state when the dialog is closed - await this.setVaultUiOnboardingState(true); - - return result; - } - - private async isNewAccount(): Promise { - const userProfile = await this.apiService.getProfile(); - const profileCreationDate = new Date(userProfile.creationDate); - return profileCreationDate > this.onboardingUiReleaseDate; - } - - /** - * Updates and saves the state indicating whether the user has viewed - * the new UI onboarding information dialog. - */ - private async setVaultUiOnboardingState(value: boolean): Promise { - await this.vaultUiOnboardingState.update(() => value); - } - - /** - * Retrieves the current state indicating whether the user has viewed - * the new UI onboarding information dialog.s - */ - private async getVaultUiOnboardingState(): Promise { - return await firstValueFrom(this.vaultUiOnboardingState$); - } -} diff --git a/apps/cli/package.json b/apps/cli/package.json index 8a6dffb1cb3..d1d8ac76ec4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -63,7 +63,7 @@ "browser-hrtime": "1.1.8", "chalk": "4.1.2", "commander": "11.1.0", - "form-data": "4.0.0", + "form-data": "4.0.1", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", "jsdom": "25.0.1", diff --git a/apps/desktop/desktop_native/core/src/password/windows.rs b/apps/desktop/desktop_native/core/src/password/windows.rs index 32300b9f81f..8b297fc33b7 100644 --- a/apps/desktop/desktop_native/core/src/password/windows.rs +++ b/apps/desktop/desktop_native/core/src/password/windows.rs @@ -13,7 +13,7 @@ use windows::{ const CRED_FLAGS_NONE: u32 = 0; -pub async fn get_password<'a>(service: &str, account: &str) -> Result { +pub async fn get_password(service: &str, account: &str) -> Result { let target_name = U16CString::from_str(target_name(service, account))?; let mut credential: *mut CREDENTIALW = std::ptr::null_mut(); diff --git a/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m b/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m index 8b73635a7ca..fc13c04591a 100644 --- a/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m +++ b/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m @@ -30,21 +30,24 @@ void runSync(void* context, NSDictionary *params) { [mappedCredentials addObject:credential]; } - if ([type isEqualToString:@"fido2"]) { - NSString *cipherId = credential[@"cipherId"]; - NSString *rpId = credential[@"rpId"]; - NSString *userName = credential[@"userName"]; - NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); - NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); + if (@available(macos 14, *)) { + if ([type isEqualToString:@"fido2"]) { + NSString *cipherId = credential[@"cipherId"]; + NSString *rpId = credential[@"rpId"]; + NSString *userName = credential[@"userName"]; + NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); + NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); - ASPasskeyCredentialIdentity *credential = [[ASPasskeyCredentialIdentity alloc] - initWithRelyingPartyIdentifier:rpId - userName:userName - credentialID:credentialId - userHandle:userHandle - recordIdentifier:cipherId]; + Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); + id credential = [[passkeyCredentialIdentityClass alloc] + initWithRelyingPartyIdentifier:rpId + userName:userName + credentialID:credentialId + userHandle:userHandle + recordIdentifier:cipherId]; - [mappedCredentials addObject:credential]; + [mappedCredentials addObject:credential]; + } } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64df6696ad7..8e903a2e770 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.1.1", + "version": "2025.1.3", "keywords": [ "bitwarden", "password", @@ -35,7 +35,7 @@ "clean:dist": "rimraf ./dist", "pack:dir": "npm run clean:dist && electron-builder --dir -p never", "pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", - "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && mksquashfs ./dist/tmp-snap/ $SNAP_FILE -noappend -comp lzo -no-fragments && rm -rf ./dist/tmp-snap/", + "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snapcraft pack ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/", "pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz ./dist/linux-arm64-unpacked/", "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 19748e797bb..d35845fa6aa 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -362,14 +362,24 @@ export class SettingsComponent implements OnInit, OnDestroy { } }); - this.supportsBiometric = - (await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available; + this.supportsBiometric = this.shouldAllowBiometricSetup( + await this.biometricsService.getBiometricsStatus(), + ); this.timerId = setInterval(async () => { - this.supportsBiometric = - (await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available; + this.supportsBiometric = this.shouldAllowBiometricSetup( + await this.biometricsService.getBiometricsStatus(), + ); }, 1000); } + private shouldAllowBiometricSetup(biometricStatus: BiometricsStatus): boolean { + return [ + BiometricsStatus.Available, + BiometricsStatus.AutoSetupNeeded, + BiometricsStatus.ManualSetupNeeded, + ].includes(biometricStatus); + } + async saveVaultTimeout(newValue: VaultTimeout) { if (newValue === VaultTimeoutStringType.Never) { const confirmed = await this.dialogService.openSimpleDialog({ @@ -650,7 +660,7 @@ export class SettingsComponent implements OnInit, OnDestroy { const skipSupportedPlatformCheck = ipc.platform.allowBrowserintegrationOverride || ipc.platform.isDev; - if (skipSupportedPlatformCheck) { + if (!skipSupportedPlatformCheck) { if ( ipc.platform.deviceType === DeviceType.MacOsDesktop && !this.platformUtilsService.isMacAppStore() diff --git a/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts index e69ebca3630..9e5755dd579 100644 --- a/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts @@ -117,15 +117,15 @@ describe("biometrics tests", function () { const testCases = [ // happy path [true, false, false, BiometricsStatus.Available], - [false, true, true, BiometricsStatus.AutoSetupNeeded], - [false, true, false, BiometricsStatus.ManualSetupNeeded], - [false, false, false, BiometricsStatus.HardwareUnavailable], + [false, true, true, BiometricsStatus.HardwareUnavailable], + [true, true, true, BiometricsStatus.AutoSetupNeeded], + [true, true, false, BiometricsStatus.ManualSetupNeeded], // should not happen [false, false, true, BiometricsStatus.HardwareUnavailable], - [true, true, true, BiometricsStatus.Available], - [true, true, false, BiometricsStatus.Available], [true, false, true, BiometricsStatus.Available], + [false, true, false, BiometricsStatus.HardwareUnavailable], + [false, false, false, BiometricsStatus.HardwareUnavailable], ]; for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) { diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index 06956503a05..d0ba66fdad4 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -60,6 +60,8 @@ export class MainBiometricsService extends DesktopBiometricsService { */ async getBiometricsStatus(): Promise { if (!(await this.osBiometricsService.osSupportsBiometric())) { + return BiometricsStatus.HardwareUnavailable; + } else { if (await this.osBiometricsService.osBiometricsNeedsSetup()) { if (await this.osBiometricsService.osBiometricsCanAutoSetup()) { return BiometricsStatus.AutoSetupNeeded; @@ -67,8 +69,6 @@ export class MainBiometricsService extends DesktopBiometricsService { return BiometricsStatus.ManualSetupNeeded; } } - - return BiometricsStatus.HardwareUnavailable; } return BiometricsStatus.Available; } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index d8705487d86..e825bd41581 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.1.1", + "version": "2025.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.1.1", + "version": "2025.1.3", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 95490ee34dd..6feed970798 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.1.1", + "version": "2025.1.3", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 0b024817edc..c1112c51e39 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -77,10 +77,7 @@ export class OrganizationLayoutComponent implements OnInit { filter((org) => org != null), ); - this.canAccessExport$ = combineLatest([ - this.organization$, - this.configService.getFeatureFlag$(FeatureFlag.PM11360RemoveProviderExportPermission), - ]).pipe(map(([org, removeProviderExport]) => org.canAccessExport(removeProviderExport))); + this.canAccessExport$ = this.organization$.pipe(map((org) => org.canAccessExport)); this.showPaymentAndHistory$ = this.organization$.pipe( map( diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index ac2c7448b0a..06ceaa0d9c7 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -1,13 +1,8 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { inject, NgModule } from "@angular/core"; -import { CanMatchFn, RouterModule, Routes } from "@angular/router"; -import { map } from "rxjs"; +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard"; import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard"; @@ -16,11 +11,6 @@ import { PoliciesComponent } from "../../organizations/policies"; import { AccountComponent } from "./account.component"; import { TwoFactorSetupComponent } from "./two-factor-setup.component"; -const removeProviderExportPermission$: CanMatchFn = () => - inject(ConfigService) - .getFeatureFlag$(FeatureFlag.PM11360RemoveProviderExportPermission) - .pipe(map((removeProviderExport) => removeProviderExport === true)); - const routes: Routes = [ { path: "", @@ -68,27 +58,13 @@ const routes: Routes = [ titleId: "importData", }, }, - - // Export routing is temporarily duplicated to set the flag value passed into org.canAccessExport { path: "export", loadComponent: () => import("../tools/vault-export/org-vault-export.component").then( (mod) => mod.OrganizationVaultExportComponent, ), - canMatch: [removeProviderExportPermission$], // if this matches, the flag is ON - canActivate: [organizationPermissionsGuard((org) => org.canAccessExport(true))], - data: { - titleId: "exportVault", - }, - }, - { - path: "export", - loadComponent: () => - import("../tools/vault-export/org-vault-export.component").then( - (mod) => mod.OrganizationVaultExportComponent, - ), - canActivate: [organizationPermissionsGuard((org) => org.canAccessExport(false))], + canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)], data: { titleId: "exportVault", }, @@ -118,7 +94,8 @@ function getSettingsRoute(organization: Organization) { if (organization.canManageDeviceApprovals) { return "device-approvals"; } - return undefined; + + return "/"; } @NgModule({ diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index bc5c7da8db9..73577c7b002 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -1062,7 +1062,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } private refreshSalesTax(): void { - if (!this.taxInformation.country || !this.taxInformation.postalCode) { + if ( + this.taxInformation === undefined || + !this.taxInformation.country || + !this.taxInformation.postalCode + ) { return; } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 4592f8de894..edc29b16049 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -193,7 +193,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.billing = await this.organizationApiService.getBilling(this.organizationId); this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.taxInformation = await this.organizationApiService.getTaxInfo(this.organizationId); - } else { + } else if (!this.selfHosted) { this.taxInformation = await this.apiService.getTaxInfo(); } diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index a7ff50b4264..1c15f7cc7c1 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -157,7 +157,6 @@ export const SMAvailable: Story = { canManageUsers: false, canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => false, }, ] as Organization[], mockProviders: [], @@ -173,7 +172,6 @@ export const SMAndACAvailable: Story = { canManageUsers: true, canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => false, }, ] as Organization[], mockProviders: [], @@ -189,7 +187,6 @@ export const WithAllOptions: Story = { canManageUsers: true, canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => false, }, ] as Organization[], mockProviders: [{ id: "provider-a" }] as Provider[], diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index b53d0243f64..7a4df4bad00 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -176,7 +176,6 @@ export const WithSM: Story = { canManageUsers: false, canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => false, }, ] as Organization[], mockProviders: [], @@ -192,7 +191,6 @@ export const WithSMAndAC: Story = { canManageUsers: true, canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => false, }, ] as Organization[], mockProviders: [], @@ -208,7 +206,6 @@ export const WithAllOptions: Story = { canManageUsers: true, canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => false, }, ] as Organization[], mockProviders: [{ id: "provider-a" }] as Provider[], diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index a071d0f8852..919b3be0424 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -116,7 +116,6 @@ describe("ProductSwitcherService", () => { id: "1234", canAccessSecretsManager: true, enabled: true, - canAccessExport: (_) => true, }, ] as Organization[]); @@ -232,14 +231,12 @@ describe("ProductSwitcherService", () => { canAccessSecretsManager: true, enabled: true, name: "Org 2", - canAccessExport: (_) => true, }, { id: "4243", canAccessSecretsManager: true, enabled: true, name: "Org 32", - canAccessExport: (_) => true, }, ] as Organization[]); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 2779c0470e7..15c5a7fcf6c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -122,6 +122,39 @@ } } }, + "atRiskApplicationsWithCount": { + "message": "At-risk applications ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "atRiskMembersDescription": { + "message": "These members are logging into applications with weak, exposed, or reused passwords." + }, + "atRiskApplicationsDescription": { + "message": "These applications have weak, exposed, or reused passwords." + }, + "atRiskMembersDescriptionWithApp": { + "message": "These members are logging into $APPNAME$ with weak, exposed, or reused passwords.", + "placeholders": { + "appname": { + "content": "$1", + "example": "Salesforce" + } + } + }, + "atRiskMembersWithCount": { + "message": "At-risk members ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "atRiskMembersDescription": { "message": "These members are logging into applications with weak, exposed, or reused passwords." }, @@ -8256,33 +8289,33 @@ "trustedDevices": { "message": "Trusted devices" }, - "memberDecryptionOptionTdeDescriptionPartOne": { - "message": "Once authenticated, members will decrypt vault data using a key stored on their device. The", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO Required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "memberDecryptionOptionTdeDescPart1": { + "message": "Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, - "memberDecryptionOptionTdeDescriptionLinkOne": { + "memberDecryptionOptionTdeDescLink1": { "message": "single organization", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, - "memberDecryptionOptionTdeDescriptionPartTwo": { + "memberDecryptionOptionTdeDescPart2": { "message": "policy,", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, - "memberDecryptionOptionTdeDescriptionLinkTwo": { + "memberDecryptionOptionTdeDescLink2": { "message": "SSO required", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, - "memberDecryptionOptionTdeDescriptionPartThree": { + "memberDecryptionOptionTdeDescPart3": { "message": "policy, and", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, - "memberDecryptionOptionTdeDescriptionLinkThree": { + "memberDecryptionOptionTdeDescLink3": { "message": "account recovery administration", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, - "memberDecryptionOptionTdeDescriptionPartFour": { - "message": "policy with automatic enrollment will turn on when this option is used.", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Once authenticated, members will decrypt vault data using a key stored on their device. The single organization policy, SSO required policy, and account recovery administration policy with automatic enrollment will turn on when this option is used.'" + "memberDecryptionOptionTdeDescPart4": { + "message": "policy will turn on when this option is used.", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "orgPermissionsUpdatedMustSetPassword": { "message": "Your organization permissions were updated, requiring you to set a master password.", diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts index ad87f319e73..947fc8a79d3 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts @@ -30,6 +30,10 @@ export type ApplicationHealthReportDetail = { atRiskMemberDetails: MemberDetailsFlat[]; }; +export type ApplicationHealthReportDetailWithCriticalFlag = ApplicationHealthReportDetail & { + isMarkedAsCritical: boolean; +}; + /** * Breaks the cipher health info out by uri and passes * along the password health and member info @@ -100,3 +104,12 @@ export type AtRiskMemberDetail = { email: string; atRiskPasswordCount: number; }; + +/* + * A list of applications and the count of + * at risk passwords for each application + */ +export type AtRiskApplicationDetail = { + applicationName: string; + atRiskPasswordCount: number; +}; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts new file mode 100644 index 00000000000..838dc2c8241 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts @@ -0,0 +1,79 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { CriticalAppsApiService } from "./critical-apps-api.service"; +import { + PasswordHealthReportApplicationId, + PasswordHealthReportApplicationsRequest, + PasswordHealthReportApplicationsResponse, +} from "./critical-apps.service"; + +describe("CriticalAppsApiService", () => { + let service: CriticalAppsApiService; + const apiService = mock(); + + beforeEach(() => { + service = new CriticalAppsApiService(apiService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should call apiService.send with correct parameters for SaveCriticalApps", (done) => { + const requests: PasswordHealthReportApplicationsRequest[] = [ + { organizationId: "org1" as OrganizationId, url: "test one" }, + { organizationId: "org1" as OrganizationId, url: "test two" }, + ]; + const response: PasswordHealthReportApplicationsResponse[] = [ + { + id: "1" as PasswordHealthReportApplicationId, + organizationId: "org1" as OrganizationId, + uri: "test one", + }, + { + id: "2" as PasswordHealthReportApplicationId, + organizationId: "org1" as OrganizationId, + uri: "test two", + }, + ]; + + apiService.send.mockReturnValue(Promise.resolve(response)); + + service.saveCriticalApps(requests).subscribe((result) => { + expect(result).toEqual(response); + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/reports/password-health-report-applications/", + requests, + true, + true, + ); + done(); + }); + }); + + it("should call apiService.send with correct parameters for GetCriticalApps", (done) => { + const orgId: OrganizationId = "org1" as OrganizationId; + const response: PasswordHealthReportApplicationsResponse[] = [ + { id: "1" as PasswordHealthReportApplicationId, organizationId: orgId, uri: "test one" }, + { id: "2" as PasswordHealthReportApplicationId, organizationId: orgId, uri: "test two" }, + ]; + + apiService.send.mockReturnValue(Promise.resolve(response)); + + service.getCriticalApps(orgId).subscribe((result) => { + expect(result).toEqual(response); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + `/reports/password-health-report-applications/${orgId.toString()}`, + null, + true, + true, + ); + done(); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts new file mode 100644 index 00000000000..edd2cf34b56 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts @@ -0,0 +1,39 @@ +import { from, Observable } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { + PasswordHealthReportApplicationsRequest, + PasswordHealthReportApplicationsResponse, +} from "./critical-apps.service"; + +export class CriticalAppsApiService { + constructor(private apiService: ApiService) {} + + saveCriticalApps( + requests: PasswordHealthReportApplicationsRequest[], + ): Observable { + const dbResponse = this.apiService.send( + "POST", + "/reports/password-health-report-applications/", + requests, + true, + true, + ); + + return from(dbResponse as Promise); + } + + getCriticalApps(orgId: OrganizationId): Observable { + const dbResponse = this.apiService.send( + "GET", + `/reports/password-health-report-applications/${orgId.toString()}`, + null, + true, + true, + ); + + return from(dbResponse as Promise); + } +} diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts new file mode 100644 index 00000000000..c6c4562310e --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts @@ -0,0 +1,142 @@ +import { randomUUID } from "crypto"; + +import { fakeAsync, flush } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { CriticalAppsApiService } from "./critical-apps-api.service"; +import { + CriticalAppsService, + PasswordHealthReportApplicationId, + PasswordHealthReportApplicationsRequest, + PasswordHealthReportApplicationsResponse, +} from "./critical-apps.service"; + +describe("CriticalAppsService", () => { + let service: CriticalAppsService; + const keyService = mock(); + const encryptService = mock(); + const criticalAppsApiService = mock({ + saveCriticalApps: jest.fn(), + getCriticalApps: jest.fn(), + }); + + beforeEach(() => { + service = new CriticalAppsService(keyService, encryptService, criticalAppsApiService); + + // reset mocks + jest.resetAllMocks(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should set critical apps", async () => { + // arrange + const criticalApps = ["https://example.com", "https://example.org"]; + + const request = [ + { organizationId: "org1", url: "encryptedUrlName" }, + { organizationId: "org1", url: "encryptedUrlName" }, + ] as PasswordHealthReportApplicationsRequest[]; + + const response = [ + { id: "id1", organizationId: "org1", uri: "https://example.com" }, + { id: "id2", organizationId: "org1", uri: "https://example.org" }, + ] as PasswordHealthReportApplicationsResponse[]; + + encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName")); + criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response)); + + // act + await service.setCriticalApps("org1", criticalApps); + + // expectations + expect(keyService.getOrgKey).toHaveBeenCalledWith("org1"); + expect(encryptService.encrypt).toHaveBeenCalledTimes(2); + expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request); + }); + + it("should exclude records that already exist", async () => { + // arrange + // one record already exists + service.setAppsInListForOrg([ + { + id: randomUUID() as PasswordHealthReportApplicationId, + organizationId: "org1" as OrganizationId, + uri: "https://example.com", + }, + ]); + + // two records are selected - one already in the database + const selectedUrls = ["https://example.com", "https://example.org"]; + + // expect only one record to be sent to the server + const request = [ + { organizationId: "org1", url: "encryptedUrlName" }, + ] as PasswordHealthReportApplicationsRequest[]; + + // mocked response + const response = [ + { id: "id1", organizationId: "org1", uri: "test" }, + ] as PasswordHealthReportApplicationsResponse[]; + + encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName")); + criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response)); + + // act + await service.setCriticalApps("org1", selectedUrls); + + // expectations + expect(keyService.getOrgKey).toHaveBeenCalledWith("org1"); + expect(encryptService.encrypt).toHaveBeenCalledTimes(1); + expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request); + }); + + it("should get critical apps", fakeAsync(() => { + const orgId = "org1" as OrganizationId; + const response = [ + { id: "id1", organizationId: "org1", uri: "https://example.com" }, + { id: "id2", organizationId: "org1", uri: "https://example.org" }, + ] as PasswordHealthReportApplicationsResponse[]; + + encryptService.decryptToUtf8.mockResolvedValue("https://example.com"); + criticalAppsApiService.getCriticalApps.mockReturnValue(of(response)); + + const mockRandomBytes = new Uint8Array(64) as CsprngArray; + const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; + keyService.getOrgKey.mockResolvedValue(mockOrgKey); + + service.setOrganizationId(orgId as OrganizationId); + flush(); + + expect(keyService.getOrgKey).toHaveBeenCalledWith(orgId.toString()); + expect(encryptService.decryptToUtf8).toHaveBeenCalledTimes(2); + expect(criticalAppsApiService.getCriticalApps).toHaveBeenCalledWith(orgId); + })); + + it("should get by org id", () => { + const orgId = "org1" as OrganizationId; + const response = [ + { id: "id1", organizationId: "org1", uri: "https://example.com" }, + { id: "id2", organizationId: "org1", uri: "https://example.org" }, + { id: "id3", organizationId: "org2", uri: "https://example.org" }, + { id: "id4", organizationId: "org2", uri: "https://example.org" }, + ] as PasswordHealthReportApplicationsResponse[]; + + service.setAppsInListForOrg(response); + + service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => { + expect(res).toHaveLength(2); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts new file mode 100644 index 00000000000..10b7d3f1fbb --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts @@ -0,0 +1,159 @@ +import { + BehaviorSubject, + first, + firstValueFrom, + forkJoin, + from, + map, + Observable, + of, + Subject, + switchMap, + takeUntil, + zip, +} from "rxjs"; +import { Opaque } from "type-fest"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { CriticalAppsApiService } from "./critical-apps-api.service"; + +/* Retrieves and decrypts critical apps for a given organization + * Encrypts and saves data for a given organization + */ +export class CriticalAppsService { + private orgId = new BehaviorSubject(null); + private criticalAppsList = new BehaviorSubject([]); + private teardown = new Subject(); + + private fetchOrg$ = this.orgId + .pipe( + switchMap((orgId) => this.retrieveCriticalApps(orgId)), + takeUntil(this.teardown), + ) + .subscribe((apps) => this.criticalAppsList.next(apps)); + + constructor( + private keyService: KeyService, + private encryptService: EncryptService, + private criticalAppsApiService: CriticalAppsApiService, + ) {} + + // Get a list of critical apps for a given organization + getAppsListForOrg(orgId: string): Observable { + return this.criticalAppsList + .asObservable() + .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); + } + + // Reset the critical apps list + setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) { + this.criticalAppsList.next(apps); + } + + // Save the selected critical apps for a given organization + async setCriticalApps(orgId: string, selectedUrls: string[]) { + const key = await this.keyService.getOrgKey(orgId); + + // only save records that are not already in the database + const newEntries = await this.filterNewEntries(orgId as OrganizationId, selectedUrls); + const criticalAppsRequests = await this.encryptNewEntries( + orgId as OrganizationId, + key, + newEntries, + ); + + const dbResponse = await firstValueFrom( + this.criticalAppsApiService.saveCriticalApps(criticalAppsRequests), + ); + + // add the new entries to the criticalAppsList + const updatedList = [...this.criticalAppsList.value]; + for (const responseItem of dbResponse) { + const decryptedUrl = await this.encryptService.decryptToUtf8( + new EncString(responseItem.uri), + key, + ); + if (!updatedList.some((f) => f.uri === decryptedUrl)) { + updatedList.push({ + id: responseItem.id, + organizationId: responseItem.organizationId, + uri: decryptedUrl, + } as PasswordHealthReportApplicationsResponse); + } + } + this.criticalAppsList.next(updatedList); + } + + // Get the critical apps for a given organization + setOrganizationId(orgId: OrganizationId) { + this.orgId.next(orgId); + } + + private retrieveCriticalApps( + orgId: OrganizationId | null, + ): Observable { + if (orgId === null) { + return of([]); + } + + const result$ = zip( + this.criticalAppsApiService.getCriticalApps(orgId), + from(this.keyService.getOrgKey(orgId)), + ).pipe( + switchMap(([response, key]) => { + const results = response.map(async (r: PasswordHealthReportApplicationsResponse) => { + const encrypted = new EncString(r.uri); + const uri = await this.encryptService.decryptToUtf8(encrypted, key); + return { id: r.id, organizationId: r.organizationId, uri: uri }; + }); + return forkJoin(results); + }), + first(), + ); + + return result$ as Observable; + } + + private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise { + return await firstValueFrom(this.criticalAppsList).then((criticalApps) => { + const criticalAppsUri = criticalApps + .filter((f) => f.organizationId === orgId) + .map((f) => f.uri); + return selectedUrls.filter((url) => !criticalAppsUri.includes(url)); + }); + } + + private async encryptNewEntries( + orgId: OrganizationId, + key: OrgKey, + newEntries: string[], + ): Promise { + const criticalAppsPromises = newEntries.map(async (url) => { + const encryptedUrlName = await this.encryptService.encrypt(url, key); + return { + organizationId: orgId, + url: encryptedUrlName?.encryptedString?.toString() ?? "", + } as PasswordHealthReportApplicationsRequest; + }); + + return await Promise.all(criticalAppsPromises); + } +} + +export interface PasswordHealthReportApplicationsRequest { + organizationId: OrganizationId; + url: string; +} + +export interface PasswordHealthReportApplicationsResponse { + id: PasswordHealthReportApplicationId; + organizationId: OrganizationId; + uri: string; +} + +export type PasswordHealthReportApplicationId = Opaque; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts index a8e62437b9d..f547df31f41 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts @@ -1,4 +1,6 @@ export * from "./member-cipher-details-api.service"; export * from "./password-health.service"; +export * from "./critical-apps.service"; +export * from "./critical-apps-api.service"; export * from "./risk-insights-report.service"; export * from "./risk-insights-data.service"; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts index d97550b5887..c3bcc59eca5 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts @@ -13,6 +13,7 @@ import { ApplicationHealthReportDetail, ApplicationHealthReportSummary, AtRiskMemberDetail, + AtRiskApplicationDetail, CipherHealthReportDetail, CipherHealthReportUriDetail, ExposedPasswordDetail, @@ -114,6 +115,30 @@ export class RiskInsightsReportService { })); } + generateAtRiskApplicationList( + cipherHealthReportDetails: ApplicationHealthReportDetail[], + ): AtRiskApplicationDetail[] { + const appsRiskMap = new Map(); + + cipherHealthReportDetails + .filter((app) => app.atRiskPasswordCount > 0) + .forEach((app) => { + if (appsRiskMap.has(app.applicationName)) { + appsRiskMap.set( + app.applicationName, + appsRiskMap.get(app.applicationName) + app.atRiskPasswordCount, + ); + } else { + appsRiskMap.set(app.applicationName, app.atRiskPasswordCount); + } + }); + + return Array.from(appsRiskMap.entries()).map(([applicationName, atRiskPasswordCount]) => ({ + applicationName, + atRiskPasswordCount, + })); + } + /** * Gets the summary from the application health report. Returns total members and applications as well * as the total at risk members and at risk applications diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts index 2a2ae73227a..1cbe57a7082 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts @@ -16,7 +16,7 @@ import { import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrgDomainServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain.service.abstraction"; import { OrganizationDomainResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain.response"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -54,7 +54,7 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { private validationService: ValidationService, private toastService: ToastService, private configService: ConfigService, - private policyApiService: PolicyApiServiceAbstraction, + private policyService: PolicyService, ) { this.accountDeprovisioningEnabled$ = this.configService.getFeatureFlag$( FeatureFlag.AccountDeprovisioning, @@ -83,9 +83,14 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { await this.orgDomainApiService.getAllByOrgId(this.organizationId); if (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) { - const singleOrgPolicy = await this.policyApiService.getPolicy( - this.organizationId, - PolicyType.SingleOrg, + const singleOrgPolicy = await firstValueFrom( + this.policyService.policies$.pipe( + map((policies) => + policies.find( + (p) => p.type === PolicyType.SingleOrg && p.organizationId === this.organizationId, + ), + ), + ), ); this.singleOrgPolicyEnabled = singleOrgPolicy?.enabled ?? false; } diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index 0731820e413..036163af3d9 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -89,19 +89,19 @@ {{ "trustedDevices" | i18n }} - {{ "memberDecryptionOptionTdeDescriptionPartOne" | i18n }} - {{ - "memberDecryptionOptionTdeDescriptionLinkOne" | i18n - }} - {{ "memberDecryptionOptionTdeDescriptionPartTwo" | i18n }} - {{ - "memberDecryptionOptionTdeDescriptionLinkTwo" | i18n - }} - {{ "memberDecryptionOptionTdeDescriptionPartThree" | i18n }} - {{ - "memberDecryptionOptionTdeDescriptionLinkThree" | i18n - }} - {{ "memberDecryptionOptionTdeDescriptionPartFour" | i18n }} + {{ "memberDecryptionOptionTdeDescPart1" | i18n }} + + {{ "memberDecryptionOptionTdeDescLink1" | i18n }} + + {{ "memberDecryptionOptionTdeDescPart2" | i18n }} + + {{ "memberDecryptionOptionTdeDescLink2" | i18n }} + + {{ "memberDecryptionOptionTdeDescPart3" | i18n }} + + {{ "memberDecryptionOptionTdeDescLink3" | i18n }} + + {{ "memberDecryptionOptionTdeDescPart4" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts index 2db7af4bb46..5f461ff6c49 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/access-intelligence.module.ts @@ -1,14 +1,19 @@ import { NgModule } from "@angular/core"; +import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { CriticalAppsService } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { + CriticalAppsApiService, MemberCipherDetailsApiService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/tools/reports/risk-insights/services"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { KeyService } from "@bitwarden/key-management"; import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; import { RiskInsightsComponent } from "./risk-insights.component"; @@ -33,6 +38,16 @@ import { RiskInsightsComponent } from "./risk-insights.component"; provide: RiskInsightsDataService, deps: [RiskInsightsReportService], }, + safeProvider({ + provide: CriticalAppsService, + useClass: CriticalAppsService, + deps: [KeyService, EncryptService, CriticalAppsApiService], + }), + safeProvider({ + provide: CriticalAppsApiService, + useClass: CriticalAppsApiService, + deps: [ApiService], + }), ], }) export class AccessIntelligenceModule {} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html index 0493f7e44b8..bcc15fbc8fc 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.html @@ -35,10 +35,11 @@ > @@ -54,7 +55,7 @@ buttonType="secondary" bitButton *ngIf="isCriticalAppsFeatureEnabled" - [disabled]="!selectedIds.size" + [disabled]="!selectedUrls.size" [loading]="markingAsCritical" (click)="markAppsAsCritical()" > @@ -79,9 +80,11 @@ + {{ r.applicationName }} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts index 00708de282f..b22b94599f9 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/all-applications.component.ts @@ -1,15 +1,17 @@ -import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; +import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { debounceTime, map, Observable, of, Subscription } from "rxjs"; +import { combineLatest, debounceTime, map, Observable, of, skipWhile } from "rxjs"; import { + CriticalAppsService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { ApplicationHealthReportDetail, + ApplicationHealthReportDetailWithCriticalFlag, ApplicationHealthReportSummary, } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -32,6 +34,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; import { openAppAtRiskMembersDialog } from "./app-at-risk-members-dialog.component"; +import { OrgAtRiskAppsDialogComponent } from "./org-at-risk-apps-dialog.component"; import { OrgAtRiskMembersDialogComponent } from "./org-at-risk-members-dialog.component"; import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; @@ -49,16 +52,15 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component" SharedModule, ], }) -export class AllApplicationsComponent implements OnInit, OnDestroy { - protected dataSource = new TableDataSource(); - protected selectedIds: Set = new Set(); +export class AllApplicationsComponent implements OnInit { + protected dataSource = new TableDataSource(); + protected selectedUrls: Set = new Set(); protected searchControl = new FormControl("", { nonNullable: true }); protected loading = true; protected organization = {} as Organization; noItemsIcon = Icons.Security; protected markingAsCritical = false; protected applicationSummary = {} as ApplicationHealthReportSummary; - private subscription = new Subscription(); destroyRef = inject(DestroyRef); isLoading$: Observable = of(false); @@ -69,28 +71,33 @@ export class AllApplicationsComponent implements OnInit, OnDestroy { FeatureFlag.CriticalApps, ); - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? ""; + combineLatest([ + this.dataService.applications$, + this.criticalAppsService.getAppsListForOrg(organizationId), + this.organizationService.get$(organizationId), + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + skipWhile(([_, __, organization]) => !organization), + map(([applications, criticalApps, organization]) => { + const criticalUrls = criticalApps.map((ca) => ca.uri); + const data = applications?.map((app) => ({ + ...app, + isMarkedAsCritical: criticalUrls.includes(app.applicationName), + })) as ApplicationHealthReportDetailWithCriticalFlag[]; + return { data, organization }; + }), + ) + .subscribe(({ data, organization }) => { + this.dataSource.data = data ?? []; + this.applicationSummary = this.reportService.generateApplicationsSummary(data ?? []); + if (organization) { + this.organization = organization; + } + }); - if (organizationId) { - this.organization = await this.organizationService.get(organizationId); - this.subscription = this.dataService.applications$ - .pipe( - map((applications) => { - if (applications) { - this.dataSource.data = applications; - this.applicationSummary = - this.reportService.generateApplicationsSummary(applications); - } - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(); - this.isLoading$ = this.dataService.isLoading$; - } - } - - ngOnDestroy(): void { - this.subscription?.unsubscribe(); + this.isLoading$ = this.dataService.isLoading$; } constructor( @@ -102,6 +109,7 @@ export class AllApplicationsComponent implements OnInit, OnDestroy { protected dataService: RiskInsightsDataService, protected organizationService: OrganizationService, protected reportService: RiskInsightsReportService, + protected criticalAppsService: CriticalAppsService, protected dialogService: DialogService, ) { this.searchControl.valueChanges @@ -118,21 +126,28 @@ export class AllApplicationsComponent implements OnInit, OnDestroy { }); }; + isMarkedAsCriticalItem(applicationName: string) { + return this.selectedUrls.has(applicationName); + } + markAppsAsCritical = async () => { - // TODO: Send to API once implemented this.markingAsCritical = true; - return new Promise((resolve) => { - setTimeout(() => { - this.selectedIds.clear(); - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("appsMarkedAsCritical"), - }); - resolve(true); - this.markingAsCritical = false; - }, 1000); - }); + + try { + await this.criticalAppsService.setCriticalApps( + this.organization.id, + Array.from(this.selectedUrls), + ); + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("appsMarkedAsCritical"), + }); + } finally { + this.selectedUrls.clear(); + this.markingAsCritical = false; + } }; trackByFunction(_: number, item: ApplicationHealthReportDetail) { @@ -154,12 +169,20 @@ export class AllApplicationsComponent implements OnInit, OnDestroy { }); }; - onCheckboxChange(id: number, event: Event) { + showOrgAtRiskApps = async () => { + this.dialogService.open(OrgAtRiskAppsDialogComponent, { + data: this.reportService.generateAtRiskApplicationList(this.dataSource.data), + }); + }; + + onCheckboxChange(applicationName: string, event: Event) { const isChecked = (event.target as HTMLInputElement).checked; if (isChecked) { - this.selectedIds.add(id); + this.selectedUrls.add(applicationName); } else { - this.selectedIds.delete(id); + this.selectedUrls.delete(applicationName); } } + + getSelectedUrls = () => Array.from(this.selectedUrls); } diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/application-table.mock.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/application-table.mock.ts index 4df363ab2c7..4dffa60b562 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/application-table.mock.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/application-table.mock.ts @@ -6,6 +6,7 @@ export const applicationTableMockData = [ totalPasswords: 10, atRiskMembers: 2, totalMembers: 5, + isMarkedAsCritical: false, }, { id: 2, @@ -14,6 +15,7 @@ export const applicationTableMockData = [ totalPasswords: 8, atRiskMembers: 1, totalMembers: 3, + isMarkedAsCritical: false, }, { id: 3, @@ -22,6 +24,7 @@ export const applicationTableMockData = [ totalPasswords: 6, atRiskMembers: 0, totalMembers: 2, + isMarkedAsCritical: false, }, { id: 4, @@ -30,6 +33,7 @@ export const applicationTableMockData = [ totalPasswords: 4, atRiskMembers: 0, totalMembers: 1, + isMarkedAsCritical: false, }, { id: 5, @@ -38,6 +42,7 @@ export const applicationTableMockData = [ totalPasswords: 2, atRiskMembers: 0, totalMembers: 0, + isMarkedAsCritical: false, }, { id: 6, @@ -46,5 +51,6 @@ export const applicationTableMockData = [ totalPasswords: 1, atRiskMembers: 0, totalMembers: 0, + isMarkedAsCritical: false, }, ]; diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-apps-dialog.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-apps-dialog.component.html new file mode 100644 index 00000000000..298011b2157 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-apps-dialog.component.html @@ -0,0 +1,25 @@ + + + {{ "atRiskApplicationsWithCount" | i18n: atRiskApps.length }} + + +
+ {{ "atRiskApplicationsDescription" | i18n }} +
+
{{ "application" | i18n }}
+
{{ "atRiskPasswords" | i18n }}
+
+ +
+
{{ app.applicationName }}
+
{{ app.atRiskPasswordCount }}
+
+
+
+
+ + + +
diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-apps-dialog.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-apps-dialog.component.ts new file mode 100644 index 00000000000..0ae00f60874 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-apps-dialog.component.ts @@ -0,0 +1,24 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AtRiskApplicationDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; +import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components"; + +export const openOrgAtRiskMembersDialog = ( + dialogService: DialogService, + dialogConfig: AtRiskApplicationDetail[], +) => + dialogService.open(OrgAtRiskAppsDialogComponent, { + data: dialogConfig, + }); + +@Component({ + standalone: true, + templateUrl: "./org-at-risk-apps-dialog.component.html", + imports: [ButtonModule, CommonModule, DialogModule, JslibModule, TypographyModule], +}) +export class OrgAtRiskAppsDialogComponent { + constructor(@Inject(DIALOG_DATA) protected atRiskApps: AtRiskApplicationDetail[]) {} +} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-members-dialog.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-members-dialog.component.html index 41ac8af7886..1f1de103661 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-members-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/org-at-risk-members-dialog.component.html @@ -4,9 +4,7 @@
- {{ - "atRiskMembersDescription" | i18n - }} + {{ "atRiskMembersDescription" | i18n }}
{{ "email" | i18n }}
{{ "atRiskPasswords" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html index 7fe320ede6a..ae8bd94e5f3 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html @@ -40,7 +40,7 @@ - {{ "criticalApplicationsWithCount" | i18n: criticalAppsCount }} + {{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }} diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts index 75601994c70..5adb0d32945 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts @@ -6,11 +6,17 @@ import { Observable, EMPTY } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { RiskInsightsDataService } from "@bitwarden/bit-common/tools/reports/risk-insights"; +import { + RiskInsightsDataService, + CriticalAppsService, + PasswordHealthReportApplicationsResponse, +} from "@bitwarden/bit-common/tools/reports/risk-insights"; import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; +// eslint-disable-next-line no-restricted-imports -- used for dependency injection import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; @@ -51,6 +57,7 @@ export class RiskInsightsComponent implements OnInit { dataLastUpdated: Date = new Date(); isCriticalAppsFeatureEnabled: boolean = false; + criticalApps$: Observable = new Observable(); showDebugTabs: boolean = false; appsCount: number = 0; @@ -69,10 +76,13 @@ export class RiskInsightsComponent implements OnInit { private router: Router, private configService: ConfigService, private dataService: RiskInsightsDataService, + private criticalAppsService: CriticalAppsService, ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; }); + const orgId = this.route.snapshot.paramMap.get("organizationId") ?? ""; + this.criticalApps$ = this.criticalAppsService.getAppsListForOrg(orgId); } async ngOnInit() { @@ -104,6 +114,7 @@ export class RiskInsightsComponent implements OnInit { if (applications) { this.appsCount = applications.length; } + this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId); }, }); } diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts index 4aa54429aad..048a4733948 100644 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts @@ -91,7 +91,7 @@ describe("DefaultvNextCollectionService", () => { // Assert emitted values expect(result.length).toBe(2); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: collection1.id, name: "DEC_NAME_" + collection1.id, @@ -167,7 +167,7 @@ describe("DefaultvNextCollectionService", () => { const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(2); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: collection1.id, name: makeEncString("ENC_NAME_" + collection1.id), @@ -205,7 +205,7 @@ describe("DefaultvNextCollectionService", () => { const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(3); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: collection1.id, name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), @@ -230,7 +230,7 @@ describe("DefaultvNextCollectionService", () => { const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(1); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: collection1.id, name: makeEncString("ENC_NAME_" + collection1.id), @@ -253,7 +253,7 @@ describe("DefaultvNextCollectionService", () => { const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(1); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: newCollection3.id, name: makeEncString("ENC_NAME_" + newCollection3.id), diff --git a/libs/admin-console/test.setup.ts b/libs/admin-console/test.setup.ts index 6be6e7b8dd1..8ab102f2cf4 100644 --- a/libs/admin-console/test.setup.ts +++ b/libs/admin-console/test.setup.ts @@ -1,6 +1,10 @@ import { webcrypto } from "crypto"; + +import { addCustomMatchers } from "@bitwarden/common/spec"; import "jest-preset-angular/setup-jest"; +addCustomMatchers(); + Object.defineProperty(window, "CSS", { value: null }); Object.defineProperty(window, "getComputedStyle", { value: () => { diff --git a/libs/admin-console/tsconfig.json b/libs/admin-console/tsconfig.json index 3d22cb2ec51..4f057fd6af0 100644 --- a/libs/admin-console/tsconfig.json +++ b/libs/admin-console/tsconfig.json @@ -8,6 +8,6 @@ "@bitwarden/key-management": ["../key-management/src"] } }, - "include": ["src", "spec"], + "include": ["src", "spec", "../../libs/common/custom-matchers.d.ts"], "exclude": ["node_modules", "dist"] } diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts index 8f22f391b13..a3f5e062e4f 100644 --- a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -107,9 +107,7 @@ export class LoginDecryptionOptionsComponent implements OnInit { private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private validationService: ValidationService, ) { - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.clientType === this.platformUtilsService.getClientType(); + this.clientType = this.platformUtilsService.getClientType(); } async ngOnInit() { diff --git a/libs/common/spec/matchers/index.ts b/libs/common/spec/matchers/index.ts index 44440be5b54..b2e09cc8e92 100644 --- a/libs/common/spec/matchers/index.ts +++ b/libs/common/spec/matchers/index.ts @@ -1,16 +1,12 @@ -import * as matchers from "jest-extended"; - import { toBeFulfilled, toBeResolved, toBeRejected } from "./promise-fulfilled"; import { toAlmostEqual } from "./to-almost-equal"; +import { toContainPartialObjects } from "./to-contain-partial-objects"; import { toEqualBuffer } from "./to-equal-buffer"; export * from "./to-equal-buffer"; export * from "./to-almost-equal"; export * from "./promise-fulfilled"; -// add all jest-extended matchers -expect.extend(matchers); - export function addCustomMatchers() { expect.extend({ toEqualBuffer: toEqualBuffer, @@ -18,6 +14,7 @@ export function addCustomMatchers() { toBeFulfilled: toBeFulfilled, toBeResolved: toBeResolved, toBeRejected: toBeRejected, + toContainPartialObjects, }); } @@ -59,4 +56,9 @@ export interface CustomMatchers { * @returns CustomMatcherResult indicating whether or not the test passed */ toBeRejected(withinMs?: number): Promise; + /** + * Matches if the received array contains all the expected objects using partial matching (expect.objectContaining). + * @param expected An array of partial objects that should be contained in the received array. + */ + toContainPartialObjects(expected: Array): R; } diff --git a/libs/common/spec/matchers/to-contain-partial-objects.spec.ts b/libs/common/spec/matchers/to-contain-partial-objects.spec.ts new file mode 100644 index 00000000000..ab6f90adf17 --- /dev/null +++ b/libs/common/spec/matchers/to-contain-partial-objects.spec.ts @@ -0,0 +1,77 @@ +describe("toContainPartialObjects", () => { + describe("matches", () => { + it("if the array only contains the partial objects", () => { + const actual = [ + { + id: 1, + name: "foo", + }, + { + id: 2, + name: "bar", + }, + ]; + + const expected = [{ id: 1 }, { id: 2 }]; + + expect(actual).toContainPartialObjects(expected); + }); + + it("if the array contains the partial objects and other objects", () => { + const actual = [ + { + id: 1, + name: "foo", + }, + { + id: 2, + name: "bar", + }, + { + id: 3, + name: "baz", + }, + ]; + + const expected = [{ id: 1 }, { id: 2 }]; + + expect(actual).toContainPartialObjects(expected); + }); + }); + + describe("doesn't match", () => { + it("if the array does not contain any partial objects", () => { + const actual = [ + { + id: 1, + name: "foo", + }, + { + id: 2, + name: "bar", + }, + ]; + + const expected = [{ id: 1, name: "Foo" }]; + + expect(actual).not.toContainPartialObjects(expected); + }); + + it("if the array contains some but not all partial objects", () => { + const actual = [ + { + id: 1, + name: "foo", + }, + { + id: 2, + name: "bar", + }, + ]; + + const expected = [{ id: 2 }, { id: 3 }]; + + expect(actual).not.toContainPartialObjects(expected); + }); + }); +}); diff --git a/libs/common/spec/matchers/to-contain-partial-objects.ts b/libs/common/spec/matchers/to-contain-partial-objects.ts new file mode 100644 index 00000000000..f072ca6fba6 --- /dev/null +++ b/libs/common/spec/matchers/to-contain-partial-objects.ts @@ -0,0 +1,31 @@ +import { EOL } from "os"; + +import { diff } from "jest-diff"; + +export const toContainPartialObjects: jest.CustomMatcher = function ( + received: Array, + expected: Array, +) { + const matched = this.equals( + received, + expect.arrayContaining(expected.map((e) => expect.objectContaining(e))), + ); + + if (matched) { + return { + message: () => + "Expected the received array NOT to include partial matches for all expected objects." + + EOL + + diff(expected, received), + pass: true, + }; + } + + return { + message: () => + "Expected the received array to contain partial matches for all expected objects." + + EOL + + diff(expected, received), + pass: false, + }; +}; diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 2161feb516e..da81f340fda 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -17,7 +17,7 @@ export function canAccessSettingsTab(org: Organization): boolean { org.canManageSso || org.canManageScim || org.canAccessImport || - org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway + org.canAccessExport || org.canManageDeviceApprovals ); } diff --git a/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts b/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts index b5c0f6291fc..c25a153a068 100644 --- a/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts +++ b/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts @@ -17,7 +17,7 @@ export function canAccessSettingsTab(org: Organization): boolean { org.canManageSso || org.canManageScim || org.canAccessImport || - org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway + org.canAccessExport || org.canManageDeviceApprovals ); } diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 8441298bbff..9dcc9f0752c 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -182,11 +182,7 @@ export class Organization { ); } - canAccessExport(removeProviderExport: boolean) { - if (!removeProviderExport && this.isProviderUser) { - return true; - } - + get canAccessExport() { return ( this.isMember && (this.type === OrganizationUserType.Owner || diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index feffe2ca442..d008a09d66c 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,6 +13,7 @@ export enum FeatureFlag { InlineMenuPositioningImprovements = "inline-menu-positioning-improvements", InlineMenuTotp = "inline-menu-totp", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", + NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", BrowserFilelessImport = "browser-fileless-import", @@ -43,7 +44,6 @@ export enum FeatureFlag { NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss", DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship", MacOsNativeCredentialSync = "macos-native-credential-sync", - PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission", PM12443RemovePagingLogic = "pm-12443-remove-paging-logic", PrivateKeyRegeneration = "pm-12241-private-key-regeneration", ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs", @@ -70,6 +70,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.InlineMenuPositioningImprovements]: FALSE, [FeatureFlag.InlineMenuTotp]: FALSE, [FeatureFlag.NotificationBarAddLoginImprovements]: FALSE, + [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, [FeatureFlag.BrowserFilelessImport]: FALSE, @@ -100,7 +101,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE, [FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, - [FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE, [FeatureFlag.PM12443RemovePagingLogic]: FALSE, [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.ResellerManagedOrgAlert]: FALSE, diff --git a/libs/common/src/platform/state/implementations/default-derived-state.provider.ts b/libs/common/src/platform/state/implementations/default-derived-state.provider.ts index 3c8c39e21e8..61f36fa0b75 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-derived-state.provider.ts @@ -8,7 +8,14 @@ import { DerivedStateProvider } from "../derived-state.provider"; import { DefaultDerivedState } from "./default-derived-state"; export class DefaultDerivedStateProvider implements DerivedStateProvider { - private cache: Record> = {}; + /** + * The cache uses a WeakMap to maintain separate derived states per user. + * Each user's state Observable acts as a unique key, without needing to + * pass around `userId`. Also, when a user's state Observable is cleaned up + * (like during an account swap) their cache is automatically garbage + * collected. + */ + private cache = new WeakMap, Record>>(); constructor() {} @@ -17,8 +24,14 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider { deriveDefinition: DeriveDefinition, dependencies: TDeps, ): DerivedState { + let stateCache = this.cache.get(parentState$); + if (!stateCache) { + stateCache = {}; + this.cache.set(parentState$, stateCache); + } + const cacheKey = deriveDefinition.buildCacheKey(); - const existingDerivedState = this.cache[cacheKey]; + const existingDerivedState = stateCache[cacheKey]; if (existingDerivedState != null) { // I have to cast out of the unknown generic but this should be safe if rules // around domain token are made @@ -26,7 +39,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider { } const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies); - this.cache[cacheKey] = newDerivedState; + stateCache[cacheKey] = newDerivedState; return newDerivedState; } diff --git a/libs/common/src/platform/state/implementations/default-derived-state.spec.ts b/libs/common/src/platform/state/implementations/default-derived-state.spec.ts index 7e8d76bd203..6fcc1c408cb 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-derived-state.spec.ts @@ -9,6 +9,7 @@ import { DeriveDefinition } from "../derive-definition"; import { StateDefinition } from "../state-definition"; import { DefaultDerivedState } from "./default-derived-state"; +import { DefaultDerivedStateProvider } from "./default-derived-state.provider"; let callCount = 0; const cleanupDelayMs = 10; @@ -182,4 +183,29 @@ describe("DefaultDerivedState", () => { expect(await firstValueFrom(observable)).toEqual(new Date(newDate)); }); }); + + describe("account switching", () => { + let provider: DefaultDerivedStateProvider; + + beforeEach(() => { + provider = new DefaultDerivedStateProvider(); + }); + + it("should provide a dedicated cache for each account", async () => { + const user1State$ = new Subject(); + const user1Derived = provider.get(user1State$, deriveDefinition, deps); + const user1Emissions = trackEmissions(user1Derived.state$); + + const user2State$ = new Subject(); + const user2Derived = provider.get(user2State$, deriveDefinition, deps); + const user2Emissions = trackEmissions(user2Derived.state$); + + user1State$.next("2015-12-30"); + user2State$.next("2020-12-29"); + await awaitAsync(); + + expect(user1Emissions).toEqual([new Date("2015-12-30")]); + expect(user2Emissions).toEqual([new Date("2020-12-29")]); + }); + }); }); diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 1ed5227cb13..483a8c050d3 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -179,7 +179,6 @@ export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerRepro web: "disk-local", }); export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk"); -export const VAULT_BROWSER_UI_ONBOARDING = new StateDefinition("vaultBrowserUiOnboarding", "disk"); export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition( "newDeviceVerificationNotice", "disk", diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts index 831cad74155..0b60aef4917 100644 --- a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts @@ -184,7 +184,7 @@ describe("KeyServiceLegacyEncryptorProvider", () => { singleUserId$.complete(); - expect(completed).toBeTrue(); + expect(completed).toBe(true); }); it("completes when `userKey$` emits a falsy value after emitting a truthy value", () => { @@ -199,7 +199,7 @@ describe("KeyServiceLegacyEncryptorProvider", () => { userKey$.next(null); - expect(completed).toBeTrue(); + expect(completed).toBe(true); }); it("completes once `dependencies.singleUserId$` emits and `userKey$` completes", () => { @@ -214,7 +214,7 @@ describe("KeyServiceLegacyEncryptorProvider", () => { userKey$.complete(); - expect(completed).toBeTrue(); + expect(completed).toBe(true); }); }); @@ -445,7 +445,7 @@ describe("KeyServiceLegacyEncryptorProvider", () => { singleOrganizationId$.complete(); - expect(completed).toBeTrue(); + expect(completed).toBe(true); }); it("completes when `orgKeys$` emits a falsy value after emitting a truthy value", () => { @@ -466,7 +466,7 @@ describe("KeyServiceLegacyEncryptorProvider", () => { orgKey$.next(OrgRecords); orgKey$.next(null); - expect(completed).toBeTrue(); + expect(completed).toBe(true); }); it("completes once `dependencies.singleOrganizationId$` emits and `userKey$` completes", () => { @@ -486,7 +486,7 @@ describe("KeyServiceLegacyEncryptorProvider", () => { orgKey$.complete(); - expect(completed).toBeTrue(); + expect(completed).toBe(true); }); }); }); diff --git a/libs/common/src/tools/extension/data.ts b/libs/common/src/tools/extension/data.ts new file mode 100644 index 00000000000..cab6272a068 --- /dev/null +++ b/libs/common/src/tools/extension/data.ts @@ -0,0 +1,26 @@ +/** well-known name for a feature extensible through an extension. */ +export const Site = Object.freeze({ + forwarder: "forwarder", +} as const); + +/** well-known name for a field surfaced from an extension site to a vendor. */ +export const Field = Object.freeze({ + token: "token", + baseUrl: "baseUrl", + domain: "domain", + prefix: "prefix", +} as const); + +/** Permission levels for metadata. */ +export const Permission = Object.freeze({ + /** unless a rule denies access, allow it. If a permission is `null` + * or `undefined` it should be treated as `Permission.default`. + */ + default: "default", + /** unless a rule allows access, deny it. */ + none: "none", + /** access is explicitly granted to use an extension. */ + allow: "allow", + /** access is explicitly prohibited for this extension. This rule overrides allow rules. */ + deny: "deny", +} as const); diff --git a/libs/common/src/tools/extension/extension-registry.abstraction.ts b/libs/common/src/tools/extension/extension-registry.abstraction.ts new file mode 100644 index 00000000000..7734c01ea50 --- /dev/null +++ b/libs/common/src/tools/extension/extension-registry.abstraction.ts @@ -0,0 +1,104 @@ +import { ExtensionSite } from "./extension-site"; +import { + ExtensionMetadata, + ExtensionSet, + ExtensionPermission, + SiteId, + SiteMetadata, + VendorId, + VendorMetadata, +} from "./type"; + +/** Tracks extension sites and the vendors that extend them. */ +export abstract class ExtensionRegistry { + /** Registers a site supporting extensibility. + * Each site may only be registered once. Calls after the first for + * the same SiteId have no effect. + * @param site identifies the site being extended + * @param meta configures the extension site + * @return self for method chaining. + * @remarks The registry initializes with a set of allowed sites and fields. + * `registerSite` drops a registration and trims its allowed fields to only + * those indicated in the allow list. + */ + abstract registerSite: (meta: SiteMetadata) => this; + + /** List all registered extension sites with their extension permission, if any. + * @returns a list of all extension sites. `permission` is defined when the site + * is associated with an extension permission. + */ + abstract sites: () => { site: SiteMetadata; permission?: ExtensionPermission }[]; + + /** Get a site's metadata + * @param site identifies a site registration + * @return the site's metadata or `undefined` if the site isn't registered. + */ + abstract site: (site: SiteId) => SiteMetadata | undefined; + + /** Registers a vendor providing an extension. + * Each vendor may only be registered once. Calls after the first for + * the same VendorId have no effect. + * @param site - identifies the site being extended + * @param meta - configures the extension site + * @return self for method chaining. + */ + abstract registerVendor: (meta: VendorMetadata) => this; + + /** List all registered vendors with their permissions, if any. + * @returns a list of all extension sites. `permission` is defined when the site + * is associated with an extension permission. + */ + abstract vendors: () => { vendor: VendorMetadata; permission?: ExtensionPermission }[]; + + /** Get a vendor's metadata + * @param site identifies a vendor registration + * @return the vendor's metadata or `undefined` if the vendor isn't registered. + */ + abstract vendor: (vendor: VendorId) => VendorMetadata | undefined; + + /** Registers an extension provided by a vendor to an extension site. + * The vendor and site MUST be registered before the extension. + * Each extension may only be registered once. Calls after the first for + * the same SiteId and VendorId have no effect. + * @param site - identifies the site being extended + * @param meta - configures the extension site + * @return self for method chaining. + */ + abstract registerExtension: (meta: ExtensionMetadata) => this; + + /** Get an extensions metadata + * @param site identifies the extension's site + * @param vendor identifies the extension's vendor + * @return the extension's metadata or `undefined` if the extension isn't registered. + */ + abstract extension: (site: SiteId, vendor: VendorId) => ExtensionMetadata | undefined; + + /** List all registered extensions and their permissions */ + abstract extensions: () => ReadonlyArray<{ + extension: ExtensionMetadata; + permissions: ExtensionPermission[]; + }>; + + /** Registers a permission. Only 1 permission can be registered for each extension set. + * Calls after the first *replace* the registered permission. + * @param set the collection of extensions affected by the permission + * @param permission the permission for the collection + * @return self for method chaining. + */ + abstract setPermission: (set: ExtensionSet, permission: ExtensionPermission) => this; + + /** Retrieves the current permission for the given extension set or `undefined` if + * a permission doesn't exist. + */ + abstract permission: (set: ExtensionSet) => ExtensionPermission | undefined; + + /** Returns all registered extension rules. */ + abstract permissions: () => { set: ExtensionSet; permission: ExtensionPermission }[]; + + /** Creates a point-in-time snapshot of the registry's contents with extension + * permissions applied for the provided SiteId. + * @param id identifies the extension site to create. + * @returns the extension site, or `undefined` if the site is not registered. + */ + abstract build: (id: SiteId) => ExtensionSite | undefined; +} diff --git a/libs/common/src/tools/extension/extension-site.ts b/libs/common/src/tools/extension/extension-site.ts new file mode 100644 index 00000000000..e8aba008493 --- /dev/null +++ b/libs/common/src/tools/extension/extension-site.ts @@ -0,0 +1,20 @@ +import { deepFreeze } from "../util"; + +import { ExtensionMetadata, SiteMetadata, VendorId } from "./type"; + +/** Describes the capabilities of an extension site. + * This type is immutable. + */ +export class ExtensionSite { + /** instantiate the extension site + * @param site describes the extension site + * @param vendors describes the available vendors + * @param extensions describes the available extensions + */ + constructor( + readonly site: Readonly, + readonly extensions: ReadonlyMap>, + ) { + deepFreeze(this); + } +} diff --git a/libs/common/src/tools/extension/factory.ts b/libs/common/src/tools/extension/factory.ts new file mode 100644 index 00000000000..10ebc77804a --- /dev/null +++ b/libs/common/src/tools/extension/factory.ts @@ -0,0 +1,24 @@ +import { DefaultFields, DefaultSites, Extension } from "./metadata"; +import { RuntimeExtensionRegistry } from "./runtime-extension-registry"; +import { VendorExtensions, Vendors } from "./vendor"; + +// FIXME: find a better way to build the registry than a hard-coded factory function + +/** Constructs the extension registry */ +export function buildExtensionRegistry() { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + for (const site of Reflect.ownKeys(Extension) as string[]) { + registry.registerSite(Extension[site]); + } + + for (const vendor of Vendors) { + registry.registerVendor(vendor); + } + + for (const extension of VendorExtensions) { + registry.registerExtension(extension); + } + + return registry; +} diff --git a/libs/common/src/tools/extension/index.ts b/libs/common/src/tools/extension/index.ts new file mode 100644 index 00000000000..e786dde4f59 --- /dev/null +++ b/libs/common/src/tools/extension/index.ts @@ -0,0 +1,12 @@ +export { Site, Field, Permission } from "./data"; +export { + SiteId, + FieldId, + VendorId, + ExtensionId, + ExtensionPermission, + SiteMetadata, + ExtensionMetadata, + VendorMetadata, +} from "./type"; +export { ExtensionSite } from "./extension-site"; diff --git a/libs/common/src/tools/extension/metadata.ts b/libs/common/src/tools/extension/metadata.ts new file mode 100644 index 00000000000..895b1d1b31f --- /dev/null +++ b/libs/common/src/tools/extension/metadata.ts @@ -0,0 +1,17 @@ +import { Field, Site, Permission } from "./data"; +import { FieldId, SiteId, SiteMetadata } from "./type"; + +export const DefaultSites: SiteId[] = Object.freeze(Object.keys(Site) as any); + +export const DefaultFields: FieldId[] = Object.freeze(Object.keys(Field) as any); + +export const Extension: Record = { + [Site.forwarder]: { + id: Site.forwarder, + availableFields: [Field.baseUrl, Field.domain, Field.prefix, Field.token], + }, +}; + +export const AllowedPermissions: ReadonlyArray = Object.freeze( + Object.values(Permission), +); diff --git a/libs/common/src/tools/extension/runtime-extension-registry.spec.ts b/libs/common/src/tools/extension/runtime-extension-registry.spec.ts new file mode 100644 index 00000000000..6aa7382db57 --- /dev/null +++ b/libs/common/src/tools/extension/runtime-extension-registry.spec.ts @@ -0,0 +1,923 @@ +import { deepFreeze } from "../util"; + +import { Field, Site, Permission } from "./data"; +import { ExtensionSite } from "./extension-site"; +import { DefaultFields, DefaultSites } from "./metadata"; +import { RuntimeExtensionRegistry } from "./runtime-extension-registry"; +import { ExtensionMetadata, SiteId, SiteMetadata, VendorMetadata } from "./type"; +import { Bitwarden } from "./vendor/bitwarden"; + +// arbitrary test entities +const SomeSiteId: SiteId = Site.forwarder; + +const SomeSite: SiteMetadata = Object.freeze({ + id: SomeSiteId, + availableFields: [], +}); + +const SomeVendor = Bitwarden; +const SomeVendorId = SomeVendor.id; +const SomeExtension: ExtensionMetadata = deepFreeze({ + site: SomeSite, + product: { vendor: SomeVendor, name: "Some Product" }, + host: { authorization: "bearer", selfHost: "maybe", baseUrl: "https://vault.bitwarden.com" }, + requestedFields: [], +}); + +const JustTrustUs: VendorMetadata = Object.freeze({ + id: "justrustus" as any, + name: "JustTrust.Us", +}); +const JustTrustUsExtension: ExtensionMetadata = deepFreeze({ + site: SomeSite, + product: { vendor: JustTrustUs }, + host: { authorization: "bearer", selfHost: "maybe", baseUrl: "https://justrust.us" }, + requestedFields: [], +}); + +// In the following tests, not-null assertions (`!`) indicate that +// the returned object should never be null or undefined given +// the conditions defined within the test case +describe("RuntimeExtensionRegistry", () => { + describe("registerSite", () => { + it("registers an extension site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + + const result = registry.registerSite(SomeSite).site(SomeSiteId); + + expect(result).toEqual(SomeSite); + }); + + it("interns the site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + + const result = registry.registerSite(SomeSite).site(SomeSiteId); + + expect(result).not.toBe(SomeSite); + }); + + it("registers an extension site with fields", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + const site: SiteMetadata = { + ...SomeSite, + availableFields: [Field.baseUrl], + }; + + const result = registry.registerSite(site).site(SomeSiteId); + + expect(result).toEqual(site); + }); + + it("ignores unavailable sites", () => { + const registry = new RuntimeExtensionRegistry([], []); + const ignored: SiteMetadata = { + id: "an-unavailable-site" as any, + availableFields: [], + }; + + const result = registry.registerSite(ignored).sites(); + + expect(result).toEqual([]); + }); + + it("ignores duplicate registrations", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + const ignored: SiteMetadata = { + ...SomeSite, + availableFields: [Field.token], + }; + + const result = registry.registerSite(SomeSite).registerSite(ignored).site(SomeSiteId); + + expect(result).toEqual(SomeSite); + }); + + it("ignores unknown available fields", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + const ignored: SiteMetadata = { + ...SomeSite, + availableFields: [SomeSite.availableFields, "ignored" as any], + }; + + const { availableFields } = registry.registerSite(ignored).site(SomeSiteId)!; + + expect(availableFields).toEqual(SomeSite.availableFields); + }); + + it("freezes the site definition", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + const site = registry.registerSite(SomeSite).site(SomeSiteId)!; + + // reassigning `availableFields` throws b/c the object is frozen + expect(() => (site.availableFields = [Field.domain])).toThrow(); + }); + }); + + describe("site", () => { + it("returns `undefined` for an unknown site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + const result = registry.site(SomeSiteId); + + expect(result).toBeUndefined(); + }); + + it("returns the same result when called repeatedly", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry.registerSite(SomeSite); + + const first = registry.site(SomeSiteId); + const second = registry.site(SomeSiteId); + + expect(first).toBe(second); + }); + }); + + describe("sites", () => { + it("lists registered sites", () => { + const registry = new RuntimeExtensionRegistry([SomeSiteId, "bar"] as any[], DefaultFields); + const barSite: SiteMetadata = { + id: "bar" as any, + availableFields: [], + }; + + const result = registry.registerSite(SomeSite).registerSite(barSite).sites(); + + expect(result.some(({ site }) => site.id === SomeSiteId)).toBe(true); + expect(result.some(({ site }) => site.id === barSite.id)).toBe(true); + }); + + it("includes permissions for a site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + const result = registry + .registerSite(SomeSite) + .setPermission({ site: SomeSite.id }, Permission.allow) + .sites(); + + expect(result).toEqual([{ site: SomeSite, permission: Permission.allow }]); + }); + + it("ignores duplicate registrations", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + const ignored: SiteMetadata = { + ...SomeSite, + availableFields: [Field.token], + }; + + const result = registry.registerSite(SomeSite).registerSite(ignored).sites(); + + expect(result).toEqual([{ site: SomeSite }]); + }); + + it("ignores permissions for other sites", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + const result = registry + .registerSite(SomeSite) + .setPermission({ site: SomeSite.id }, Permission.allow) + .setPermission({ site: "bar" as any }, Permission.deny) + .sites(); + + expect(result).toEqual([{ site: SomeSite, permission: Permission.allow }]); + }); + }); + + describe("registerVendor", () => { + it("registers a vendor", () => { + const registry = new RuntimeExtensionRegistry([], []); + const result = registry.registerVendor(SomeVendor).vendors(); + + expect(result).toEqual([{ vendor: SomeVendor }]); + }); + + it("freezes the vendor definition", () => { + const registry = new RuntimeExtensionRegistry([], []); + // copy `SomeVendor` because it is already frozen + const original: VendorMetadata = { ...SomeVendor }; + + const [{ vendor }] = registry.registerVendor(original).vendors(); + + // reassigning `name` throws b/c the object is frozen + expect(() => (vendor.name = "Bytewarden")).toThrow(); + }); + }); + + describe("vendor", () => { + it("returns `undefined` for an unknown site", () => { + const registry = new RuntimeExtensionRegistry([], []); + + const result = registry.vendor(SomeVendorId); + + expect(result).toBeUndefined(); + }); + + it("returns the same result when called repeatedly", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry.registerVendor(SomeVendor); + + const first = registry.vendor(SomeVendorId); + const second = registry.vendor(SomeVendorId); + + expect(first).toBe(second); + }); + }); + + describe("vendors", () => { + it("lists registered vendors", () => { + const registry = new RuntimeExtensionRegistry([], []); + registry.registerVendor(SomeVendor).registerVendor(JustTrustUs); + + const result = registry.vendors(); + + expect(result.some(({ vendor }) => vendor.id === SomeVendorId)).toBe(true); + expect(result.some(({ vendor }) => vendor.id === JustTrustUs.id)).toBe(true); + }); + + it("includes permissions for a vendor", () => { + const registry = new RuntimeExtensionRegistry([], []); + + const result = registry + .registerVendor(SomeVendor) + .setPermission({ vendor: SomeVendorId }, Permission.allow) + .vendors(); + + expect(result).toEqual([{ vendor: SomeVendor, permission: Permission.allow }]); + }); + + it("ignores duplicate registrations", () => { + const registry = new RuntimeExtensionRegistry([], []); + const vendor: VendorMetadata = SomeVendor; + const ignored: VendorMetadata = { + ...SomeVendor, + name: "Duplicate", + }; + + const result = registry.registerVendor(vendor).registerVendor(ignored).vendors(); + + expect(result).toEqual([{ vendor }]); + }); + + it("ignores permissions for other sites", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry.registerVendor(SomeVendor).setPermission({ vendor: SomeVendorId }, Permission.allow); + + const result = registry.setPermission({ vendor: JustTrustUs.id }, Permission.deny).vendors(); + + expect(result).toEqual([{ vendor: SomeVendor, permission: Permission.allow }]); + }); + }); + + describe("setPermission", () => { + it("sets the all permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { all: true } as const; + + const permission = registry.setPermission(target, Permission.allow).permission(target); + + expect(permission).toEqual(Permission.allow); + }); + + it("sets a vendor permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { vendor: SomeVendorId }; + + const permission = registry.setPermission(target, Permission.allow).permission(target); + + expect(permission).toEqual(Permission.allow); + }); + + it("sets a site permission", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + const target = { site: SomeSiteId }; + + const permission = registry.setPermission(target, Permission.allow).permission(target); + + expect(permission).toEqual(Permission.allow); + }); + + it("ignores a site permission unless it is in the allowed sites list", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { site: SomeSiteId }; + + const permission = registry.setPermission(target, Permission.allow).permission(target); + + expect(permission).toBeUndefined(); + }); + + it("throws when a permission is invalid", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + + expect(() => registry.setPermission({ all: true }, "invalid" as any)).toThrow(); + }); + + it("throws when the extension set is the wrong type", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { invalid: "invalid" } as any; + + expect(() => registry.setPermission(target, Permission.allow)).toThrow(); + }); + }); + + describe("permission", () => { + it("gets the default all permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { all: true } as const; + + const permission = registry.permission(target); + + expect(permission).toEqual(Permission.default); + }); + + it("gets an all permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { all: true } as const; + registry.setPermission(target, Permission.none); + + const permission = registry.permission(target); + + expect(permission).toEqual(Permission.none); + }); + + it("gets a vendor permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { vendor: SomeVendorId }; + registry.setPermission(target, Permission.allow); + + const permission = registry.permission(target); + + expect(permission).toEqual(Permission.allow); + }); + + it("gets a site permission", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + const target = { site: SomeSiteId }; + registry.setPermission(target, Permission.allow); + + const permission = registry.permission(target); + + expect(permission).toEqual(Permission.allow); + }); + + it("gets a vendor permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { vendor: SomeVendorId }; + registry.setPermission(target, Permission.allow); + + const permission = registry.permission(target); + + expect(permission).toEqual(Permission.allow); + }); + + it("returns undefined when the extension set is the wrong type", () => { + const registry = new RuntimeExtensionRegistry([], []); + const target = { invalid: "invalid" } as any; + + const permission = registry.permission(target); + + expect(permission).toBeUndefined(); + }); + }); + + describe("permissions", () => { + it("returns a default all permission by default", () => { + const registry = new RuntimeExtensionRegistry([], []); + + const permission = registry.permissions(); + + expect(permission).toEqual([{ set: { all: true }, permission: Permission.default }]); + }); + + it("returns the all permission", () => { + const registry = new RuntimeExtensionRegistry([], []); + registry.setPermission({ all: true }, Permission.none); + + const permission = registry.permissions(); + + expect(permission).toEqual([{ set: { all: true }, permission: Permission.none }]); + }); + + it("includes site permissions", () => { + const registry = new RuntimeExtensionRegistry([SomeSiteId, "bar"] as any[], DefaultFields); + registry.registerSite(SomeSite).setPermission({ site: SomeSiteId }, Permission.allow); + registry + .registerSite({ + id: "bar" as any, + availableFields: [], + }) + .setPermission({ site: "bar" as any }, Permission.deny); + + const result = registry.permissions(); + + expect( + result.some((p: any) => p.set.site === SomeSiteId && p.permission === Permission.allow), + ).toBe(true); + expect( + result.some((p: any) => p.set.site === "bar" && p.permission === Permission.deny), + ).toBe(true); + }); + + it("includes vendor permissions", () => { + const registry = new RuntimeExtensionRegistry([], DefaultFields); + registry.registerVendor(SomeVendor).setPermission({ vendor: SomeVendorId }, Permission.allow); + registry + .registerVendor(JustTrustUs) + .setPermission({ vendor: JustTrustUs.id }, Permission.deny); + + const result = registry.permissions(); + + expect( + result.some((p: any) => p.set.vendor === SomeVendorId && p.permission === Permission.allow), + ).toBe(true); + expect( + result.some( + (p: any) => p.set.vendor === JustTrustUs.id && p.permission === Permission.deny, + ), + ).toBe(true); + }); + }); + + describe("registerExtension", () => { + it("registers an extension", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor); + + const result = registry.registerExtension(SomeExtension).extension(SomeSiteId, SomeVendorId); + + expect(result).toEqual(SomeExtension); + }); + + it("ignores extensions with nonregistered sites", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerVendor(SomeVendor); + + // precondition: the site is not registered + expect(registry.site(SomeSiteId)).toBeUndefined(); + + const result = registry.registerExtension(SomeExtension).extension(SomeSiteId, SomeVendorId); + + expect(result).toBeUndefined(); + }); + + it("ignores extensions with nonregistered vendors", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite); + + // precondition: the vendor is not registered + expect(registry.vendor(SomeVendorId)).toBeUndefined(); + + const result = registry.registerExtension(SomeExtension).extension(SomeSiteId, SomeVendorId); + + expect(result).toBeUndefined(); + }); + + it("ignores repeated extensions with nonregistered vendors", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor).registerExtension(SomeExtension); + + // precondition: the vendor is already registered + expect(registry.extension(SomeSiteId, SomeVendorId)).toBeDefined(); + + const result = registry + .registerExtension({ + ...SomeExtension, + requestedFields: [Field.domain], + }) + .extension(SomeSiteId, SomeVendorId); + + expect(result).toEqual(SomeExtension); + }); + + it("interns site metadata", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor); + + const internedSite = registry.site(SomeSiteId); + const result = registry.registerExtension(SomeExtension).extension(SomeSiteId, SomeVendorId)!; + + expect(result.site).toBe(internedSite); + }); + + it("interns vendor metadata", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor); + + const internedVendor = registry.vendor(SomeVendorId); + const result = registry.registerExtension(SomeExtension).extension(SomeSiteId, SomeVendorId)!; + + expect(result.product.vendor).toBe(internedVendor); + }); + + it("freezes the extension metadata", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor).registerExtension(SomeExtension); + const extension = registry.extension(SomeSiteId, SomeVendorId)!; + + // field assignments & mutation functions throw b/c the object is frozen + expect(() => ((extension.site as any) = SomeSite)).toThrow(); + expect(() => ((extension.product.vendor as any) = SomeVendor)).toThrow(); + expect(() => ((extension.product.name as any) = "SomeVendor")).toThrow(); + expect(() => ((extension.host as any) = {})).toThrow(); + expect(() => ((extension.host.selfHost as any) = {})).toThrow(); + expect(() => ((extension.host as any).authorization = "basic")).toThrow(); + expect(() => ((extension.host as any).baseUrl = "https://www.example.com")).toThrow(); + expect(() => ((extension.requestedFields as any) = [Field.baseUrl])).toThrow(); + expect(() => (extension.requestedFields as any).push(Field.baseUrl)).toThrow(); + }); + }); + + describe("extension", () => { + describe("extension", () => { + it("returns `undefined` for an unknown extension", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + const result = registry.extension(SomeSiteId, SomeVendorId); + + expect(result).toBeUndefined(); + }); + + it("interns the extension", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry.registerSite(SomeSite).registerVendor(SomeVendor).registerExtension(SomeExtension); + + const first = registry.extension(SomeSiteId, SomeVendorId); + const second = registry.extension(SomeSiteId, SomeVendorId); + + expect(first).toBe(second); + }); + }); + + describe("extensions", () => { + it("lists registered extensions", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry.registerSite(SomeSite); + registry.registerVendor(SomeVendor).registerExtension(SomeExtension); + registry.registerVendor(JustTrustUs).registerExtension(JustTrustUsExtension); + + const result = registry.extensions(); + + expect( + result.some( + ({ extension }) => + extension.site.id === SomeSiteId && extension.product.vendor.id === SomeVendorId, + ), + ).toBe(true); + expect( + result.some( + ({ extension }) => + extension.site.id === SomeSiteId && extension.product.vendor.id === JustTrustUs.id, + ), + ).toBe(true); + }); + + it("includes permissions for extensions", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension) + .setPermission({ vendor: SomeVendorId }, Permission.allow); + + const result = registry.extensions(); + + expect( + result.some( + ({ extension, permissions }) => + extension.site.id === SomeSiteId && + extension.product.vendor.id === SomeVendorId && + permissions.includes(Permission.allow), + ), + ).toBe(true); + }); + }); + + describe("build", () => { + it("builds an empty extension site when no extensions are registered", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }); + + it("builds an extension site with all registered extensions", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry.registerSite(SomeSite).registerVendor(SomeVendor).registerExtension(SomeExtension); + const expected = registry.extension(SomeSiteId, SomeVendorId); + + const result = registry.build(SomeSiteId)!; + + expect(result).toBeInstanceOf(ExtensionSite); + expect(result.extensions.get(SomeVendorId)).toBe(expected); + }); + + it("returns `undefined` for an unknown site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + const result = registry.build(SomeSiteId); + + expect(result).toBeUndefined(); + }); + + describe("when the all permission is `default`", () => { + const allPermission = Permission.default; + + it("builds an extension site with all registered extensions", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension) + .setPermission({ all: true }, Permission.default); + const expected = registry.extension(SomeSiteId, SomeVendorId); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toBe(expected); + }); + + it.each([[Permission.default], [Permission.allow]])( + "includes sites with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toEqual(SomeExtension); + }, + ); + + it.each([[Permission.none], [Permission.deny]])( + "ignores sites with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }, + ); + + it.each([[Permission.default], [Permission.allow]])( + "includes vendors with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toEqual(SomeExtension); + }, + ); + + it.each([[Permission.none], [Permission.deny]])( + "ignores vendors with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }, + ); + }); + + describe("when the all permission is `none`", () => { + const allPermission = Permission.none; + + it("builds an empty extension site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension) + .setPermission({ all: true }, Permission.none); + + const result = registry.build(SomeSiteId)!; + + expect(result).toBeInstanceOf(ExtensionSite); + expect(result.extensions.size).toBe(0); + }); + + it.each([[Permission.allow]])("includes sites with `%p` permission", (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toEqual(SomeExtension); + }); + + it.each([[Permission.default], [Permission.none], [Permission.deny]])( + "ignores sites with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }, + ); + + it.each([[Permission.allow]])("includes vendors with `%p` permission", (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toEqual(SomeExtension); + }); + + it.each([[Permission.default], [Permission.none], [Permission.deny]])( + "ignores vendors with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }, + ); + }); + + describe("when the all permission is `allow`", () => { + const allPermission = Permission.allow; + + it("builds an extension site with all registered extensions", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension) + .setPermission({ all: true }, Permission.default); + const expected = registry.extension(SomeSiteId, SomeVendorId); + + const result = registry.build(SomeSiteId)!; + + expect(result).toBeInstanceOf(ExtensionSite); + expect(result.extensions.get(SomeVendorId)).toBe(expected); + }); + + it.each([[Permission.default], [Permission.none], [Permission.allow]])( + "includes sites with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toEqual(SomeExtension); + }, + ); + + it.each([[Permission.deny]])("ignores sites with `%p` permission", (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }); + + it.each([[Permission.default], [Permission.none], [Permission.allow]])( + "includes vendors with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.get(SomeVendorId)).toEqual(SomeExtension); + }, + ); + + it.each([[Permission.deny]])("ignores vendors with `%p` permission", (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }); + }); + + describe("when the all permission is `deny`", () => { + const allPermission = Permission.deny; + + it("builds an empty extension site", () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension) + .setPermission({ all: true }, Permission.deny); + + const result = registry.build(SomeSiteId)!; + + expect(result).toBeInstanceOf(ExtensionSite); + expect(result.extensions.size).toBe(0); + }); + + it.each([[Permission.default], [Permission.none], [Permission.allow], [Permission.deny]])( + "ignores sites with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ site: SomeSiteId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }, + ); + + it.each([[Permission.default], [Permission.none], [Permission.allow], [Permission.deny]])( + "ignores vendors with `%p` permission", + (permission) => { + const registry = new RuntimeExtensionRegistry(DefaultSites, []); + registry + .registerSite(SomeSite) + .registerVendor(SomeVendor) + .registerExtension(SomeExtension); + registry.setPermission({ all: true }, allPermission); + registry.setPermission({ vendor: SomeVendorId }, permission); + + const result = registry.build(SomeSiteId)!; + + expect(result.extensions.size).toBe(0); + }, + ); + }); + }); + }); +}); diff --git a/libs/common/src/tools/extension/runtime-extension-registry.ts b/libs/common/src/tools/extension/runtime-extension-registry.ts new file mode 100644 index 00000000000..1c630dcc915 --- /dev/null +++ b/libs/common/src/tools/extension/runtime-extension-registry.ts @@ -0,0 +1,286 @@ +import { deepFreeze } from "../util"; + +import { ExtensionRegistry } from "./extension-registry.abstraction"; +import { ExtensionSite } from "./extension-site"; +import { AllowedPermissions } from "./metadata"; +import { + ExtensionMetadata, + ExtensionPermission, + ExtensionSet, + FieldId, + ProductMetadata, + SiteMetadata, + SiteId, + VendorId, + VendorMetadata, +} from "./type"; + +/** Tracks extension sites and the vendors that extend them in application memory. */ +export class RuntimeExtensionRegistry implements ExtensionRegistry { + /** Instantiates the extension registry + * @param allowedSites sites that are valid for use by any extension; + * this is most useful to disable an extension site that is only + * available on a specific client. + * @param allowedFields fields that are valid for use by any extension; + * this is most useful to prohibit access to a field via policy. + */ + constructor( + private readonly allowedSites: SiteId[], + private readonly allowedFields: FieldId[], + ) { + Object.freeze(this.allowedFields); + Object.freeze(this.allowedSites); + } + + private allPermission: ExtensionPermission = "default"; + + private siteRegistrations = new Map(); + private sitePermissions = new Map(); + + private vendorRegistrations = new Map(); + private vendorPermissions = new Map(); + + private extensionRegistrations = new Array(); + private extensionsBySiteByVendor = new Map>(); + + registerSite(site: SiteMetadata): this { + if (!this.allowedSites.includes(site.id)) { + return this; + } + + // verify requested fields are on the list of valid fields to expose to + // an extension + const availableFields = site.availableFields.filter((field) => + this.allowedFields.includes(field), + ); + const validated: SiteMetadata = deepFreeze({ id: site.id, availableFields }); + + if (!this.siteRegistrations.has(site.id)) { + this.siteRegistrations.set(site.id, validated); + } + + return this; + } + + site(site: SiteId): SiteMetadata | undefined { + const result = this.siteRegistrations.get(site); + return result; + } + + sites() { + const sites: { site: SiteMetadata; permission?: ExtensionPermission }[] = []; + + for (const [k, site] of this.siteRegistrations.entries()) { + const s: (typeof sites)[number] = { site }; + const permission = this.sitePermissions.get(k); + if (permission) { + s.permission = permission; + } + + sites.push(s); + } + + return sites; + } + + registerVendor(vendor: VendorMetadata): this { + if (!this.vendorRegistrations.has(vendor.id)) { + const frozen = deepFreeze(vendor); + this.vendorRegistrations.set(vendor.id, frozen); + } + + return this; + } + + vendor(vendor: VendorId): VendorMetadata | undefined { + const result = this.vendorRegistrations.get(vendor); + return result; + } + + vendors() { + const vendors: { vendor: VendorMetadata; permission?: ExtensionPermission }[] = []; + + for (const [k, vendor] of this.vendorRegistrations.entries()) { + const s: (typeof vendors)[number] = { vendor }; + const permission = this.vendorPermissions.get(k); + if (permission) { + s.permission = permission; + } + + vendors.push(s); + } + + return vendors; + } + + setPermission(set: ExtensionSet, permission: ExtensionPermission): this { + if (!AllowedPermissions.includes(permission)) { + throw new Error(`invalid extension permission: ${permission}`); + } + + if ("all" in set && set.all) { + this.allPermission = permission; + } else if ("vendor" in set) { + this.vendorPermissions.set(set.vendor, permission); + } else if ("site" in set) { + if (this.allowedSites.includes(set.site)) { + this.sitePermissions.set(set.site, permission); + } + } else { + throw new Error(`Unrecognized extension set received: ${JSON.stringify(set)}.`); + } + + return this; + } + + permission(set: ExtensionSet) { + if ("all" in set && set.all) { + return this.allPermission; + } else if ("vendor" in set) { + return this.vendorPermissions.get(set.vendor); + } else if ("site" in set) { + return this.sitePermissions.get(set.site); + } else { + return undefined; + } + } + + permissions() { + const rules: { set: ExtensionSet; permission: ExtensionPermission }[] = []; + rules.push({ set: { all: true }, permission: this.allPermission }); + + for (const [site, permission] of this.sitePermissions.entries()) { + rules.push({ set: { site }, permission }); + } + + for (const [vendor, permission] of this.vendorPermissions.entries()) { + rules.push({ set: { vendor }, permission }); + } + + return rules; + } + + registerExtension(meta: ExtensionMetadata): this { + const site = this.siteRegistrations.get(meta.site.id); + const vendor = this.vendorRegistrations.get(meta.product.vendor.id); + if (!site || !vendor) { + return this; + } + + // exit early if the extension is already registered + const extensionsByVendor = + this.extensionsBySiteByVendor.get(meta.site.id) ?? new Map(); + if (extensionsByVendor.has(meta.product.vendor.id)) { + return this; + } + + // create immutable copy; this updates the vendor and site with + // their internalized representation to provide reference equality + // across registrations + const product: ProductMetadata = { vendor }; + if (meta.product.name) { + product.name = meta.product.name; + } + const extension: ExtensionMetadata = Object.freeze({ + site, + product: Object.freeze(product), + host: Object.freeze({ ...meta.host }), + requestedFields: Object.freeze([...meta.requestedFields]), + }); + + // register it + const index = this.extensionRegistrations.push(extension) - 1; + extensionsByVendor.set(vendor.id, index); + this.extensionsBySiteByVendor.set(site.id, extensionsByVendor); + + return this; + } + + extension(site: SiteId, vendor: VendorId): ExtensionMetadata | undefined { + const index = this.extensionsBySiteByVendor.get(site)?.get(vendor) ?? -1; + if (index < 0) { + return undefined; + } else { + return this.extensionRegistrations[index]; + } + } + + private getPermissions(site: SiteId, vendor: VendorId): ExtensionPermission[] { + const permissions = [ + this.sitePermissions.get(site), + this.vendorPermissions.get(vendor), + this.allPermission, + // Need to cast away `undefined` because typescript isn't + // aware that the filter eliminates undefined elements + ].filter((p) => !!p) as ExtensionPermission[]; + + return permissions; + } + + extensions(): ReadonlyArray<{ + extension: ExtensionMetadata; + permissions: ExtensionPermission[]; + }> { + const extensions = []; + for (const extension of this.extensionRegistrations) { + const permissions = this.getPermissions(extension.site.id, extension.product.vendor.id); + + extensions.push({ extension, permissions }); + } + + return extensions; + } + + build(id: SiteId): ExtensionSite | undefined { + const site = this.siteRegistrations.get(id); + if (!site) { + return undefined; + } + + if (this.allPermission === "deny") { + return new ExtensionSite(site, new Map()); + } + + const extensions = new Map(); + const entries = this.extensionsBySiteByVendor.get(id)?.entries() ?? ([] as const); + for (const [vendor, index] of entries) { + const permissions = this.getPermissions(id, vendor); + + const extension = evaluate(permissions, this.extensionRegistrations[index]); + if (extension) { + extensions.set(vendor, extension); + } + } + + const extensionSite = new ExtensionSite(site, extensions); + return extensionSite; + } +} + +function evaluate( + permissions: ExtensionPermission[], + value: ExtensionMetadata, +): ExtensionMetadata | undefined { + // deny always wins + if (permissions.includes("deny")) { + return undefined; + } + + // allow overrides implicit permissions + if (permissions.includes("allow")) { + return value; + } + + // none permission becomes a deny + if (permissions.includes("none")) { + return undefined; + } + + // default permission becomes an allow + if (permissions.includes("default")) { + return value; + } + + // if no permission is recognized, throw. This code is unreachable. + throw new Error("failed to recognize any permissions"); +} diff --git a/libs/common/src/tools/extension/type.ts b/libs/common/src/tools/extension/type.ts new file mode 100644 index 00000000000..f37d4ff8e53 --- /dev/null +++ b/libs/common/src/tools/extension/type.ts @@ -0,0 +1,109 @@ +import { Opaque } from "type-fest"; + +import { Site, Field, Permission } from "./data"; + +/** well-known name for a feature extensible through an extension. */ +export type SiteId = keyof typeof Site; + +/** well-known name for a field surfaced from an extension site to a vendor. */ +export type FieldId = keyof typeof Field; + +/** Identifies a vendor extending bitwarden */ +export type VendorId = Opaque<"vendor", string>; + +/** uniquely identifies an extension. */ +export type ExtensionId = { site: SiteId; vendor: VendorId }; + +/** Permission levels for metadata. */ +export type ExtensionPermission = keyof typeof Permission; + +/** The capabilities and descriptive content for an extension */ +export type SiteMetadata = { + /** Uniquely identifies the extension site. */ + id: SiteId; + + /** Lists the fields disclosed by the extension to the vendor */ + availableFields: FieldId[]; +}; + +/** The capabilities and descriptive content for an extension */ +export type VendorMetadata = { + /** Uniquely identifies the vendor. */ + id: VendorId; + + /** Brand name of the service providing the extension. */ + name: string; +}; + +type TokenHeader = + | { + /** Transmit the token as the value of an `Authentication` header */ + authentication: true; + } + | { + /** Transmit the token as an `Authorization` header and a formatted value + * * `bearer` uses OAUTH-2.0 bearer token format + * * `token` prefixes the token with "Token" + * * `basic-username` uses HTTP Basic authentication format, encoding the + * token as the username. + */ + authorization: "bearer" | "token" | "basic-username"; + }; + +/** Catalogues an extension's hosting status. + * selfHost: "never" always uses the service's base URL + * selfHost: "maybe" allows the user to override the service's + * base URL with their own. + * selfHost: "always" requires a base URL. + */ +export type ApiHost = TokenHeader & + ( + | { selfHost: "never"; baseUrl: string } + | { selfHost: "maybe"; baseUrl: string } + | { selfHost: "always" } + ); + +/** Describes a branded product */ +export type ProductMetadata = { + /** The vendor providing the extension */ + vendor: VendorMetadata; + + /** The branded name of the product, if it varies from the Vendor name */ + name?: string; +}; + +/** Describes an extension provided by a vendor */ +export type ExtensionMetadata = { + /** The part of Bitwarden extended by the vendor's services */ + readonly site: Readonly; + + /** Product description */ + readonly product: Readonly; + + /** Hosting provider capabilities required by the extension */ + readonly host: Readonly; + + /** Lists the fields disclosed by the extension to the vendor. + * This should be a subset of the `availableFields` listed in + * the extension. + */ + readonly requestedFields: ReadonlyArray>; +}; + +/** Identifies a collection of extensions. + */ +export type ExtensionSet = + | { + /** A set of extensions sharing an extension point */ + site: SiteId; + } + | { + /** A set of extensions sharing a vendor */ + vendor: VendorId; + } + | { + /** The total set of extensions. This is used to set a categorical + * rule affecting all extensions. + */ + all: true; + }; diff --git a/libs/common/src/tools/extension/vendor/addyio.ts b/libs/common/src/tools/extension/vendor/addyio.ts new file mode 100644 index 00000000000..c33abd570ad --- /dev/null +++ b/libs/common/src/tools/extension/vendor/addyio.ts @@ -0,0 +1,25 @@ +import { Field } from "../data"; +import { Extension } from "../metadata"; +import { ExtensionMetadata, VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const AddyIo: VendorMetadata = { + id: Vendor.addyio, + name: "Addy.io", +}; + +export const AddyIoExtensions: ExtensionMetadata[] = [ + { + site: Extension.forwarder, + product: { + vendor: AddyIo, + }, + host: { + authorization: "bearer", + selfHost: "maybe", + baseUrl: "https://app.addy.io", + }, + requestedFields: [Field.token, Field.baseUrl, Field.domain], + }, +]; diff --git a/libs/common/src/tools/extension/vendor/bitwarden.ts b/libs/common/src/tools/extension/vendor/bitwarden.ts new file mode 100644 index 00000000000..7f659c2d07f --- /dev/null +++ b/libs/common/src/tools/extension/vendor/bitwarden.ts @@ -0,0 +1,8 @@ +import { VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const Bitwarden: VendorMetadata = Object.freeze({ + id: Vendor.bitwarden, + name: "Bitwarden", +}); diff --git a/libs/common/src/tools/extension/vendor/data.ts b/libs/common/src/tools/extension/vendor/data.ts new file mode 100644 index 00000000000..7f0802ef82f --- /dev/null +++ b/libs/common/src/tools/extension/vendor/data.ts @@ -0,0 +1,11 @@ +import { VendorId } from "../type"; + +export const Vendor = Object.freeze({ + addyio: "addyio" as VendorId, + bitwarden: "bitwarden" as VendorId, // RESERVED + duckduckgo: "duckduckgo" as VendorId, + fastmail: "fastmail" as VendorId, + forwardemail: "forwardemail" as VendorId, + mozilla: "mozilla" as VendorId, + simplelogin: "simplelogin" as VendorId, +} as const); diff --git a/libs/common/src/tools/extension/vendor/duckduckgo.ts b/libs/common/src/tools/extension/vendor/duckduckgo.ts new file mode 100644 index 00000000000..ca4634192f5 --- /dev/null +++ b/libs/common/src/tools/extension/vendor/duckduckgo.ts @@ -0,0 +1,25 @@ +import { Field } from "../data"; +import { Extension } from "../metadata"; +import { ExtensionMetadata, VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const DuckDuckGo: VendorMetadata = { + id: Vendor.duckduckgo, + name: "DuckDuckGo", +}; + +export const DuckDuckGoExtensions: ExtensionMetadata[] = [ + { + site: Extension.forwarder, + product: { + vendor: DuckDuckGo, + }, + host: { + authorization: "bearer", + selfHost: "never", + baseUrl: "https://quack.duckduckgo.com/api", + }, + requestedFields: [Field.token], + }, +]; diff --git a/libs/common/src/tools/extension/vendor/fastmail.ts b/libs/common/src/tools/extension/vendor/fastmail.ts new file mode 100644 index 00000000000..e6fb9ec16be --- /dev/null +++ b/libs/common/src/tools/extension/vendor/fastmail.ts @@ -0,0 +1,25 @@ +import { Field } from "../data"; +import { Extension } from "../metadata"; +import { ExtensionMetadata, VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const Fastmail: VendorMetadata = { + id: Vendor.fastmail, + name: "Fastmail", +}; + +export const FastmailExtensions: ExtensionMetadata[] = [ + { + site: Extension.forwarder, + product: { + vendor: Fastmail, + }, + host: { + authorization: "bearer", + selfHost: "maybe", + baseUrl: "https://api.fastmail.com", + }, + requestedFields: [Field.token], + }, +]; diff --git a/libs/common/src/tools/extension/vendor/forwardemail.ts b/libs/common/src/tools/extension/vendor/forwardemail.ts new file mode 100644 index 00000000000..4fbc8c139b1 --- /dev/null +++ b/libs/common/src/tools/extension/vendor/forwardemail.ts @@ -0,0 +1,25 @@ +import { Field } from "../data"; +import { Extension } from "../metadata"; +import { ExtensionMetadata, VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const ForwardEmail: VendorMetadata = { + id: Vendor.forwardemail, + name: "Forward Email", +}; + +export const ForwardEmailExtensions: ExtensionMetadata[] = [ + { + site: Extension.forwarder, + product: { + vendor: ForwardEmail, + }, + host: { + authorization: "basic-username", + selfHost: "never", + baseUrl: "https://api.forwardemail.net", + }, + requestedFields: [Field.domain, Field.token], + }, +]; diff --git a/libs/common/src/tools/extension/vendor/index.ts b/libs/common/src/tools/extension/vendor/index.ts new file mode 100644 index 00000000000..3bac78c80db --- /dev/null +++ b/libs/common/src/tools/extension/vendor/index.ts @@ -0,0 +1,30 @@ +import { deepFreeze } from "../../util"; + +import { AddyIo, AddyIoExtensions } from "./addyio"; +import { Bitwarden } from "./bitwarden"; +import { DuckDuckGo, DuckDuckGoExtensions } from "./duckduckgo"; +import { Fastmail, FastmailExtensions } from "./fastmail"; +import { ForwardEmail, ForwardEmailExtensions } from "./forwardemail"; +import { Mozilla, MozillaExtensions } from "./mozilla"; +import { SimpleLogin, SimpleLoginExtensions } from "./simplelogin"; + +export const Vendors = deepFreeze([ + AddyIo, + Bitwarden, + DuckDuckGo, + Fastmail, + ForwardEmail, + Mozilla, + SimpleLogin, +]); + +export const VendorExtensions = deepFreeze( + [ + AddyIoExtensions, + DuckDuckGoExtensions, + FastmailExtensions, + ForwardEmailExtensions, + MozillaExtensions, + SimpleLoginExtensions, + ].flat(), +); diff --git a/libs/common/src/tools/extension/vendor/mozilla.ts b/libs/common/src/tools/extension/vendor/mozilla.ts new file mode 100644 index 00000000000..b02b97d8777 --- /dev/null +++ b/libs/common/src/tools/extension/vendor/mozilla.ts @@ -0,0 +1,26 @@ +import { Field } from "../data"; +import { Extension } from "../metadata"; +import { ExtensionMetadata, VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const Mozilla: VendorMetadata = { + id: Vendor.mozilla, + name: "Mozilla", +}; + +export const MozillaExtensions: ExtensionMetadata[] = [ + { + site: Extension.forwarder, + product: { + vendor: Mozilla, + name: "Firefox Relay", + }, + host: { + authorization: "token", + selfHost: "never", + baseUrl: "https://relay.firefox.com/api", + }, + requestedFields: [Field.token], + }, +]; diff --git a/libs/common/src/tools/extension/vendor/readme.md b/libs/common/src/tools/extension/vendor/readme.md new file mode 100644 index 00000000000..507769edd4e --- /dev/null +++ b/libs/common/src/tools/extension/vendor/readme.md @@ -0,0 +1,33 @@ +# Vendors + +This folder contains vendor-specific logic that extends the +Bitwarden password manager. + +## Vendor IDs + +A vendor's ID is used to identify and trace the code provided by +a vendor across Bitwarden. There are a few rules that vendor ids +must follow: + +1. They should be human-readable. (No UUIDs.) +2. They may only contain lowercase ASCII characters and numbers. +3. They must retain backwards compatibility with prior versions. + +As such, any given ID may not not match the vendor's present +brand identity. Said branding may be stored in `VendorMetadata.name`. + +## Core files + +There are 4 vendor-independent files in this directory. + +- `data.ts` - core metadata used for system initialization +- `index.ts` - exports vendor metadata +- `README.md` - this file + +## Vendor definitions + +Each vendor should have one and only one definition, whose name +MUST match their `VendorId`. The vendor is free to use either a +single file (e.g. `bitwarden.ts`) or a folder containing multiple +files (e.g. `bitwarden/extension.ts`, `bitwarden/forwarder.ts`) to +host their files. diff --git a/libs/common/src/tools/extension/vendor/simplelogin.ts b/libs/common/src/tools/extension/vendor/simplelogin.ts new file mode 100644 index 00000000000..21ee969cebb --- /dev/null +++ b/libs/common/src/tools/extension/vendor/simplelogin.ts @@ -0,0 +1,25 @@ +import { Field } from "../data"; +import { Extension } from "../metadata"; +import { ExtensionMetadata, VendorMetadata } from "../type"; + +import { Vendor } from "./data"; + +export const SimpleLogin: VendorMetadata = { + id: Vendor.simplelogin, + name: "SimpleLogin", +}; + +export const SimpleLoginExtensions: ExtensionMetadata[] = [ + { + site: Extension.forwarder, + product: { + vendor: SimpleLogin, + }, + host: { + authentication: true, + selfHost: "maybe", + baseUrl: "https://app.simplelogin.io", + }, + requestedFields: [Field.baseUrl, Field.token, Field.domain], + }, +]; diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index 9ce147a3ff4..2c433fef93b 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -56,7 +56,7 @@ describe("errorOnChange", () => { source$.complete(); - expect(complete).toBeTrue(); + expect(complete).toBe(true); }); it("errors when the input changes", async () => { diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index a6427824a64..570f3e746a0 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -22,11 +22,6 @@ export abstract class SendApiService { postSend: (request: SendRequest) => Promise; postFileTypeSend: (request: SendRequest) => Promise; postSendFile: (sendId: string, fileId: string, data: FormData) => Promise; - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - postSendFileLegacy: (data: FormData) => Promise; putSend: (id: string, request: SendRequest) => Promise; putSendRemovePassword: (id: string) => Promise; deleteSend: (id: string) => Promise; diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index ff71408bce3..f709553646f 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -5,7 +5,6 @@ import { FileUploadApiMethods, FileUploadService, } from "../../../platform/abstractions/file-upload/file-upload.service"; -import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; @@ -106,15 +105,6 @@ export class SendApiService implements SendApiServiceAbstraction { return this.apiService.send("POST", "/sends/" + sendId + "/file/" + fileId, data, true, false); } - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - async postSendFileLegacy(data: FormData): Promise { - const r = await this.apiService.send("POST", "/sends/file", data, true, true); - return new SendResponse(r); - } - async putSend(id: string, request: SendRequest): Promise { const r = await this.apiService.send("PUT", "/sends/" + id, request, true, true); return new SendResponse(r); @@ -173,9 +163,7 @@ export class SendApiService implements SendApiServiceAbstraction { this.generateMethods(uploadDataResponse, response), ); } catch (e) { - if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { - response = await this.legacyServerSendFileUpload(sendData, request); - } else if (e instanceof ErrorResponse) { + if (e instanceof ErrorResponse) { throw new Error((e as ErrorResponse).getSingleMessage()); } else { throw e; @@ -219,35 +207,4 @@ export class SendApiService implements SendApiServiceAbstraction { return this.deleteSend(sendId); }; } - - /** - * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. - * This method still exists for backward compatibility with old server versions. - */ - async legacyServerSendFileUpload( - sendData: [Send, EncArrayBuffer], - request: SendRequest, - ): Promise { - const fd = new FormData(); - try { - const blob = new Blob([sendData[1].buffer], { type: "application/octet-stream" }); - fd.append("model", JSON.stringify(request)); - fd.append("data", blob, sendData[0].file.fileName.encryptedString); - } catch (e) { - if (Utils.isNode && !Utils.isBrowser) { - fd.append("model", JSON.stringify(request)); - fd.append( - "data", - Buffer.from(sendData[1].buffer) as any, - { - filepath: sendData[0].file.fileName.encryptedString, - contentType: "application/octet-stream", - } as any, - ); - } else { - throw e; - } - } - return await this.postSendFileLegacy(fd); - } } diff --git a/libs/common/src/tools/util.ts b/libs/common/src/tools/util.ts new file mode 100644 index 00000000000..9a3a14c1c83 --- /dev/null +++ b/libs/common/src/tools/util.ts @@ -0,0 +1,19 @@ +/** Recursively freeze an object's own keys + * @param value the value to freeze + * @returns `value` + * @remarks this function is derived from MDN's `deepFreeze`, which + * has been committed to the public domain. + */ +export function deepFreeze(value: T): Readonly { + const keys = Reflect.ownKeys(value) as (keyof T)[]; + + for (const key of keys) { + const own = value[key]; + + if ((own && typeof own === "object") || typeof own === "function") { + deepFreeze(own); + } + } + + return Object.freeze(value); +} diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 9fdb4327b98..cc3aa1946ca 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -77,7 +77,7 @@ describe("Folder Service", () => { const result = await firstValueFrom(folderService.folders$(mockUserId)); expect(result.length).toBe(2); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: "1", name: makeEncString("ENC_STRING_1") }, { id: "2", name: makeEncString("ENC_STRING_2") }, ]); @@ -98,7 +98,7 @@ describe("Folder Service", () => { const result = await firstValueFrom(folderService.folderViews$(mockUserId)); expect(result.length).toBe(3); - expect(result).toIncludeAllPartialMembers([ + expect(result).toContainPartialObjects([ { id: "1", name: "DEC" }, { id: "2", name: "DEC" }, { name: "No Folder" }, diff --git a/libs/components/src/drawer/drawer-body.component.ts b/libs/components/src/drawer/drawer-body.component.ts new file mode 100644 index 00000000000..9bd2adcffbc --- /dev/null +++ b/libs/components/src/drawer/drawer-body.component.ts @@ -0,0 +1,36 @@ +import { CdkScrollable } from "@angular/cdk/scrolling"; +import { ChangeDetectionStrategy, Component, Signal, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; + +/** + * Body container for `bit-drawer` + */ +@Component({ + selector: "bit-drawer-body", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [], + host: { + class: + "tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", + "[class.tw-border-t-secondary-300]": "isScrolled()", + }, + hostDirectives: [ + { + directive: CdkScrollable, + }, + ], + template: ` `, +}) +export class DrawerBodyComponent { + private scrollable = inject(CdkScrollable); + + /** TODO: share this utility with browser popup header? */ + protected isScrolled: Signal = toSignal( + this.scrollable + .elementScrolled() + .pipe(map(() => this.scrollable.measureScrollOffset("top") > 0)), + { initialValue: false }, + ); +} diff --git a/libs/components/src/drawer/drawer-close.directive.ts b/libs/components/src/drawer/drawer-close.directive.ts new file mode 100644 index 00000000000..bf56dd8b71f --- /dev/null +++ b/libs/components/src/drawer/drawer-close.directive.ts @@ -0,0 +1,29 @@ +import { Directive, inject } from "@angular/core"; + +import { DrawerComponent } from "./drawer.component"; + +/** + * Closes the ancestor drawer + * + * @example + * + * ```html + * + * + * + * ``` + **/ +@Directive({ + selector: "button[bitDrawerClose]", + standalone: true, + host: { + "(click)": "onClick()", + }, +}) +export class DrawerCloseDirective { + private drawer = inject(DrawerComponent, { optional: true }); + + protected onClick() { + this.drawer?.open.set(false); + } +} diff --git a/libs/components/src/drawer/drawer-header.component.html b/libs/components/src/drawer/drawer-header.component.html new file mode 100644 index 00000000000..4652e5537ee --- /dev/null +++ b/libs/components/src/drawer/drawer-header.component.html @@ -0,0 +1,15 @@ +
+
+ +

+ {{ title() }} +

+
+ +
diff --git a/libs/components/src/drawer/drawer-header.component.ts b/libs/components/src/drawer/drawer-header.component.ts new file mode 100644 index 00000000000..73834b8487e --- /dev/null +++ b/libs/components/src/drawer/drawer-header.component.ts @@ -0,0 +1,34 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, HostBinding, input } from "@angular/core"; + +import { IconButtonModule } from "../icon-button"; +import { I18nPipe } from "../shared/i18n.pipe"; +import { TypographyModule } from "../typography"; + +import { DrawerCloseDirective } from "./drawer-close.directive"; + +/** + * Header container for `bit-drawer` + **/ +@Component({ + selector: "bit-drawer-header", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, DrawerCloseDirective, TypographyModule, IconButtonModule, I18nPipe], + templateUrl: "drawer-header.component.html", + host: { + class: "tw-block tw-pl-4 tw-pr-2 tw-py-2", + }, +}) +export class DrawerHeaderComponent { + /** + * The title to display + */ + title = input.required(); + + /** We don't want to set the HTML title attribute with `this.title` */ + @HostBinding("attr.title") + protected get getTitle(): null { + return null; + } +} diff --git a/libs/components/src/drawer/drawer-host.directive.ts b/libs/components/src/drawer/drawer-host.directive.ts new file mode 100644 index 00000000000..f5e3e56b099 --- /dev/null +++ b/libs/components/src/drawer/drawer-host.directive.ts @@ -0,0 +1,28 @@ +import { Portal } from "@angular/cdk/portal"; +import { Directive, signal } from "@angular/core"; + +/** + * Host that renders a drawer + * + * @internal + */ +@Directive({ + selector: "[bitDrawerHost]", + standalone: true, +}) +export class DrawerHostDirective { + private _portal = signal | undefined>(undefined); + + /** The portal to display */ + portal = this._portal.asReadonly(); + + open(portal: Portal) { + this._portal.set(portal); + } + + close(portal: Portal) { + if (portal === this.portal()) { + this._portal.set(undefined); + } + } +} diff --git a/libs/components/src/drawer/drawer.component.html b/libs/components/src/drawer/drawer.component.html new file mode 100644 index 00000000000..fce6b3c57eb --- /dev/null +++ b/libs/components/src/drawer/drawer.component.html @@ -0,0 +1,8 @@ + +
+ +
+
diff --git a/libs/components/src/drawer/drawer.component.ts b/libs/components/src/drawer/drawer.component.ts new file mode 100644 index 00000000000..ccabb6f0b6e --- /dev/null +++ b/libs/components/src/drawer/drawer.component.ts @@ -0,0 +1,76 @@ +import { CdkPortal, PortalModule } from "@angular/cdk/portal"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + model, + viewChild, +} from "@angular/core"; + +import { DrawerHostDirective } from "./drawer-host.directive"; + +/** + * A drawer is a panel of supplementary content that is adjacent to the page's main content. + * + * Drawers render in `bit-layout`. Drawers must be a descendant of `bit-layout`, but they do not need to be a direct descendant. + */ +@Component({ + selector: "bit-drawer", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, PortalModule], + templateUrl: "drawer.component.html", +}) +export class DrawerComponent { + private drawerHost = inject(DrawerHostDirective); + private portal = viewChild.required(CdkPortal); + + /** + * Whether or not the drawer is open. + * + * Note: Does not support implicit boolean transform due to Angular limitation. Must be bound explicitly `[open]="true"` instead of just `open`. + * https://github.com/angular/angular/issues/55166#issuecomment-2032150999 + **/ + open = model(false); + + /** + * The ARIA role of the drawer. + * + * - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role) + * - For drawers that contain content that is complementary to the page's main content. (default) + * - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role) + * - For drawers that primary contain links to other content. + */ + role = input<"complementary" | "navigation">("complementary"); + + constructor() { + effect( + () => { + this.open() ? this.drawerHost.open(this.portal()) : this.drawerHost.close(this.portal()); + }, + { + allowSignalWrites: true, + }, + ); + + // Set `open` to `false` when another drawer is opened. + effect( + () => { + if (this.drawerHost.portal() !== this.portal()) { + this.open.set(false); + } + }, + { + allowSignalWrites: true, + }, + ); + } + + /** Toggle the drawer between open & closed */ + toggle() { + this.open.update((prev) => !prev); + } +} diff --git a/libs/components/src/drawer/drawer.mdx b/libs/components/src/drawer/drawer.mdx new file mode 100644 index 00000000000..0098ce64ea9 --- /dev/null +++ b/libs/components/src/drawer/drawer.mdx @@ -0,0 +1,120 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./drawer.stories"; + +import { DrawerOpen as KitchenSink } from "../stories/kitchen-sink/kitchen-sink.stories"; + + + +```ts +import { DrawerComponent } from "@bitwarden/components"; +``` + +# Drawer + +A drawer is a panel of supplementary content that is adjacent to the page's main content. + + + + + +## Usage + +A `bit-drawer` in a template will not render inline, but rather will render adjacent to the main +page content. + +```html + + + +

Lorem ipsum dolor...

+
+
+``` + +`bit-drawer` must be a descendant of `bit-layout`, but it does not need to be a direct descendant. + +## Header and body + +Header and body content can be provided with the `bit-drawer-header` and `bit-drawer-body` +components, respectively. + +A title can be passed to the header by input: +`` + +Custom content can be rendered before the title with the header's `start` slot: + +```html + + + +``` + +## Opening and closing + +`bit-drawer` opens when its `open` input is `true`: + +```html +... +``` + +Note: Model inputs do not support implicit boolean transformation (see Angular reasoning +[here](https://github.com/angular/angular/issues/55166#issuecomment-2032150999)). `open` must be +bound explicitly `` instead of just ``. + +Buttons can be made to open/toggle drawers by referencing a template variable, or by manipulating +state that is bound to `open`: + +```html + ... +``` + +For convenience, close buttons can be created _inside_ the drawer with the `bitDrawerClose` +directive: + +```html + + + +``` + +## Multiple Drawers + +Only one drawer can be open at a time, and they do not stack. If a drawer is already open, opening +another will close and replace the one already open. + + + +## Headless + +Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content. + + + +## Accessibility + +- The drawer should contain an h2 element. If you are using `bit-drawer-header`, this is created for + you via the `title` input: + +```html + +

Hello world!

+
+ + + + + + +``` + +- The ARIA role of the drawer can be set with the `role` attribute: + - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role) + (default) + - For drawers that contain content that is complementary to the page's main content. + - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role) + - For drawers that primary contain links to other content. + +## Kitchen Sink + + diff --git a/libs/components/src/drawer/drawer.module.ts b/libs/components/src/drawer/drawer.module.ts new file mode 100644 index 00000000000..9f51ba06b4e --- /dev/null +++ b/libs/components/src/drawer/drawer.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from "@angular/core"; + +import { DrawerBodyComponent } from "./drawer-body.component"; +import { DrawerCloseDirective } from "./drawer-close.directive"; +import { DrawerHeaderComponent } from "./drawer-header.component"; +import { DrawerComponent } from "./drawer.component"; + +@NgModule({ + imports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective], + exports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective], +}) +export class DrawerModule {} diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts new file mode 100644 index 00000000000..54b4c89f4ce --- /dev/null +++ b/libs/components/src/drawer/drawer.stories.ts @@ -0,0 +1,124 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { ButtonModule } from "../button"; +import { CalloutModule } from "../callout"; +import { LayoutComponent } from "../layout"; +import { mockLayoutI18n } from "../layout/mocks"; +import { + disableBothThemeDecorator, + positionFixedWrapperDecorator, +} from "../stories/storybook-decorators"; +import { TypographyModule } from "../typography"; +import { I18nMockService } from "../utils"; + +import { DrawerBodyComponent } from "./drawer-body.component"; +import { DrawerHeaderComponent } from "./drawer-header.component"; +import { DrawerComponent } from "./drawer.component"; +import { DrawerModule } from "./drawer.module"; + +export default { + title: "Component Library/Drawer", + component: DrawerComponent, + subcomponents: { + DrawerHeaderComponent, + DrawerBodyComponent, + }, + decorators: [ + positionFixedWrapperDecorator(), + disableBothThemeDecorator, + moduleMetadata({ + imports: [ + RouterTestingModule, + LayoutComponent, + DrawerModule, + ButtonModule, + CalloutModule, + TypographyModule, + ], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + ...mockLayoutI18n, + close: "Close", + }); + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + +

The drawer is {{ open ? "open" : "closed" }}.

+ + + + + + + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +

+ +
+ + `, + }), + args: { + open: true, + }, +}; + +export const Headless: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + +

The drawer is {{ open ? "open" : "closed" }}.

+ + +

+ Hello world! +
+ + `, + }), + args: { + open: true, + }, +}; + +export const MultipleDrawers: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + + Foo + + + + Bar + + + `, + }), +}; diff --git a/libs/components/src/drawer/index.ts b/libs/components/src/drawer/index.ts new file mode 100644 index 00000000000..abf5b8d34f1 --- /dev/null +++ b/libs/components/src/drawer/index.ts @@ -0,0 +1,5 @@ +export * from "./drawer.module"; +export * from "./drawer.component"; +export * from "./drawer-body.component"; +export * from "./drawer-close.directive"; +export * from "./drawer-header.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index a48750a99ff..ed844520444 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -14,6 +14,7 @@ export * from "./color-password"; export * from "./container"; export * from "./dialog"; export * from "./disclosure"; +export * from "./drawer"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index ccbb40c2b57..7c1c5b2501d 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -37,4 +37,5 @@ >
+ diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index d55ad8493eb..7bf8a6ad173 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -1,7 +1,9 @@ +import { PortalModule } from "@angular/cdk/portal"; import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { DrawerHostDirective } from "../drawer/drawer-host.directive"; import { LinkModule } from "../link"; import { SideNavService } from "../navigation/side-nav.service"; import { SharedModule } from "../shared"; @@ -10,12 +12,14 @@ import { SharedModule } from "../shared"; selector: "bit-layout", templateUrl: "layout.component.html", standalone: true, - imports: [CommonModule, SharedModule, LinkModule, RouterModule], + imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule], + hostDirectives: [DrawerHostDirective], }) export class LayoutComponent { protected mainContentId = "main-content"; - constructor(protected sideNavService: SideNavService) {} + protected sideNavService = inject(SideNavService); + protected drawerPortal = inject(DrawerHostDirective).portal; focusMainContent() { document.getElementById(this.mainContentId)?.focus(); diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts index a0eadebe7fa..7fdad655548 100644 --- a/libs/components/src/layout/layout.stories.ts +++ b/libs/components/src/layout/layout.stories.ts @@ -6,10 +6,11 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { CalloutModule } from "../callout"; import { NavigationModule } from "../navigation"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; -import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator"; import { LayoutComponent } from "./layout.component"; +import { mockLayoutI18n } from "./mocks"; export default { title: "Component Library/Layout", @@ -22,12 +23,7 @@ export default { { provide: I18nService, useFactory: () => { - return new I18nMockService({ - toggleSideNavigation: "Toggle side navigation", - skipToContent: "Skip to content", - submenu: "submenu", - toggleCollapse: "toggle collapse", - }); + return new I18nMockService(mockLayoutI18n); }, }, ], diff --git a/libs/components/src/layout/mocks.ts b/libs/components/src/layout/mocks.ts new file mode 100644 index 00000000000..50c2bd9afb2 --- /dev/null +++ b/libs/components/src/layout/mocks.ts @@ -0,0 +1,7 @@ +/** TODO: create mock messages.json file for all of CL in favor of sharing per-Story mocks */ +export const mockLayoutI18n = { + toggleSideNavigation: "Toggle side navigation", + skipToContent: "Skip to content", + submenu: "submenu", + toggleCollapse: "toggle collapse", +}; diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index a6fa53ff187..f412dbc20ba 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -6,8 +6,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LayoutComponent } from "../layout"; import { SharedModule } from "../shared/shared.module"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; -import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator"; import { NavGroupComponent } from "./nav-group.component"; import { NavigationModule } from "./navigation.module"; diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 20d6ebd1d7e..376f121eb00 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -5,8 +5,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; -import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator"; import { NavItemComponent } from "./nav-item.component"; import { NavigationModule } from "./navigation.module"; diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts index 687b8917381..13f0a16a4d7 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts @@ -1,5 +1,5 @@ import { DialogRef } from "@angular/cdk/dialog"; -import { Component } from "@angular/core"; +import { Component, signal } from "@angular/core"; import { DialogService } from "../../../dialog"; import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; @@ -28,13 +28,7 @@ class KitchenSinkDialog { @Component({ standalone: true, selector: "bit-tab-main", - imports: [ - KitchenSinkSharedModule, - KitchenSinkTable, - KitchenSinkToggleList, - KitchenSinkForm, - KitchenSinkDialog, - ], + imports: [KitchenSinkSharedModule, KitchenSinkTable, KitchenSinkToggleList, KitchenSinkForm], template: ` Kitchen Sink test zone @@ -48,6 +42,11 @@ class KitchenSinkDialog {

+
+

Bitwarden Kitchen Sink

+ Learn more +
+

The purpose of this story is to compose together all of our components. When snapshot tests @@ -63,18 +62,14 @@ class KitchenSinkDialog {

-
-

Bitwarden

- Learn more -
-

About

- + +

Companies using Bitwarden

@@ -99,15 +94,87 @@ class KitchenSinkDialog {
+ + + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+ + What did foo say to bar? + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+
+
`, }) export class KitchenSinkMainComponent { constructor(public dialogService: DialogService) {} - openDefaultDialog() { + protected drawerOpen = signal(false); + + openDialog() { this.dialogService.open(KitchenSinkDialog); } + openDrawer() { + this.drawerOpen.set(true); + } + navItems = [ { icon: "bwi-collection", name: "Password Managers", route: "/" }, { icon: "bwi-collection", name: "Favorites", route: "/" }, diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts index 56e3a92e2a3..c4fe2f9b2af 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts @@ -13,6 +13,7 @@ import { CalloutModule } from "../../callout"; import { CheckboxModule } from "../../checkbox"; import { ColorPasswordModule } from "../../color-password"; import { DialogModule } from "../../dialog"; +import { DrawerModule } from "../../drawer"; import { FormControlModule } from "../../form-control"; import { FormFieldModule } from "../../form-field"; import { IconModule } from "../../icon"; @@ -48,6 +49,7 @@ import { TypographyModule } from "../../typography"; ColorPasswordModule, CommonModule, DialogModule, + DrawerModule, FormControlModule, FormFieldModule, FormsModule, @@ -85,6 +87,7 @@ import { TypographyModule } from "../../typography"; ColorPasswordModule, CommonModule, DialogModule, + DrawerModule, FormControlModule, FormFieldModule, FormsModule, diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index a90597c1710..62b93984384 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -1,13 +1,7 @@ import { importProvidersFrom } from "@angular/core"; import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { RouterModule } from "@angular/router"; -import { - Meta, - StoryObj, - applicationConfig, - componentWrapperDecorator, - moduleMetadata, -} from "@storybook/angular"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; import { userEvent, getAllByRole, @@ -23,6 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { DialogService } from "../../dialog"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; +import { disableBothThemeDecorator, positionFixedWrapperDecorator } from "../storybook-decorators"; import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component"; import { KitchenSinkForm } from "./components/kitchen-sink-form.component"; @@ -35,25 +30,8 @@ export default { title: "Documentation / Kitchen Sink", component: LayoutComponent, decorators: [ - componentWrapperDecorator( - /** - * Applying a CSS transform makes a `position: fixed` element act like it is `position: relative` - * https://github.com/storybookjs/storybook/issues/8011#issue-490251969 - */ - (story) => { - return /* HTML */ `
- ${story} -
`; - }, - ({ globals }) => { - /** - * avoid a bug with the way that we render the same component twice in the same iframe and how - * that interacts with the router-outlet - */ - const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"]; - return { theme: themeOverride }; - }, - ), + positionFixedWrapperDecorator(), + disableBothThemeDecorator, moduleMetadata({ imports: [ KitchenSinkSharedModule, @@ -135,7 +113,7 @@ export const MenuOpen: Story = { }, }; -export const DefaultDialogOpen: Story = { +export const DialogOpen: Story = { ...Default, play: async (context) => { const canvas = context.canvasElement; @@ -148,6 +126,19 @@ export const DefaultDialogOpen: Story = { }, }; +export const DrawerOpen: Story = { + ...Default, + play: async (context) => { + const canvas = context.canvasElement; + const drawerButton = getByRole(canvas, "button", { + name: "Open Drawer", + }); + + // workaround for userEvent not firing in FF https://github.com/testing-library/user-event/issues/1075 + await fireEvent.click(drawerButton); + }, +}; + export const PopoverOpen: Story = { ...Default, play: async (context) => { diff --git a/libs/components/src/utils/position-fixed-wrapper-decorator.ts b/libs/components/src/stories/storybook-decorators.ts similarity index 50% rename from libs/components/src/utils/position-fixed-wrapper-decorator.ts rename to libs/components/src/stories/storybook-decorators.ts index a3298e6ad03..d59f2dd1f3e 100644 --- a/libs/components/src/utils/position-fixed-wrapper-decorator.ts +++ b/libs/components/src/stories/storybook-decorators.ts @@ -11,7 +11,21 @@ export const positionFixedWrapperDecorator = (wrapper?: (story: string) => strin * https://github.com/storybookjs/storybook/issues/8011#issue-490251969 */ (story) => - /* HTML */ `
+ /* HTML */ `
${wrapper ? wrapper(story) : story}
`, ); + +export const disableBothThemeDecorator = componentWrapperDecorator( + (story) => story, + ({ globals }) => { + /** + * avoid a bug with the way that we render the same component twice in the same iframe and how + * that interacts with the router-outlet + */ + const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"]; + return { theme: themeOverride }; + }, +); diff --git a/libs/key-management/src/angular/lock/components/lock.component.ts b/libs/key-management/src/angular/lock/components/lock.component.ts index 6c912b0eaae..c4e32078134 100644 --- a/libs/key-management/src/angular/lock/components/lock.component.ts +++ b/libs/key-management/src/angular/lock/components/lock.component.ts @@ -30,7 +30,7 @@ import { MasterPasswordVerification, MasterPasswordVerificationResponse, } from "@bitwarden/common/auth/types/verification"; -import { ClientType } from "@bitwarden/common/enums"; +import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -244,6 +244,10 @@ export class LockComponent implements OnInit, OnDestroy { if (activeAccount == null) { return; } + // this account may be unlocked, prevent any prompts so we can redirect to vault + if (await this.keyService.hasUserKeyInMemory(activeAccount.id)) { + return; + } this.setEmailAsPageSubtitle(activeAccount.email); @@ -301,6 +305,11 @@ export class LockComponent implements OnInit, OnDestroy { } if (this.clientType === "browser") { + // Firefox closes the popup when unfocused, so this would block all unlock methods + if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) { + return; + } + if ( this.unlockOptions.biometrics.enabled && autoPromptBiometrics && diff --git a/libs/tools/generator/components/src/catchall-settings.component.html b/libs/tools/generator/components/src/catchall-settings.component.html index 61037c91a73..4afa145c055 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.html +++ b/libs/tools/generator/components/src/catchall-settings.component.html @@ -1,4 +1,4 @@ -
+ {{ "domainName" | i18n }} {{ "clearHistory" | i18n }} + + diff --git a/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts b/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts index cec818b1cd6..7bcffd92399 100644 --- a/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -34,6 +35,7 @@ export class CredentialGeneratorHistoryDialogComponent { private accountService: AccountService, private history: GeneratorHistoryService, private dialogService: DialogService, + private dialogRef: DialogRef, ) { this.accountService.activeAccount$ .pipe( @@ -52,7 +54,13 @@ export class CredentialGeneratorHistoryDialogComponent { .subscribe(this.hasHistory$); } - clear = async () => { + /** closes the dialog */ + protected close() { + this.dialogRef.close(); + } + + /** Launches clear history flow */ + protected async clear() { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "clearGeneratorHistoryTitle" }, content: { key: "cleargGeneratorHistoryDescription" }, @@ -64,5 +72,5 @@ export class CredentialGeneratorHistoryDialogComponent { if (confirmed) { await this.history.clear(await firstValueFrom(this.userId$)); } - }; + } } diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index ce86abe80ae..6f27c7020db 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -56,7 +56,7 @@
- + {{ "type" | i18n }} -
+ {{ "service" | i18n }} + {{ "forwarderDomainName" | i18n }}
{{ "options" | i18n }}
- +
diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html index 9f8e00921fb..5e4d1079725 100644 --- a/libs/tools/generator/components/src/password-settings.component.html +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -2,7 +2,7 @@

{{ "options" | i18n }}

- +
diff --git a/libs/tools/generator/components/src/subaddress-settings.component.html b/libs/tools/generator/components/src/subaddress-settings.component.html index 1dfb5e3460d..b7f71b12b2a 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.html +++ b/libs/tools/generator/components/src/subaddress-settings.component.html @@ -1,4 +1,4 @@ - + {{ "email" | i18n }}
- + {{ "type" | i18n }} -
+ {{ "service" | i18n }} + (); + +describe("email - catchall generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(catchall.engine.create(dependencyProvider)).toBeInstanceOf(EmailRandomizer); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata = null; + beforeEach(() => { + const profile = catchall.profiles[Profile.account]; + if (isCoreProfile(profile)) { + accountProfile = profile; + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: CatchallGenerationOptions = { + catchallType: "random", + catchallDomain: "example.com", + }; + + const result = accountProfile.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a catchall constraints", () => { + const context = { defaultConstraints: {} }; + + const constraints = accountProfile.constraints.create([], context); + + expect(constraints).toBeInstanceOf(CatchallConstraints); + }); + + it("extracts the domain from context.email", () => { + const context = { email: "foo@example.com", defaultConstraints: {} }; + + const constraints = accountProfile.constraints.create([], context) as CatchallConstraints; + + expect(constraints.domain).toEqual("example.com"); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/email/catchall.ts b/libs/tools/generator/core/src/metadata/email/catchall.ts new file mode 100644 index 00000000000..0711e5c3719 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/email/catchall.ts @@ -0,0 +1,70 @@ +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { EmailRandomizer } from "../../engine"; +import { CatchallConstraints } from "../../policies/catchall-constraints"; +import { + CatchallGenerationOptions, + CredentialGenerator, + GeneratorDependencyProvider, +} from "../../types"; +import { Algorithm, Type, Profile } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const catchall: GeneratorMetadata = deepFreeze({ + id: Algorithm.catchall, + category: Type.email, + weight: 210, + i18nKeys: { + name: "catchallEmail", + description: "catchallEmailDesc", + credentialType: "email", + generateCredential: "generateEmail", + credentialGenerated: "emailGenerated", + copyCredential: "copyEmail", + useCredential: "useThisEmail", + }, + capabilities: { + autogenerate: true, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "catchallGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "catchallType", + "catchallDomain", + ]), + state: GENERATOR_DISK, + initial: { + catchallType: "random", + catchallDomain: "", + }, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + }, + constraints: { + default: { catchallDomain: { minLength: 1 } }, + create(_policies, context) { + return new CatchallConstraints(context.email ?? ""); + }, + }, + }, + }, +}); + +export default catchall; diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts new file mode 100644 index 00000000000..1dfc219d466 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts @@ -0,0 +1,4 @@ +// Forwarders are pending integration with the extension API +// +// They use the 300-block of weights and derive their metadata +// using logic similar to `toCredentialGeneratorConfiguration` diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts new file mode 100644 index 00000000000..2ac7645ed30 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts @@ -0,0 +1,65 @@ +import { mock } from "jest-mock-extended"; + +import { EmailRandomizer } from "../../engine"; +import { SubaddressConstraints } from "../../policies/subaddress-constraints"; +import { SubaddressGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { Profile } from "../data"; +import { CoreProfileMetadata } from "../profile-metadata"; +import { isCoreProfile } from "../util"; + +import plusAddress from "./plus-address"; + +const dependencyProvider = mock(); + +describe("email - plus address generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(plusAddress.engine.create(dependencyProvider)).toBeInstanceOf(EmailRandomizer); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata = null; + beforeEach(() => { + const profile = plusAddress.profiles[Profile.account]; + if (isCoreProfile(profile)) { + accountProfile = profile; + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: SubaddressGenerationOptions = { + subaddressType: "random", + subaddressEmail: "foo@example.com", + }; + + const result = accountProfile.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a subaddress constraints", () => { + const context = { defaultConstraints: {} }; + + const constraints = accountProfile.constraints.create([], context); + + expect(constraints).toBeInstanceOf(SubaddressConstraints); + }); + + it("sets the constraint email to context.email", () => { + const context = { email: "bar@example.com", defaultConstraints: {} }; + + const constraints = accountProfile.constraints.create([], context) as SubaddressConstraints; + + expect(constraints.email).toEqual("bar@example.com"); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.ts b/libs/tools/generator/core/src/metadata/email/plus-address.ts new file mode 100644 index 00000000000..0db0acd415c --- /dev/null +++ b/libs/tools/generator/core/src/metadata/email/plus-address.ts @@ -0,0 +1,72 @@ +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { EmailRandomizer } from "../../engine"; +import { SubaddressConstraints } from "../../policies/subaddress-constraints"; +import { + CredentialGenerator, + GeneratorDependencyProvider, + SubaddressGenerationOptions, +} from "../../types"; +import { Algorithm, Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const plusAddress: GeneratorMetadata = deepFreeze({ + id: Algorithm.plusAddress, + category: Type.email, + weight: 200, + i18nKeys: { + name: "plusAddressedEmail", + description: "plusAddressedEmailDesc", + credentialType: "email", + generateCredential: "generateEmail", + credentialGenerated: "emailGenerated", + copyCredential: "copyEmail", + useCredential: "useThisEmail", + }, + capabilities: { + autogenerate: true, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "subaddressGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "subaddressType", + "subaddressEmail", + ]), + state: GENERATOR_DISK, + initial: { + subaddressType: "random", + subaddressEmail: "", + }, + options: { + deserializer(value) { + return value; + }, + clearOn: ["logout"], + }, + }, + constraints: { + default: {}, + create(_policy, context) { + return new SubaddressConstraints(context.email ?? ""); + }, + }, + }, + }, +}); + +export default plusAddress; diff --git a/libs/tools/generator/core/src/metadata/generator-metadata.ts b/libs/tools/generator/core/src/metadata/generator-metadata.ts new file mode 100644 index 00000000000..9296d30430e --- /dev/null +++ b/libs/tools/generator/core/src/metadata/generator-metadata.ts @@ -0,0 +1,29 @@ +import { CredentialGenerator, GeneratorDependencyProvider } from "../types"; + +import { AlgorithmMetadata } from "./algorithm-metadata"; +import { Profile } from "./data"; +import { ProfileMetadata } from "./profile-metadata"; + +/** Extends the algorithm metadata with storage and engine configurations. + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ +export type GeneratorMetadata = AlgorithmMetadata & { + /** An algorithm that generates credentials when ran. */ + engine: { + /** Factory for the generator + */ + create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator; + }; + + /** Defines parameters for credential generation */ + profiles: { + /** profiles supported by this generator; when `undefined`, + * the generator does not support the profile. + */ + [K in keyof typeof Profile]?: ProfileMetadata; + }; +}; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts new file mode 100644 index 00000000000..57961a60033 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -0,0 +1,102 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { PasswordRandomizer } from "../../engine"; +import { PassphrasePolicyConstraints } from "../../policies"; +import { PassphraseGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { Profile } from "../data"; +import { CoreProfileMetadata } from "../profile-metadata"; +import { isCoreProfile } from "../util"; + +import effPassphrase from "./eff-word-list"; + +const dependencyProvider = mock(); + +describe("password - eff words generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(effPassphrase.engine.create(dependencyProvider)).toBeInstanceOf(PasswordRandomizer); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata = null; + beforeEach(() => { + const profile = effPassphrase.profiles[Profile.account]; + if (isCoreProfile(profile)) { + accountProfile = profile; + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial }; + + const result = accountProfile.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a passphrase policy constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + + const constraints = accountProfile.constraints.create([], context); + + expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints); + }); + + it("forwards the policy to the constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 6, + capitalize: false, + includeNumber: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile.constraints.create(policies, context); + + expect(constraints.constraints.numWords.min).toEqual(6); + }); + + it("combines multiple policies in the constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 6, + capitalize: false, + includeNumber: false, + }, + }, + { + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 3, + capitalize: true, + includeNumber: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile.constraints.create(policies, context); + + expect(constraints.constraints.numWords.min).toEqual(6); + expect(constraints.constraints.capitalize.requiredValue).toEqual(true); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts new file mode 100644 index 00000000000..fc86032bf6b --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts @@ -0,0 +1,91 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; + +import { PasswordRandomizer } from "../../engine"; +import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; +import { + CredentialGenerator, + GeneratorDependencyProvider, + PassphraseGenerationOptions, +} from "../../types"; +import { Algorithm, Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const passphrase: GeneratorMetadata = { + id: Algorithm.passphrase, + category: Type.password, + weight: 110, + i18nKeys: { + name: "passphrase", + credentialType: "passphrase", + generateCredential: "generatePassphrase", + credentialGenerated: "passphraseGenerated", + copyCredential: "copyPassphrase", + useCredential: "useThisPassphrase", + }, + capabilities: { + autogenerate: false, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "passphraseGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "numWords", + "wordSeparator", + "capitalize", + "includeNumber", + ]), + state: GENERATOR_DISK, + initial: { + numWords: 6, + wordSeparator: "-", + capitalize: false, + includeNumber: false, + }, + options: { + deserializer(value) { + return value; + }, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + constraints: { + type: PolicyType.PasswordGenerator, + default: { + wordSeparator: { maxLength: 1 }, + numWords: { + min: 3, + max: 20, + recommendation: 6, + }, + }, + create(policies, context) { + const initial = { + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }; + const policy = policies.reduce(passphraseLeastPrivilege, initial); + const constraints = new PassphrasePolicyConstraints(policy, context.defaultConstraints); + return constraints; + }, + }, + }, + }, +}; + +export default passphrase; diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts new file mode 100644 index 00000000000..d91ceaac248 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -0,0 +1,105 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { PasswordRandomizer } from "../../engine"; +import { DynamicPasswordPolicyConstraints } from "../../policies"; +import { PasswordGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { Profile } from "../data"; +import { CoreProfileMetadata } from "../profile-metadata"; +import { isCoreProfile } from "../util"; + +import password from "./random-password"; + +const dependencyProvider = mock(); + +describe("password - characters generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(password.engine.create(dependencyProvider)).toBeInstanceOf(PasswordRandomizer); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata = null; + beforeEach(() => { + const profile = password.profiles[Profile.account]; + if (isCoreProfile(profile)) { + accountProfile = profile; + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: PasswordGenerationOptions = { ...accountProfile.storage.initial }; + + const result = accountProfile.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a passphrase policy constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + + const constraints = accountProfile.constraints.create([], context); + + expect(constraints).toBeInstanceOf(DynamicPasswordPolicyConstraints); + }); + + it("forwards the policy to the constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + minLength: 10, + capitalize: false, + useNumbers: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile.constraints.create(policies, context); + + expect(constraints.constraints.length.min).toEqual(10); + }); + + it("combines multiple policies in the constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + minLength: 14, + useSpecial: false, + useNumbers: false, + }, + }, + { + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + minLength: 10, + useSpecial: true, + includeNumber: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile.constraints.create(policies, context); + + expect(constraints.constraints.length.min).toEqual(14); + expect(constraints.constraints.special.requiredValue).toEqual(true); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/password/random-password.ts b/libs/tools/generator/core/src/metadata/password/random-password.ts new file mode 100644 index 00000000000..693236b0967 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/random-password.ts @@ -0,0 +1,117 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { PasswordRandomizer } from "../../engine"; +import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; +import { + CredentialGenerator, + GeneratorDependencyProvider, + PasswordGeneratorSettings, +} from "../../types"; +import { Algorithm, Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const password: GeneratorMetadata = deepFreeze({ + id: Algorithm.password, + category: Type.password, + weight: 100, + i18nKeys: { + name: "password", + generateCredential: "generatePassword", + credentialGenerated: "passwordGenerated", + credentialType: "password", + copyCredential: "copyPassword", + useCredential: "useThisPassword", + }, + capabilities: { + autogenerate: true, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "passwordGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "length", + "ambiguous", + "uppercase", + "minUppercase", + "lowercase", + "minLowercase", + "number", + "minNumber", + "special", + "minSpecial", + ]), + state: GENERATOR_DISK, + initial: { + length: 14, + ambiguous: true, + uppercase: true, + minUppercase: 1, + lowercase: true, + minLowercase: 1, + number: true, + minNumber: 1, + special: false, + minSpecial: 0, + }, + options: { + deserializer(value) { + return value; + }, + clearOn: ["logout"], + }, + }, + constraints: { + type: PolicyType.PasswordGenerator, + default: { + length: { + min: 5, + max: 128, + recommendation: 14, + }, + minNumber: { + min: 0, + max: 9, + }, + minSpecial: { + min: 0, + max: 9, + }, + }, + create(policies, context) { + const initial = { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }; + const policy = policies.reduce(passwordLeastPrivilege, initial); + const constraints = new DynamicPasswordPolicyConstraints( + policy, + context.defaultConstraints, + ); + return constraints; + }, + }, + }, + }, +}); + +export default password; diff --git a/libs/tools/generator/core/src/metadata/profile-metadata.ts b/libs/tools/generator/core/src/metadata/profile-metadata.ts new file mode 100644 index 00000000000..4ac9139f632 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/profile-metadata.ts @@ -0,0 +1,80 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { SiteId } from "@bitwarden/common/tools/extension"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; +import { Constraints } from "@bitwarden/common/tools/types"; + +import { GeneratorConstraints } from "../types"; + +export type ProfileContext = { + /** The email address for the current user; + * `undefined` when no email is available. + */ + email?: string; + + /** Default application limits for the profile */ + defaultConstraints: Constraints; +}; + +type ProfileConstraints = { + /** The key used to locate this profile's policies in the admin console. + * When this type is undefined, no policy is defined for the profile. + */ + type?: PolicyType; + + /** default application limits for this profile; these are overridden + * by the policy + */ + default: Constraints; + + /** Constructs generator constraints from a policy. + * @param policies the administrative policy to apply to the provided constraints + * When `type` is undefined then `policy` is `undefined` this is an empty array. + * @param defaultConstraints application constraints; typically those defined in + * the `default` member, above. + * @returns the generator constraints to apply to this profile's options. + */ + create: (policies: Policy[], context: ProfileContext) => GeneratorConstraints; +}; + +/** Generator profiles partition generator operations + * according to where they're used within the password + * manager. Core profiles store their data using the + * generator's system storage. + */ +export type CoreProfileMetadata = { + /** distinguishes profile metadata types */ + type: "core"; + + /** plaintext import buffer */ + import?: ObjectKey, Options> & { format: "plain" }; + + /** persistent storage location */ + storage: ObjectKey; + + /** policy enforced when saving the options */ + constraints: ProfileConstraints; +}; + +/** Generator profiles partition generator operations + * according to where they're used within the password + * manager. Extension profiles store their data + * using the extension system. + */ +export type ExtensionProfileMetadata = { + /** distinguishes profile metadata types */ + type: "extension"; + + /** The extension site described by this metadata */ + site: Site; + + constraints: ProfileConstraints; +}; + +/** Generator profiles partition generator operations + * according to where they're used within the password + * manager + */ +export type ProfileMetadata = + | CoreProfileMetadata + | ExtensionProfileMetadata; diff --git a/libs/tools/generator/core/src/metadata/type.ts b/libs/tools/generator/core/src/metadata/type.ts new file mode 100644 index 00000000000..924b92883e5 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/type.ts @@ -0,0 +1,28 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; + +import { AlgorithmsByType, Profile, Type } from "./data"; + +/** categorizes credentials according to their use-case outside of Bitwarden */ +export type CredentialType = keyof typeof Type; + +/** categorizes credentials according to their expected use-case within Bitwarden */ +export type GeneratorProfile = keyof typeof Profile; + +/** A type of password that may be generated by the credential generator. */ +export type PasswordAlgorithm = (typeof AlgorithmsByType.password)[number]; + +/** A type of username that may be generated by the credential generator. */ +export type UsernameAlgorithm = (typeof AlgorithmsByType.username)[number]; + +/** A type of email address that may be generated by the credential generator. */ +export type EmailAlgorithm = (typeof AlgorithmsByType.email)[number] | ForwarderExtensionId; + +/** Identifies a forwarding service */ +export type ForwarderExtensionId = { forwarder: VendorId }; + +/** A type of credential that can be generated by the credential generator. */ +// this is defined in terms of `AlgorithmsByType` to typecheck the keys of +// `AlgorithmsByType` against the keys of `CredentialType`. +export type CredentialAlgorithm = + | (typeof AlgorithmsByType)[CredentialType][number] + | ForwarderExtensionId; diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts new file mode 100644 index 00000000000..aba9680a448 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts @@ -0,0 +1,58 @@ +import { mock } from "jest-mock-extended"; + +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; + +import { UsernameRandomizer } from "../../engine"; +import { EffUsernameGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { Profile } from "../data"; +import { CoreProfileMetadata } from "../profile-metadata"; +import { isCoreProfile } from "../util"; + +import effWordList from "./eff-word-list"; + +const dependencyProvider = mock(); + +describe("username - eff words generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(effWordList.engine.create(dependencyProvider)).toBeInstanceOf(UsernameRandomizer); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata = null; + beforeEach(() => { + const profile = effWordList.profiles[Profile.account]; + if (isCoreProfile(profile)) { + accountProfile = profile; + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: EffUsernameGenerationOptions = { + wordCapitalize: true, + wordIncludeNumber: true, + }; + + const result = accountProfile.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a effWordList constraints", () => { + const context = { defaultConstraints: {} }; + + const constraints = accountProfile.constraints.create([], context); + + expect(constraints).toBeInstanceOf(IdentityConstraint); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts new file mode 100644 index 00000000000..6373daf8ed5 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts @@ -0,0 +1,70 @@ +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { UsernameRandomizer } from "../../engine"; +import { + CredentialGenerator, + EffUsernameGenerationOptions, + GeneratorDependencyProvider, +} from "../../types"; +import { Algorithm, Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const effWordList: GeneratorMetadata = deepFreeze({ + id: Algorithm.username, + category: Type.username, + weight: 400, + i18nKeys: { + name: "randomWord", + credentialType: "username", + generateCredential: "generateUsername", + credentialGenerated: "usernameGenerated", + copyCredential: "copyUsername", + useCredential: "useThisUsername", + }, + capabilities: { + autogenerate: true, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new UsernameRandomizer(dependencies.randomizer); + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "effUsernameGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "wordCapitalize", + "wordIncludeNumber", + ]), + state: GENERATOR_DISK, + initial: { + wordCapitalize: false, + wordIncludeNumber: false, + website: null, + }, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + }, + constraints: { + default: {}, + create(_policies, _context) { + return new IdentityConstraint(); + }, + }, + }, + }, +}); + +export default effWordList; diff --git a/libs/tools/generator/core/src/metadata/util.spec.ts b/libs/tools/generator/core/src/metadata/util.spec.ts new file mode 100644 index 00000000000..2283699140b --- /dev/null +++ b/libs/tools/generator/core/src/metadata/util.spec.ts @@ -0,0 +1,218 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; + +import { Algorithm, AlgorithmsByType } from "./data"; +import { ProfileMetadata } from "./profile-metadata"; +import { + isPasswordAlgorithm, + isUsernameAlgorithm, + isForwarderExtensionId, + isEmailAlgorithm, + isSameAlgorithm, + isCoreProfile, + isForwarderProfile, +} from "./util"; + +describe("credential generator metadata utility functions", () => { + describe("isPasswordAlgorithm", () => { + it("returns `true` when the algorithm is a password algorithm", () => { + for (const algorithm of AlgorithmsByType.password) { + expect(isPasswordAlgorithm(algorithm)).toBe(true); + } + }); + + it("returns `false` when the algorithm is an email algorithm", () => { + for (const algorithm of AlgorithmsByType.email) { + expect(isPasswordAlgorithm(algorithm)).toBe(false); + } + }); + + it("returns `false` when the algorithm is a username algorithm", () => { + for (const algorithm of AlgorithmsByType.username) { + expect(isPasswordAlgorithm(algorithm)).toBe(false); + } + }); + + it("returns `false` when the algorithm is a forwarder extension", () => { + expect(isPasswordAlgorithm({ forwarder: "bitwarden" as VendorId })).toBe(false); + }); + }); + + describe("isUsernameAlgorithm", () => { + it("returns `false` when the algorithm is a password algorithm", () => { + for (const algorithm of AlgorithmsByType.password) { + expect(isUsernameAlgorithm(algorithm)).toBe(false); + } + }); + + it("returns `false` when the algorithm is an email algorithm", () => { + for (const algorithm of AlgorithmsByType.email) { + expect(isUsernameAlgorithm(algorithm)).toBe(false); + } + }); + + it("returns `true` when the algorithm is a username algorithm", () => { + for (const algorithm of AlgorithmsByType.username) { + expect(isUsernameAlgorithm(algorithm)).toBe(true); + } + }); + + it("returns `false` when the algorithm is a forwarder extension", () => { + expect(isUsernameAlgorithm({ forwarder: "bitwarden" as VendorId })).toBe(false); + }); + }); + + describe("isForwarderExtensionId", () => { + it("returns `false` when the algorithm is a password algorithm", () => { + for (const algorithm of AlgorithmsByType.password) { + expect(isForwarderExtensionId(algorithm)).toBe(false); + } + }); + + it("returns `false` when the algorithm is an email algorithm", () => { + for (const algorithm of AlgorithmsByType.email) { + expect(isForwarderExtensionId(algorithm)).toBe(false); + } + }); + + it("returns `false` when the algorithm is a username algorithm", () => { + for (const algorithm of AlgorithmsByType.username) { + expect(isForwarderExtensionId(algorithm)).toBe(false); + } + }); + + it("returns `true` when the algorithm is a forwarder extension", () => { + expect(isForwarderExtensionId({ forwarder: "bitwarden" as VendorId })).toBe(true); + }); + }); + + describe("isEmailAlgorithm", () => { + it("returns `false` when the algorithm is a password algorithm", () => { + for (const algorithm of AlgorithmsByType.password) { + expect(isEmailAlgorithm(algorithm)).toBe(false); + } + }); + + it("returns `true` when the algorithm is an email algorithm", () => { + for (const algorithm of AlgorithmsByType.email) { + expect(isEmailAlgorithm(algorithm)).toBe(true); + } + }); + + it("returns `false` when the algorithm is a username algorithm", () => { + for (const algorithm of AlgorithmsByType.username) { + expect(isEmailAlgorithm(algorithm)).toBe(false); + } + }); + + it("returns `true` when the algorithm is a forwarder extension", () => { + expect(isEmailAlgorithm({ forwarder: "bitwarden" as VendorId })).toBe(true); + }); + }); + + describe("isSameAlgorithm", () => { + it("returns `true` when the algorithms are equal", () => { + // identical object + expect(isSameAlgorithm(Algorithm.catchall, Algorithm.catchall)).toBe(true); + + // equal object + expect(isSameAlgorithm(Algorithm.catchall, `${Algorithm.catchall}`)).toBe(true); + }); + + it("returns `false` when the algorithms are different", () => { + // not an exhaustive list + expect(isSameAlgorithm(Algorithm.catchall, Algorithm.passphrase)).toBe(false); + expect(isSameAlgorithm(Algorithm.passphrase, Algorithm.password)).toBe(false); + expect(isSameAlgorithm(Algorithm.password, Algorithm.plusAddress)).toBe(false); + expect(isSameAlgorithm(Algorithm.plusAddress, Algorithm.username)).toBe(false); + expect(isSameAlgorithm(Algorithm.username, Algorithm.passphrase)).toBe(false); + }); + + it("returns `true` when the algorithms refer to a forwarder with a matching vendor", () => { + const someVendor = { forwarder: "bitwarden" as VendorId }; + const sameVendor = { forwarder: "bitwarden" as VendorId }; + expect(isSameAlgorithm(someVendor, sameVendor)).toBe(true); + }); + + it("returns `false` when the algorithms refer to a forwarder with a different vendor", () => { + const someVendor = { forwarder: "bitwarden" as VendorId }; + const sameVendor = { forwarder: "bytewarden" as VendorId }; + expect(isSameAlgorithm(someVendor, sameVendor)).toBe(false); + }); + + it("returns `false` when the algorithms refer to a forwarder and a core algorithm", () => { + const someVendor = { forwarder: "bitwarden" as VendorId }; + // not an exhaustive list + expect(isSameAlgorithm(someVendor, Algorithm.plusAddress)).toBe(false); + expect(isSameAlgorithm(Algorithm.username, someVendor)).toBe(false); + }); + }); + + describe("isCoreProfile", () => { + it("returns `true` when the profile's type is `core`", () => { + const profile: ProfileMetadata = { + type: "core", + storage: null, + constraints: { + default: {}, + create: () => null, + }, + }; + + expect(isCoreProfile(profile)).toBe(true); + }); + + it("returns `false` when the profile's type is `extension`", () => { + const profile: ProfileMetadata = { + type: "extension", + site: "forwarder", + constraints: { + default: {}, + create: () => null, + }, + }; + + expect(isCoreProfile(profile)).toBe(false); + }); + }); + + describe("isForwarderProfile", () => { + it("returns `false` when the profile's type is `core`", () => { + const profile: ProfileMetadata = { + type: "core", + storage: null, + constraints: { + default: {}, + create: () => null, + }, + }; + + expect(isForwarderProfile(profile)).toBe(false); + }); + + it("returns `true` when the profile's type is `extension` and the site is `forwarder`", () => { + const profile: ProfileMetadata = { + type: "extension", + site: "forwarder", + constraints: { + default: {}, + create: () => null, + }, + }; + + expect(isForwarderProfile(profile)).toBe(true); + }); + + it("returns `false` when the profile's type is `extension` and the site is not `forwarder`", () => { + const profile: ProfileMetadata = { + type: "extension", + site: "not-a-forwarder" as any, + constraints: { + default: {}, + create: () => null, + }, + }; + + expect(isForwarderProfile(profile)).toBe(false); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/util.ts b/libs/tools/generator/core/src/metadata/util.ts new file mode 100644 index 00000000000..e85061720ad --- /dev/null +++ b/libs/tools/generator/core/src/metadata/util.ts @@ -0,0 +1,60 @@ +import { AlgorithmsByType } from "./data"; +import { CoreProfileMetadata, ExtensionProfileMetadata, ProfileMetadata } from "./profile-metadata"; +import { + CredentialAlgorithm, + EmailAlgorithm, + ForwarderExtensionId, + PasswordAlgorithm, + UsernameAlgorithm, +} from "./type"; + +/** Returns true when the input algorithm is a password algorithm. */ +export function isPasswordAlgorithm( + algorithm: CredentialAlgorithm, +): algorithm is PasswordAlgorithm { + return AlgorithmsByType.password.includes(algorithm as any); +} + +/** Returns true when the input algorithm is a username algorithm. */ +export function isUsernameAlgorithm( + algorithm: CredentialAlgorithm, +): algorithm is UsernameAlgorithm { + return AlgorithmsByType.username.includes(algorithm as any); +} + +/** Returns true when the input algorithm is a forwarder integration. */ +export function isForwarderExtensionId( + algorithm: CredentialAlgorithm, +): algorithm is ForwarderExtensionId { + return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; +} + +/** Returns true when the input algorithm is an email algorithm. */ +export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { + return AlgorithmsByType.email.includes(algorithm as any) || isForwarderExtensionId(algorithm); +} + +/** Returns true when the algorithms are the same. */ +export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) { + if (lhs === rhs) { + return true; + } else if (isForwarderExtensionId(lhs) && isForwarderExtensionId(rhs)) { + return lhs.forwarder === rhs.forwarder; + } else { + return false; + } +} + +/** Returns true when the input describes a core profile. */ +export function isCoreProfile( + value: ProfileMetadata, +): value is CoreProfileMetadata { + return value.type === "core"; +} + +/** Returns true when the input describes a forwarder extension profile. */ +export function isForwarderProfile( + value: ProfileMetadata, +): value is ExtensionProfileMetadata { + return value.type === "extension" && value.site === "forwarder"; +} diff --git a/libs/tools/generator/core/src/policies/catchall-constraints.ts b/libs/tools/generator/core/src/policies/catchall-constraints.ts index 47476a304a9..7793180988d 100644 --- a/libs/tools/generator/core/src/policies/catchall-constraints.ts +++ b/libs/tools/generator/core/src/policies/catchall-constraints.ts @@ -24,7 +24,7 @@ export class CatchallConstraints implements StateConstraints> = {}; diff --git a/libs/tools/generator/core/src/types/password-generation-options.ts b/libs/tools/generator/core/src/types/password-generation-options.ts index 76e8827d4de..7a8a538c409 100644 --- a/libs/tools/generator/core/src/types/password-generation-options.ts +++ b/libs/tools/generator/core/src/types/password-generation-options.ts @@ -2,58 +2,58 @@ */ export type PasswordGeneratorSettings = { /** The length of the password selected by the user */ - length: number; + length?: number; /** `true` when ambiguous characters may be included in the output. * `false` when ambiguous characters should not be included in the output. */ - ambiguous: boolean; + ambiguous?: boolean; /** `true` when uppercase ASCII characters should be included in the output * This value defaults to `false. */ - uppercase: boolean; + uppercase?: boolean; /** The minimum number of uppercase characters to include in the output. * The value is ignored when `uppercase` is `false`. * The value defaults to 1 when `uppercase` is `true`. */ - minUppercase: number; + minUppercase?: number; /** `true` when lowercase ASCII characters should be included in the output. * This value defaults to `false`. */ - lowercase: boolean; + lowercase?: boolean; /** The minimum number of lowercase characters to include in the output. * The value defaults to 1 when `lowercase` is `true`. * The value defaults to 0 when `lowercase` is `false`. */ - minLowercase: number; + minLowercase?: number; /** Whether or not to include ASCII digits in the output * This value defaults to `true` when `minNumber` is at least 1. * This value defaults to `false` when `minNumber` is less than 1. */ - number: boolean; + number?: boolean; /** The minimum number of digits to include in the output. * The value defaults to 1 when `number` is `true`. * The value defaults to 0 when `number` is `false`. */ - minNumber: number; + minNumber?: number; /** Whether or not to include special characters in the output. * This value defaults to `true` when `minSpecial` is at least 1. * This value defaults to `false` when `minSpecial` is less than 1. */ - special: boolean; + special?: boolean; /** The minimum number of special characters to include in the output. * This value defaults to 1 when `special` is `true`. * This value defaults to 0 when `special` is `false`. */ - minSpecial: number; + minSpecial?: number; }; /** Request format for password credential generation. diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts index 98c2e8ab283..4b6041ffeba 100644 --- a/libs/tools/generator/core/src/util.ts +++ b/libs/tools/generator/core/src/util.ts @@ -107,7 +107,7 @@ export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions) DefaultPasswordGenerationOptions.special, DefaultPasswordGenerationOptions.minSpecial, ), - ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous, + ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous!, all: 0, }; diff --git a/libs/tools/generator/core/tsconfig.json b/libs/tools/generator/core/tsconfig.json index 7c703686b20..a95b588686f 100644 --- a/libs/tools/generator/core/tsconfig.json +++ b/libs/tools/generator/core/tsconfig.json @@ -5,7 +5,6 @@ "@bitwarden/admin-console/common": ["../../../admin-console/src/common"], "@bitwarden/auth/common": ["../../../auth/src/common"], "@bitwarden/common/*": ["../../../common/src/*"], - "@bitwarden/generator-core": ["../../../tools/generator/core/src"], "@bitwarden/key-management": ["../../../key-management/src"] } }, diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index d7755546365..d244be15087 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -18,12 +18,12 @@ {{ send.name }} diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 4bd87a7869d..f872ad0cf15 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -101,8 +101,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy { return false; } - const { username, password, totp } = this.cipher.login; - return username || password || totp; + const { username, password, totp, fido2Credentials } = this.cipher.login; + return username || password || totp || fido2Credentials; } get hasAutofill() { diff --git a/package-lock.json b/package-lock.json index 5366b26861b..12a1468e3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,11 +38,11 @@ "bootstrap": "4.6.0", "braintree-web-drop-in": "1.43.0", "buffer": "6.0.3", - "bufferutil": "4.0.8", + "bufferutil": "4.0.9", "chalk": "4.1.2", "commander": "11.1.0", "core-js": "3.39.0", - "form-data": "4.0.0", + "form-data": "4.0.1", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", "jquery": "3.7.1", @@ -98,6 +98,7 @@ "@storybook/angular": "8.4.7", "@storybook/manager-api": "8.4.7", "@storybook/theming": "8.4.7", + "@storybook/web-components-webpack5": "8.4.7", "@types/argon2-browser": "1.18.4", "@types/chrome": "0.0.280", "@types/firefox-webext-browser": "120.0.4", @@ -120,8 +121,8 @@ "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", - "@typescript-eslint/eslint-plugin": "8.19.1", - "@typescript-eslint/parser": "8.19.1", + "@typescript-eslint/eslint-plugin": "8.20.0", + "@typescript-eslint/parser": "8.20.0", "@webcomponents/custom-elements": "1.6.0", "@yao-pkg/pkg": "5.16.1", "autoprefixer": "10.4.20", @@ -139,23 +140,23 @@ "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.3.9", - "eslint": "8.57.0", + "eslint": "8.57.1", "eslint-config-prettier": "9.1.0", - "eslint-import-resolver-typescript": "3.6.1", - "eslint-plugin-import": "2.29.1", + "eslint-import-resolver-typescript": "3.7.0", + "eslint-plugin-import": "2.31.0", "eslint-plugin-rxjs": "5.0.3", "eslint-plugin-rxjs-angular": "2.0.1", - "eslint-plugin-storybook": "0.8.0", - "eslint-plugin-tailwindcss": "3.17.4", + "eslint-plugin-storybook": "0.11.2", + "eslint-plugin-tailwindcss": "3.17.5", "html-loader": "5.1.0", "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", - "husky": "9.1.4", - "jest-extended": "4.0.2", + "husky": "9.1.7", + "jest-diff": "29.7.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", - "lint-staged": "15.2.8", + "lint-staged": "15.4.0", "mini-css-extract-plugin": "2.9.2", "node-ipc": "9.2.1", "postcss": "8.4.49", @@ -205,7 +206,7 @@ "browser-hrtime": "1.1.8", "chalk": "4.1.2", "commander": "11.1.0", - "form-data": "4.0.0", + "form-data": "4.0.1", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", "jsdom": "25.0.1", @@ -231,7 +232,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.1.1", + "version": "2025.1.3", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -949,18 +950,6 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/webpack": { "version": "5.94.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", @@ -1323,18 +1312,6 @@ "node": ">= 4" } }, - "node_modules/@angular-eslint/schematics/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@angular-eslint/template-parser": { "version": "18.4.3", "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-18.4.3.tgz", @@ -1852,18 +1829,6 @@ "node": ">=14.0.0" } }, - "node_modules/@angular/build/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@angular/build/node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -2059,18 +2024,6 @@ "node": ">=18.0.0" } }, - "node_modules/@angular/cli/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@angular/cli/node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -2713,9 +2666,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5041,19 +4994,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@compodoc/compodoc/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@compodoc/compodoc/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -5904,9 +5844,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { @@ -6012,14 +5952,14 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -7498,6 +7438,16 @@ "node": ">= 8" } }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, "node_modules/@npmcli/agent": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", @@ -7989,6 +7939,13 @@ "darwin" ] }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@schematics/angular": { "version": "18.2.12", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.12.tgz", @@ -8856,19 +8813,6 @@ "storybook": "^8.4.7" } }, - "node_modules/@storybook/core/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@storybook/csf": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.11.tgz", @@ -9017,6 +8961,56 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, + "node_modules/@storybook/web-components": { + "version": "8.4.7", + "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-8.4.7.tgz", + "integrity": "sha512-zR/bUWGkS5uxvqfXnW082ScrC4y5UrTdE1VKasezLGi5bTLub2hz8JP87PJgtWrq+mdrdmkLGzv5O4iJ/tlMAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/components": "8.4.7", + "@storybook/global": "^5.0.0", + "@storybook/manager-api": "8.4.7", + "@storybook/preview-api": "8.4.7", + "@storybook/theming": "8.4.7", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "lit": "^2.0.0 || ^3.0.0", + "storybook": "^8.4.7" + } + }, + "node_modules/@storybook/web-components-webpack5": { + "version": "8.4.7", + "resolved": "https://registry.npmjs.org/@storybook/web-components-webpack5/-/web-components-webpack5-8.4.7.tgz", + "integrity": "sha512-RgLFQB7F4FOX5nOK3byaCo5Gs8nKMq1uNswOXdHSgZKfJfaZxmyMMGmnVUmOOLECsxyREokHwRDKma8SgFrRRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/builder-webpack5": "8.4.7", + "@storybook/web-components": "8.4.7", + "@types/node": "^22.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "lit": "^2.0.0 || ^3.0.0", + "storybook": "^8.4.7" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -10082,17 +10076,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", - "integrity": "sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", + "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/type-utils": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/scope-manager": "8.20.0", + "@typescript-eslint/type-utils": "8.20.0", + "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -10111,67 +10105,6 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.1.tgz", - "integrity": "sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", - "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/@typescript-eslint/experimental-utils": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", @@ -10322,16 +10255,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.1.tgz", - "integrity": "sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", + "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/scope-manager": "8.20.0", + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/typescript-estree": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0", "debug": "^4.3.4" }, "engines": { @@ -10347,14 +10280,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", - "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", + "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1" + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10364,10 +10297,34 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", + "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.20.0", + "@typescript-eslint/utils": "8.20.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", - "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", + "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", "dev": true, "license": "MIT", "engines": { @@ -10379,14 +10336,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", - "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", + "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -10405,134 +10362,38 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz", - "integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", + "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.11.0", - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/typescript-estree": "7.11.0" + "@typescript-eslint/scope-manager": "8.20.0", + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/typescript-estree": "8.20.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz", - "integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/visitor-keys": "7.11.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz", - "integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz", - "integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/visitor-keys": "7.11.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz", - "integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "7.11.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", - "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", + "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/types": "8.20.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -12728,9 +12589,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", - "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -14610,9 +14471,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -15571,19 +15432,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/electron-updater/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/electron/node_modules/@types/node": { "version": "20.17.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.12.tgz", @@ -16040,9 +15888,9 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", @@ -16050,8 +15898,8 @@ "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -16148,19 +15996,20 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", - "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.7.0.tgz", + "integrity": "sha512-Vrwyi8HHxY97K5ebydMtffsWAn1SCR9eol49eCd5fJS4O1WV7PaAjbcjmbfJJSMz/t4Mal212Uz/fQZrOB8mow==", "dev": true, "license": "ISC", "dependencies": { - "debug": "^4.3.4", - "enhanced-resolve": "^5.12.0", - "eslint-module-utils": "^2.7.4", - "fast-glob": "^3.3.1", - "get-tsconfig": "^4.5.0", - "is-core-module": "^2.11.0", - "is-glob": "^4.0.3" + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.3.7", + "enhanced-resolve": "^5.15.0", + "fast-glob": "^3.3.2", + "get-tsconfig": "^4.7.5", + "is-bun-module": "^1.0.2", + "is-glob": "^4.0.3", + "stable-hash": "^0.0.4" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -16170,7 +16019,16 @@ }, "peerDependencies": { "eslint": "*", - "eslint-plugin-import": "*" + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } } }, "node_modules/eslint-module-utils": { @@ -16202,35 +16060,37 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { @@ -16331,167 +16191,27 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-0.8.0.tgz", - "integrity": "sha512-CZeVO5EzmPY7qghO2t64oaFM+8FTaD4uzOEjHKp516exyTKo+skKAL9GI3QALS2BXhyALJjNtwbmr1XinGE8bA==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-0.11.2.tgz", + "integrity": "sha512-0Z4DUklJrC+GHjCRXa7PYfPzWC15DaVnwaOYenpgXiCEijXPZkLKCms+rHhtoRcWccP7Z8DpOOaP1gc3P9oOwg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf": "^0.0.1", - "@typescript-eslint/utils": "^5.62.0", - "requireindex": "^1.2.0", + "@storybook/csf": "^0.1.11", + "@typescript-eslint/utils": "^8.8.1", "ts-dedent": "^2.2.0" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "eslint": ">=6" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/@storybook/csf": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.1.tgz", - "integrity": "sha512-USTLkZze5gkel8MYCujSRBVIrUQ3YPBrLOx7GNk/0wttvVtlzWXAq9eLbQ4p/NicGxP+3T7KPEMVV//g+yubpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-plugin-storybook/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" + "eslint": ">=8" } }, "node_modules/eslint-plugin-tailwindcss": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.17.4.tgz", - "integrity": "sha512-gJAEHmCq2XFfUP/+vwEfEJ9igrPeZFg+skeMtsxquSQdxba9XRk5bn0Bp9jxG1VV9/wwPKi1g3ZjItu6MIjhNg==", + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.17.5.tgz", + "integrity": "sha512-8Mi7p7dm+mO1dHgRHHFdPu4RDTBk69Cn4P0B40vRQR+MrguUpwmKwhZy1kqYe3Km8/4nb+cyrCF+5SodOEmaow==", "dev": true, "license": "MIT", "dependencies": { @@ -17764,9 +17484,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -18921,9 +18641,9 @@ } }, "node_modules/husky": { - "version": "9.1.4", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.4.tgz", - "integrity": "sha512-bho94YyReb4JV7LYWRWxZ/xr6TtOTt8cMfmQ39MQYJ7f/YE268s3GdghGwi+y4zAeqewE5zYLvuhV0M0ijsDEA==", + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "license": "MIT", "bin": { @@ -19453,6 +19173,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-1.3.0.tgz", + "integrity": "sha512-DgXeu5UWI0IsMQundYb5UAOzm6G2eVnarJ0byP6Tm55iZNKceD59LNPA2L4VvsScTtHcw0yEkVwSf7PC+QoLSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -20729,28 +20459,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-extended": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", - "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-diff": "^29.0.0", - "jest-get-type": "^29.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "jest": ">=27.2.5" - }, - "peerDependenciesMeta": { - "jest": { - "optional": true - } - } - }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -22309,22 +22017,22 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "15.2.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.8.tgz", - "integrity": "sha512-PUWFf2zQzsd9EFU+kM1d7UP+AZDbKFKuj+9JNVTBkhUFhbg4MAt6WfyMMwBfM4lYqd4D2Jwac5iuTu9rVj4zCQ==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.0.tgz", + "integrity": "sha512-UdODqEZiQimd7rCzZ2vqFuELRNUda3mdv7M93jhE4SmDiqAj/w/msvwKgagH23jv2iCPw6Q5m+ltX4VlHvp2LQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "~5.3.0", + "chalk": "~5.4.1", "commander": "~12.1.0", - "debug": "~4.3.6", + "debug": "~4.4.0", "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.7", + "lilconfig": "~3.1.3", + "listr2": "~8.2.5", + "micromatch": "~4.0.8", "pidtree": "~0.6.0", "string-argv": "~0.3.2", - "yaml": "~2.5.0" + "yaml": "~2.6.1" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -22337,9 +22045,9 @@ } }, "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "dev": true, "license": "MIT", "engines": { @@ -28973,13 +28681,10 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -28995,24 +28700,6 @@ "license": "MIT", "optional": true }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/send": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", @@ -29734,6 +29421,13 @@ "dev": true, "license": "ISC" }, + "node_modules/stable-hash": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz", + "integrity": "sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==", + "dev": true, + "license": "MIT" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -30879,16 +30573,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", - "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", + "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-dedent": { @@ -33121,9 +32815,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 1090d3efb33..8b692a57ac9 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@storybook/angular": "8.4.7", "@storybook/manager-api": "8.4.7", "@storybook/theming": "8.4.7", + "@storybook/web-components-webpack5": "8.4.7", "@types/argon2-browser": "1.18.4", "@types/chrome": "0.0.280", "@types/firefox-webext-browser": "120.0.4", @@ -80,8 +81,8 @@ "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", - "@typescript-eslint/eslint-plugin": "8.19.1", - "@typescript-eslint/parser": "8.19.1", + "@typescript-eslint/eslint-plugin": "8.20.0", + "@typescript-eslint/parser": "8.20.0", "@webcomponents/custom-elements": "1.6.0", "@yao-pkg/pkg": "5.16.1", "autoprefixer": "10.4.20", @@ -99,23 +100,23 @@ "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.3.9", - "eslint": "8.57.0", + "eslint": "8.57.1", "eslint-config-prettier": "9.1.0", - "eslint-import-resolver-typescript": "3.6.1", - "eslint-plugin-import": "2.29.1", + "eslint-import-resolver-typescript": "3.7.0", + "eslint-plugin-import": "2.31.0", "eslint-plugin-rxjs": "5.0.3", "eslint-plugin-rxjs-angular": "2.0.1", - "eslint-plugin-storybook": "0.8.0", - "eslint-plugin-tailwindcss": "3.17.4", + "eslint-plugin-storybook": "0.11.2", + "eslint-plugin-tailwindcss": "3.17.5", "html-loader": "5.1.0", "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", - "husky": "9.1.4", - "jest-extended": "4.0.2", + "husky": "9.1.7", + "jest-diff": "29.7.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", - "lint-staged": "15.2.8", + "lint-staged": "15.4.0", "mini-css-extract-plugin": "2.9.2", "node-ipc": "9.2.1", "postcss": "8.4.49", @@ -168,11 +169,11 @@ "bootstrap": "4.6.0", "braintree-web-drop-in": "1.43.0", "buffer": "6.0.3", - "bufferutil": "4.0.8", + "bufferutil": "4.0.9", "chalk": "4.1.2", "commander": "11.1.0", "core-js": "3.39.0", - "form-data": "4.0.0", + "form-data": "4.0.1", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", "jquery": "3.7.1", diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 941a612a30c..a69452389f5 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -36,6 +36,8 @@ "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/vault": ["./libs/vault/src"], + "@bitwarden/key-management": ["./libs/key-management/src"], + "@bitwarden/key-management/angular": ["./libs/key-management/src/angular"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"] }, "plugins": [