1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 14:04:03 +00:00

Merge branch 'main' into km/test-arm64

This commit is contained in:
Bernd Schoolmann
2025-01-16 21:59:12 +01:00
committed by GitHub
168 changed files with 5653 additions and 1126 deletions

51
.github/renovate.json vendored
View File

@@ -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",

View File

@@ -1,4 +1,5 @@
name: Staged Rollout Desktop
run-name: Staged Rollout Desktop - ${{ inputs.rollout_percentage }}%
on:
workflow_dispatch:

View File

@@ -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"

View File

@@ -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"
},

View File

@@ -20,7 +20,7 @@
{{ biometricUnavailabilityReason }}
</bit-hint>
</bit-form-control>
<bit-form-control class="tw-pl-5" *ngIf="this.form.value.biometric">
<bit-form-control class="tw-pl-5" *ngIf="this.form.value.biometric && showAutoPrompt">
<input
bitCheckbox
id="autoBiometricsPrompt"

View File

@@ -29,6 +29,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -106,6 +107,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
hasVaultTimeoutPolicy = false;
biometricUnavailabilityReason: string;
showChangeMasterPass = true;
showAutoPrompt = true;
form = this.formBuilder.group({
vaultTimeout: [null as VaultTimeout | null],
@@ -141,6 +143,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
// Firefox popup closes when unfocused by biometrics, blocking all unlock methods
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
this.showAutoPrompt = false;
}
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.showMasterPasswordOnClientRestartOption = hasMasterPassword;
const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout);
@@ -514,6 +521,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
try {
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
result = await this.keyService.validateUserKey(userKey, userId);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
result = false;
}

View File

@@ -0,0 +1,67 @@
import { dirname, join } from "path";
import path from "path";
import type { StorybookConfig } from "@storybook/web-components-webpack5";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
import remarkGfm from "remark-gfm";
const getAbsolutePath = (value: string): string =>
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;

View File

@@ -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<Args>;
const Template = (args: Args) => ActionButton({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => BadgeButton({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => CloseButton({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => EditButton({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => CipherAction({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => {
return html`
<div style="width: ${args.size}; height: ${args.size}; overflow: hidden;">
${CipherIcon({ ...args })}
</div>
`;
};
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template: StoryObj<Args>["render"] = (args) =>
html`<div>${CipherInfoIndicatorIcons({ ...args })}</div>`;
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args, IconComponent: (props: Args) => ReturnType<typeof html>) => html`
<div
style="width: ${args.size}px; height: ${args.size}px; display: flex; align-items: center; justify-content: center;"
>
${IconComponent({ ...args })}
</div>
`;
const createIconStory = (iconName: keyof typeof Icons): StoryObj<Args> => {
const story = {
render: (args) => Template(args, Icons[iconName]),
} as StoryObj<Args>;
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");

View File

@@ -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<Args>;
const Template = (args: Args) => NotificationBody({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => NotificationFooter({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => NotificationHeader({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => ActionRow({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => ButtonRow({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -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<Args>;
const Template = (args: Args) => ItemRow({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
};

View File

@@ -0,0 +1,7 @@
{
"name": "@bitwarden/lit-components",
"version": "2025.1.1",
"scripts": {
"storybook:lit": "storybook dev -p 6006 -c ./.lit-storybook"
}
}

View File

@@ -665,6 +665,7 @@ export default class MainBackground {
this.logService,
this.keyService,
this.biometricStateService,
this.messagingService,
);
this.appIdService = new AppIdService(this.storageService, this.logService);

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -1,7 +1,7 @@
<app-vault-list-items-container
*ngIf="autofillCiphers$ | async as ciphers"
[ciphers]="ciphers"
[title]="'itemSuggestions' | i18n"
[title]="((currentURIIsBlocked$ | async) ? 'itemSuggestions' : 'autofillSuggestions') | i18n"
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null"

View File

@@ -65,6 +65,12 @@ export class AutofillVaultListItemsComponent implements OnInit {
),
);
/**
* Flag indicating that the current tab location is blocked
*/
currentURIIsBlocked$: Observable<boolean> =
this.vaultPopupAutofillService.currentTabIsOnBlocklist$;
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupAutofillService: VaultPopupAutofillService,

View File

@@ -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();
});

View File

@@ -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`
<svg xmlns="http://www.w3.org/2000/svg" width="86" height="74" fill="none">
<g fill-rule="evenodd" clip-path="url(#a)" clip-rule="evenodd">
<path class="tw-fill-art-primary" d="m17.477 51.274 2.472 17.441a3.779 3.779 0 0 0 4.583 3.154l1.497-.342a3.779 3.779 0 0 0 2.759-4.831L23.44 49.91l1.8-.573 5.348 16.784a5.668 5.668 0 0 1-4.138 7.247l-1.497.341a5.668 5.668 0 0 1-6.874-4.73l-2.473-17.44 1.871-.266Z"/>
<path class="tw-fill-art-accent" d="m55.063 27.1-1.38.316-.211-.92 1.381-.316a3.306 3.306 0 0 1 3.96 2.486l1.052 4.605a3.306 3.306 0 0 1-2.487 3.96l-.92.21-.211-.92.92-.211a2.362 2.362 0 0 0 1.777-2.828l-1.052-4.605a2.362 2.362 0 0 0-2.829-1.777Z"/>
<path class="tw-fill-art-primary" d="M49.79 12.5a.18.18 0 0 0-.272-.11L21.855 29.438a.181.181 0 0 0-.058.055l-.208.323-10.947 2.5a.457.457 0 0 0-.139.064.664.664 0 0 0-.15.135.343.343 0 0 0-.06.095l.499 2.182-4.36.996c-1.873.428-3.086 2.465-2.64 4.417l1.5 6.566c.446 1.951 2.423 3.26 4.296 2.832l4.36-.996.499 2.182c.012.012.04.034.095.06a.658.658 0 0 0 .194.055c.07.009.122.004.152-.003l10.947-2.501.328.2a.18.18 0 0 0 .075.025l32.324 3.344a.18.18 0 0 0 .196-.218L49.79 12.5Zm-1.263-1.72a2.07 2.07 0 0 1 3.104 1.299L60.6 51.332a2.07 2.07 0 0 1-2.233 2.517l-32.323-3.343a2.072 2.072 0 0 1-.474-.106l-10.26 2.344a2.474 2.474 0 0 1-1.571-.184c-.463-.217-.973-.643-1.127-1.32l-.085-.37-2.518.576c-2.975.68-5.9-1.37-6.559-4.253l-1.5-6.566c-.659-2.883 1.086-6 4.061-6.68l2.518-.575-.084-.37c-.155-.677.12-1.282.442-1.678.325-.4.803-.727 1.334-.848l10.262-2.345c.113-.113.24-.214.38-.3l27.664-17.05Z"/>
<path class="tw-fill-art-accent" d="m10.792 34.793 3.156 13.814-.92.21L9.87 35.004l.921-.21ZM21.59 29.817l4.246 18.578-.508.12-.512.118L20.68 30.02l.91-.203Z"/>
<path class="tw-fill-art-primary" d="M64.287.59A.945.945 0 0 1 65.58.248c8.784 5.11 15.628 14.039 18.166 25.145 2.537 11.105.25 22.12-5.443 30.538a.945.945 0 0 1-1.565-1.059c5.398-7.98 7.587-18.46 5.166-29.058C79.48 15.215 72.958 6.726 64.629 1.882A.945.945 0 0 1 64.287.59Z"/>
<path class="tw-fill-art-accent" d="M61.6 6.385a.472.472 0 0 1 .643-.18c7.245 4.067 12.949 11.44 15.055 20.66s.171 18.338-4.588 25.149a.472.472 0 0 1-.774-.542c4.603-6.587 6.49-15.431 4.441-24.397-2.048-8.965-7.59-16.113-14.596-20.047a.472.472 0 0 1-.18-.643Z"/>
<path class="tw-fill-art-primary" d="M57.804 11.193a.472.472 0 0 1 .604-.285c6.11 2.186 11.426 8.739 13.364 17.22 1.938 8.48-.006 16.693-4.56 21.315a.472.472 0 1 1-.672-.663c4.27-4.335 6.197-12.187 4.311-20.442-1.886-8.254-7.032-14.49-12.761-16.54a.472.472 0 0 1-.286-.605Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h86v74H0z"/>
</clipPath>
</defs>
</svg>
`;
@Component({
standalone: true,
selector: "app-vault-ui-onboarding",
template: `
<bit-simple-dialog>
<div bitDialogIcon>
<bit-icon [icon]="icon"></bit-icon>
</div>
<span bitDialogTitle>
{{ "bitwardenNewLook" | i18n }}
</span>
<span bitDialogContent>
{{ "bitwardenNewLookDesc" | i18n }}
</span>
<ng-container bitDialogFooter>
<button
bitButton
type="button"
buttonType="primary"
(click)="navigateToLink()"
bitDialogClose
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link bwi-fw" aria-hidden="true"></i>
</button>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>
`,
imports: [CommonModule, DialogModule, ButtonModule, JslibModule, IconModule],
})
export class VaultUiOnboardingComponent {
icon = announcementIcon;
static open(dialogService: DialogService) {
return dialogService.open<boolean>(VaultUiOnboardingComponent);
}
navigateToLink = async () => {
window.open(
"https://bitwarden.com/blog/bringing-intuitive-workflows-and-visual-updates-to-the-bitwarden-browser/",
"_blank",
);
};
}

View File

@@ -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)),

View File

@@ -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);
}));
});
});

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<boolean>(
VAULT_BROWSER_UI_ONBOARDING,
"dialogState",
{
deserializer: (obj) => obj,
},
);
@Injectable()
export class VaultUiOnboardingService {
private onboardingUiReleaseDate = new Date("2024-12-10");
private vaultUiOnboardingState: GlobalState<boolean> = 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<void> {
const hasViewedDialog = await this.getVaultUiOnboardingState();
if (!hasViewedDialog && !(await this.isNewAccount())) {
await this.openVaultUiOnboardingDialog();
}
}
private async openVaultUiOnboardingDialog(): Promise<boolean> {
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<boolean> {
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<void> {
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<boolean> {
return await firstValueFrom(this.vaultUiOnboardingState$);
}
}

View File

@@ -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",

View File

@@ -13,7 +13,7 @@ use windows::{
const CRED_FLAGS_NONE: u32 = 0;
pub async fn get_password<'a>(service: &str, account: &str) -> Result<String> {
pub async fn get_password(service: &str, account: &str) -> Result<String> {
let target_name = U16CString::from_str(target_name(service, account))?;
let mut credential: *mut CREDENTIALW = std::ptr::null_mut();

View File

@@ -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];
}
}
}

View File

@@ -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",

View File

@@ -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()

View File

@@ -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) {

View File

@@ -60,6 +60,8 @@ export class MainBiometricsService extends DesktopBiometricsService {
*/
async getBiometricsStatus(): Promise<BiometricsStatus> {
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;
}

View File

@@ -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"

View File

@@ -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. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -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(

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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[],

View File

@@ -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[],

View File

@@ -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[]);

View File

@@ -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.",

View File

@@ -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;
};

View File

@@ -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<ApiService>();
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();
});
});
});

View File

@@ -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<PasswordHealthReportApplicationsResponse[]> {
const dbResponse = this.apiService.send(
"POST",
"/reports/password-health-report-applications/",
requests,
true,
true,
);
return from(dbResponse as Promise<PasswordHealthReportApplicationsResponse[]>);
}
getCriticalApps(orgId: OrganizationId): Observable<PasswordHealthReportApplicationsResponse[]> {
const dbResponse = this.apiService.send(
"GET",
`/reports/password-health-report-applications/${orgId.toString()}`,
null,
true,
true,
);
return from(dbResponse as Promise<PasswordHealthReportApplicationsResponse[]>);
}
}

View File

@@ -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<KeyService>();
const encryptService = mock<EncryptService>();
const criticalAppsApiService = mock<CriticalAppsApiService>({
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);
});
});
});

View File

@@ -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<OrganizationId | null>(null);
private criticalAppsList = new BehaviorSubject<PasswordHealthReportApplicationsResponse[]>([]);
private teardown = new Subject<void>();
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<PasswordHealthReportApplicationsResponse[]> {
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<PasswordHealthReportApplicationsResponse[]> {
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<PasswordHealthReportApplicationsResponse[]>;
}
private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise<string[]> {
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<PasswordHealthReportApplicationsRequest[]> {
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<string, "PasswordHealthReportApplicationId">;

View File

@@ -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";

View File

@@ -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<string, number>();
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

View File

@@ -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;
}

View File

@@ -89,19 +89,19 @@
{{ "trustedDevices" | i18n }}
</bit-label>
<bit-hint>
{{ "memberDecryptionOptionTdeDescriptionPartOne" | i18n }}
<a bitLink routerLink="../policies">{{
"memberDecryptionOptionTdeDescriptionLinkOne" | i18n
}}</a>
{{ "memberDecryptionOptionTdeDescriptionPartTwo" | i18n }}
<a bitLink routerLink="../policies">{{
"memberDecryptionOptionTdeDescriptionLinkTwo" | i18n
}}</a>
{{ "memberDecryptionOptionTdeDescriptionPartThree" | i18n }}
<a bitLink routerLink="../policies">{{
"memberDecryptionOptionTdeDescriptionLinkThree" | i18n
}}</a>
{{ "memberDecryptionOptionTdeDescriptionPartFour" | i18n }}
{{ "memberDecryptionOptionTdeDescPart1" | i18n }}
<a bitLink routerLink="../policies">
{{ "memberDecryptionOptionTdeDescLink1" | i18n }}
</a>
{{ "memberDecryptionOptionTdeDescPart2" | i18n }}
<a bitLink routerLink="../policies">
{{ "memberDecryptionOptionTdeDescLink2" | i18n }}
</a>
{{ "memberDecryptionOptionTdeDescPart3" | i18n }}
<a bitLink routerLink="../policies">
{{ "memberDecryptionOptionTdeDescLink3" | i18n }}
</a>
{{ "memberDecryptionOptionTdeDescPart4" | i18n }}
</bit-hint>
</bit-radio-button>
</bit-radio-group>

View File

@@ -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 {}

View File

@@ -35,10 +35,11 @@
>
</tools-card>
<tools-card
class="tw-flex-1"
class="tw-flex-1 tw-cursor-pointer"
[title]="'atRiskApplications' | i18n"
[value]="applicationSummary.totalAtRiskApplicationCount"
[maxValue]="applicationSummary.totalApplicationCount"
(click)="showOrgAtRiskApps()"
>
</tools-card>
</div>
@@ -54,7 +55,7 @@
buttonType="secondary"
bitButton
*ngIf="isCriticalAppsFeatureEnabled"
[disabled]="!selectedIds.size"
[disabled]="!selectedUrls.size"
[loading]="markingAsCritical"
(click)="markAppsAsCritical()"
>
@@ -79,9 +80,11 @@
<input
bitCheckbox
type="checkbox"
[checked]="selectedIds.has(r.id)"
(change)="onCheckboxChange(r.id, $event)"
*ngIf="!r.isMarkedAsCritical"
[checked]="selectedUrls.has(r.applicationName)"
(change)="onCheckboxChange(r.applicationName, $event)"
/>
<i class="bwi bwi-star-f" *ngIf="r.isMarkedAsCritical"></i>
</td>
<td class="tw-cursor-pointer" (click)="showAppAtRiskMembers(r.applicationName)" bitCell>
<span>{{ r.applicationName }}</span>

View File

@@ -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<ApplicationHealthReportDetail>();
protected selectedIds: Set<number> = new Set<number>();
export class AllApplicationsComponent implements OnInit {
protected dataSource = new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
protected selectedUrls: Set<string> = new Set<string>();
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<boolean> = 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);
}

View File

@@ -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,
},
];

View File

@@ -0,0 +1,25 @@
<bit-dialog>
<ng-container bitDialogTitle>
<span bitDialogTitle>{{ "atRiskApplicationsWithCount" | i18n: atRiskApps.length }} </span>
</ng-container>
<ng-container bitDialogContent>
<div class="tw-flex tw-flex-col tw-gap-2">
<span bitTypography="body1">{{ "atRiskApplicationsDescription" | i18n }}</span>
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
<div bitTypography="body2" class="tw-font-bold">{{ "application" | i18n }}</div>
<div bitTypography="body2" class="tw-font-bold">{{ "atRiskPasswords" | i18n }}</div>
</div>
<ng-container *ngFor="let app of atRiskApps">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ app.applicationName }}</div>
<div>{{ app.atRiskPasswordCount }}</div>
</div>
</ng-container>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitDialogClose buttonType="secondary" type="button">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -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<boolean, AtRiskApplicationDetail[]>(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[]) {}
}

View File

@@ -4,9 +4,7 @@
</ng-container>
<ng-container bitDialogContent>
<div class="tw-flex tw-flex-col tw-gap-2">
<span bitTypography="body2" class="tw-text-muted">{{
"atRiskMembersDescription" | i18n
}}</span>
<span bitTypography="body1">{{ "atRiskMembersDescription" | i18n }}</span>
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
<div bitTypography="body2" class="tw-font-bold">{{ "email" | i18n }}</div>
<div bitTypography="body2" class="tw-font-bold">{{ "atRiskPasswords" | i18n }}</div>

View File

@@ -40,7 +40,7 @@
<bit-tab *ngIf="isCriticalAppsFeatureEnabled">
<ng-template bitTabLabel>
<i class="bwi bwi-star"></i>
{{ "criticalApplicationsWithCount" | i18n: criticalAppsCount }}
{{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }}
</ng-template>
<tools-critical-applications></tools-critical-applications>
</bit-tab>

View File

@@ -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<PasswordHealthReportApplicationsResponse[]> = 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);
},
});
}

View File

@@ -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),

View File

@@ -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: () => {

View File

@@ -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"]
}

View File

@@ -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() {

View File

@@ -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<R = unknown> {
* @returns CustomMatcherResult indicating whether or not the test passed
*/
toBeRejected(withinMs?: number): Promise<R>;
/**
* 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<T>(expected: Array<T>): R;
}

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,31 @@
import { EOL } from "os";
import { diff } from "jest-diff";
export const toContainPartialObjects: jest.CustomMatcher = function (
received: Array<any>,
expected: Array<any>,
) {
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,
};
};

View File

@@ -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
);
}

View File

@@ -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
);
}

View File

@@ -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 ||

View File

@@ -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,

View File

@@ -8,7 +8,14 @@ import { DerivedStateProvider } from "../derived-state.provider";
import { DefaultDerivedState } from "./default-derived-state";
export class DefaultDerivedStateProvider implements DerivedStateProvider {
private cache: Record<string, DerivedState<unknown>> = {};
/**
* 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<Observable<unknown>, Record<string, DerivedState<unknown>>>();
constructor() {}
@@ -17,8 +24,14 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
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;
}

View File

@@ -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<string>();
const user1Derived = provider.get(user1State$, deriveDefinition, deps);
const user1Emissions = trackEmissions(user1Derived.state$);
const user2State$ = new Subject<string>();
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")]);
});
});
});

View File

@@ -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",

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<SiteMetadata>,
readonly extensions: ReadonlyMap<VendorId, Readonly<ExtensionMetadata>>,
) {
deepFreeze(this);
}
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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<string, SiteMetadata> = {
[Site.forwarder]: {
id: Site.forwarder,
availableFields: [Field.baseUrl, Field.domain, Field.prefix, Field.token],
},
};
export const AllowedPermissions: ReadonlyArray<keyof typeof Permission> = Object.freeze(
Object.values(Permission),
);

View File

@@ -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);
},
);
});
});
});
});

View File

@@ -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<SiteId, SiteMetadata>();
private sitePermissions = new Map<SiteId, ExtensionPermission>();
private vendorRegistrations = new Map<VendorId, VendorMetadata>();
private vendorPermissions = new Map<VendorId, ExtensionPermission>();
private extensionRegistrations = new Array<ExtensionMetadata>();
private extensionsBySiteByVendor = new Map<SiteId, Map<VendorId, number>>();
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<VendorId, number>();
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<VendorId, ExtensionMetadata>();
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");
}

View File

@@ -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<SiteMetadata>;
/** Product description */
readonly product: Readonly<ProductMetadata>;
/** Hosting provider capabilities required by the extension */
readonly host: Readonly<ApiHost>;
/** 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<Readonly<FieldId>>;
};
/** 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;
};

View File

@@ -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],
},
];

View File

@@ -0,0 +1,8 @@
import { VendorMetadata } from "../type";
import { Vendor } from "./data";
export const Bitwarden: VendorMetadata = Object.freeze({
id: Vendor.bitwarden,
name: "Bitwarden",
});

View File

@@ -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);

View File

@@ -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],
},
];

View File

@@ -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],
},
];

View File

@@ -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],
},
];

View File

@@ -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(),
);

Some files were not shown because too many files have changed in this diff Show More