@@ -18,7 +19,10 @@ import { LinkModule } from "@bitwarden/components";
})
export class LoginSecondaryContentComponent {
registerRouteService = inject(RegisterRouteService);
+ serverSettingsService = inject(DefaultServerSettingsService);
// TODO: remove when email verification flag is removed
protected registerRoute$ = this.registerRouteService.registerRoute$();
+
+ protected isUserRegistrationDisabled$ = this.serverSettingsService.isUserRegistrationDisabled$;
}
diff --git a/libs/common/src/platform/abstractions/config/config.service.ts b/libs/common/src/platform/abstractions/config/config.service.ts
index 9b16cee3854..05a3dcd148c 100644
--- a/libs/common/src/platform/abstractions/config/config.service.ts
+++ b/libs/common/src/platform/abstractions/config/config.service.ts
@@ -3,6 +3,7 @@ import { SemVer } from "semver";
import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
+import { ServerSettings } from "../../models/domain/server-settings";
import { Region } from "../environment.service";
import { ServerConfig } from "./server-config";
@@ -10,6 +11,8 @@ import { ServerConfig } from "./server-config";
export abstract class ConfigService {
/** The server config of the currently active user */
serverConfig$: Observable;
+ /** The server settings of the currently active user */
+ serverSettings$: Observable;
/** The cloud region of the currently active user */
cloudRegion$: Observable;
/**
diff --git a/libs/common/src/platform/abstractions/config/server-config.ts b/libs/common/src/platform/abstractions/config/server-config.ts
index bb186059641..b51628cbf5b 100644
--- a/libs/common/src/platform/abstractions/config/server-config.ts
+++ b/libs/common/src/platform/abstractions/config/server-config.ts
@@ -6,6 +6,7 @@ import {
ThirdPartyServerConfigData,
EnvironmentServerConfigData,
} from "../../models/data/server-config.data";
+import { ServerSettings } from "../../models/domain/server-settings";
const dayInMilliseconds = 24 * 3600 * 1000;
@@ -16,6 +17,7 @@ export class ServerConfig {
environment?: EnvironmentServerConfigData;
utcDate: Date;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
+ settings: ServerSettings;
constructor(serverConfigData: ServerConfigData) {
this.version = serverConfigData.version;
@@ -24,6 +26,7 @@ export class ServerConfig {
this.utcDate = new Date(serverConfigData.utcDate);
this.environment = serverConfigData.environment;
this.featureStates = serverConfigData.featureStates;
+ this.settings = serverConfigData.settings;
if (this.server?.name == null && this.server?.url == null) {
this.server = null;
diff --git a/libs/common/src/platform/models/data/server-config.data.spec.ts b/libs/common/src/platform/models/data/server-config.data.spec.ts
index b94092662a6..13d14204085 100644
--- a/libs/common/src/platform/models/data/server-config.data.spec.ts
+++ b/libs/common/src/platform/models/data/server-config.data.spec.ts
@@ -16,6 +16,9 @@ describe("ServerConfigData", () => {
name: "test",
url: "https://test.com",
},
+ settings: {
+ disableUserRegistration: false,
+ },
environment: {
cloudRegion: Region.EU,
vault: "https://vault.com",
diff --git a/libs/common/src/platform/models/data/server-config.data.ts b/libs/common/src/platform/models/data/server-config.data.ts
index 57e8fbc6284..d5f17fd0ace 100644
--- a/libs/common/src/platform/models/data/server-config.data.ts
+++ b/libs/common/src/platform/models/data/server-config.data.ts
@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { Region } from "../../abstractions/environment.service";
+import { ServerSettings } from "../domain/server-settings";
import {
ServerConfigResponse,
ThirdPartyServerConfigResponse,
@@ -15,6 +16,7 @@ export class ServerConfigData {
environment?: EnvironmentServerConfigData;
utcDate: string;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
+ settings: ServerSettings;
constructor(serverConfigResponse: Partial) {
this.version = serverConfigResponse?.version;
@@ -27,6 +29,7 @@ export class ServerConfigData {
? new EnvironmentServerConfigData(serverConfigResponse.environment)
: null;
this.featureStates = serverConfigResponse?.featureStates;
+ this.settings = new ServerSettings(serverConfigResponse.settings);
}
static fromJSON(obj: Jsonify): ServerConfigData {
diff --git a/libs/common/src/platform/models/domain/server-settings.spec.ts b/libs/common/src/platform/models/domain/server-settings.spec.ts
new file mode 100644
index 00000000000..3e6295fa5c4
--- /dev/null
+++ b/libs/common/src/platform/models/domain/server-settings.spec.ts
@@ -0,0 +1,20 @@
+import { ServerSettings } from "./server-settings";
+
+describe("ServerSettings", () => {
+ describe("disableUserRegistration", () => {
+ it("defaults disableUserRegistration to false", () => {
+ const settings = new ServerSettings();
+ expect(settings.disableUserRegistration).toBe(false);
+ });
+
+ it("sets disableUserRegistration to true when provided", () => {
+ const settings = new ServerSettings({ disableUserRegistration: true });
+ expect(settings.disableUserRegistration).toBe(true);
+ });
+
+ it("sets disableUserRegistration to false when provided", () => {
+ const settings = new ServerSettings({ disableUserRegistration: false });
+ expect(settings.disableUserRegistration).toBe(false);
+ });
+ });
+});
diff --git a/libs/common/src/platform/models/domain/server-settings.ts b/libs/common/src/platform/models/domain/server-settings.ts
new file mode 100644
index 00000000000..b18f07466d7
--- /dev/null
+++ b/libs/common/src/platform/models/domain/server-settings.ts
@@ -0,0 +1,7 @@
+export class ServerSettings {
+ disableUserRegistration: boolean;
+
+ constructor(data?: ServerSettings) {
+ this.disableUserRegistration = data?.disableUserRegistration ?? false;
+ }
+}
diff --git a/libs/common/src/platform/models/response/server-config.response.ts b/libs/common/src/platform/models/response/server-config.response.ts
index a546d2d3de7..d295634830a 100644
--- a/libs/common/src/platform/models/response/server-config.response.ts
+++ b/libs/common/src/platform/models/response/server-config.response.ts
@@ -1,6 +1,7 @@
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { BaseResponse } from "../../../models/response/base.response";
import { Region } from "../../abstractions/environment.service";
+import { ServerSettings } from "../domain/server-settings";
export class ServerConfigResponse extends BaseResponse {
version: string;
@@ -8,6 +9,7 @@ export class ServerConfigResponse extends BaseResponse {
server: ThirdPartyServerConfigResponse;
environment: EnvironmentServerConfigResponse;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
+ settings: ServerSettings;
constructor(response: any) {
super(response);
@@ -21,6 +23,7 @@ export class ServerConfigResponse extends BaseResponse {
this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
this.featureStates = this.getResponseProperty("FeatureStates");
+ this.settings = new ServerSettings(this.getResponseProperty("Settings"));
}
}
diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts
index e0603ed509b..fce1c12106f 100644
--- a/libs/common/src/platform/services/config/default-config.service.ts
+++ b/libs/common/src/platform/services/config/default-config.service.ts
@@ -28,6 +28,7 @@ import { Environment, EnvironmentService, Region } from "../../abstractions/envi
import { LogService } from "../../abstractions/log.service";
import { devFlagEnabled, devFlagValue } from "../../misc/flags";
import { ServerConfigData } from "../../models/data/server-config.data";
+import { ServerSettings } from "../../models/domain/server-settings";
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs")
@@ -57,6 +58,8 @@ export class DefaultConfigService implements ConfigService {
serverConfig$: Observable;
+ serverSettings$: Observable;
+
cloudRegion$: Observable;
constructor(
@@ -111,6 +114,10 @@ export class DefaultConfigService implements ConfigService {
this.cloudRegion$ = this.serverConfig$.pipe(
map((config) => config?.environment?.cloudRegion ?? Region.US),
);
+
+ this.serverSettings$ = this.serverConfig$.pipe(
+ map((config) => config?.settings ?? new ServerSettings()),
+ );
}
getFeatureFlag$(key: Flag) {
diff --git a/libs/common/src/platform/services/default-server-settings.service.spec.ts b/libs/common/src/platform/services/default-server-settings.service.spec.ts
new file mode 100644
index 00000000000..09bca2ff786
--- /dev/null
+++ b/libs/common/src/platform/services/default-server-settings.service.spec.ts
@@ -0,0 +1,47 @@
+import { of } from "rxjs";
+
+import { ConfigService } from "../abstractions/config/config.service";
+import { ServerSettings } from "../models/domain/server-settings";
+
+import { DefaultServerSettingsService } from "./default-server-settings.service";
+
+describe("DefaultServerSettingsService", () => {
+ let service: DefaultServerSettingsService;
+ let configServiceMock: { serverSettings$: any };
+
+ beforeEach(() => {
+ configServiceMock = { serverSettings$: of() };
+ service = new DefaultServerSettingsService(configServiceMock as ConfigService);
+ });
+
+ describe("getSettings$", () => {
+ it("returns server settings", () => {
+ const mockSettings = new ServerSettings({ disableUserRegistration: true });
+ configServiceMock.serverSettings$ = of(mockSettings);
+
+ service.getSettings$().subscribe((settings) => {
+ expect(settings).toEqual(mockSettings);
+ });
+ });
+ });
+
+ describe("isUserRegistrationDisabled$", () => {
+ it("returns true when user registration is disabled", () => {
+ const mockSettings = new ServerSettings({ disableUserRegistration: true });
+ configServiceMock.serverSettings$ = of(mockSettings);
+
+ service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => {
+ expect(isDisabled).toBe(true);
+ });
+ });
+
+ it("returns false when user registration is enabled", () => {
+ const mockSettings = new ServerSettings({ disableUserRegistration: false });
+ configServiceMock.serverSettings$ = of(mockSettings);
+
+ service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => {
+ expect(isDisabled).toBe(false);
+ });
+ });
+ });
+});
diff --git a/libs/common/src/platform/services/default-server-settings.service.ts b/libs/common/src/platform/services/default-server-settings.service.ts
new file mode 100644
index 00000000000..9d0dd4bfd94
--- /dev/null
+++ b/libs/common/src/platform/services/default-server-settings.service.ts
@@ -0,0 +1,19 @@
+import { Observable } from "rxjs";
+import { map } from "rxjs/operators";
+
+import { ConfigService } from "../abstractions/config/config.service";
+import { ServerSettings } from "../models/domain/server-settings";
+
+export class DefaultServerSettingsService {
+ constructor(private configService: ConfigService) {}
+
+ getSettings$(): Observable {
+ return this.configService.serverSettings$;
+ }
+
+ get isUserRegistrationDisabled$(): Observable {
+ return this.getSettings$().pipe(
+ map((settings: ServerSettings) => settings.disableUserRegistration),
+ );
+ }
+}
From 872f36752f585740ab4ee996b924af41951f74ac Mon Sep 17 00:00:00 2001
From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Date: Thu, 7 Nov 2024 15:03:54 +0100
Subject: [PATCH 03/50] [PM-13876] Generator PR review follow up (#11885)
* Remove unused regex
* Remove viewChild reference from markup
---------
Co-authored-by: Daniel James Smith
---
.../generator/components/src/catchall-settings.component.ts | 5 -----
.../components/src/credential-generator.component.html | 6 ------
.../components/src/password-generator.component.html | 2 --
.../components/src/username-generator.component.html | 4 ----
4 files changed, 17 deletions(-)
diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts
index 74fb37d2335..3a42d682971 100644
--- a/libs/tools/generator/components/src/catchall-settings.component.ts
+++ b/libs/tools/generator/components/src/catchall-settings.component.ts
@@ -12,11 +12,6 @@ import {
import { completeOnAccountSwitch } from "./util";
-/** Splits an email into a username, subaddress, and domain named group.
- * Subaddress is optional.
- */
-export const DOMAIN_PARSER = new RegExp("[^@]+@(?.+)");
-
/** Options group for catchall emails */
@Component({
selector: "tools-catchall-settings",
diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html
index 0182bd1c204..ce86abe80ae 100644
--- a/libs/tools/generator/components/src/credential-generator.component.html
+++ b/libs/tools/generator/components/src/credential-generator.component.html
@@ -39,14 +39,12 @@
Date: Thu, 7 Nov 2024 10:10:15 -0500
Subject: [PATCH 04/50] [PM-11201][CL-507] Add the ability to sort by Name,
Group, and Permission within the collection and item tables (#11453)
* Added sorting to vault, name, permission and group
Added default sorting
* Fixed import
* reverted test on template
* Only add sorting functionality to admin console
* changed code order
* Fixed leftover test for sortingn
* Fixed reference
* sort permissions by ascending order
* Fixed bug where a collection had multiple groups and sorting alphbatically didn't happen correctly all the time
* Fixed bug whne creating a new cipher item
* Introduced fnFactory to create a sort function with direction provided
* Used new sort function to make collections always remain at the top and ciphers below
* extracted logic to always sort collections at the top
Added similar sorting to sortBygroup
* removed org vault check
* remove unused service
* Sort only collections
* Got rid of sortFn factory in favour of passing the direction as an optional parameter
* Removed tenary
* get cipher permissions
* Use all collections to filter collection ids
* Fixed ascending and descending issues
* Added functionality to default sort in descending order
* default sort permissions in descending order
* Refactored setActive to not pass direction as a paramater
---
.../vault-items/vault-items.component.html | 32 +++-
.../vault-items/vault-items.component.ts | 177 +++++++++++++++++-
.../src/table/sortable.component.ts | 31 ++-
.../components/src/table/table-data-source.ts | 4 +-
libs/components/src/table/table.mdx | 15 +-
5 files changed, 245 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html
index d6bcd76903b..653d05ef129 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html
@@ -16,13 +16,39 @@
"all" | i18n
}}
-
{{ "name" | i18n }}
+
+
+ {{ "name" | i18n }}
+
+
+
+ {{ "name" | i18n }}
+
{{ "owner" | i18n }}
{{ "collections" | i18n }}
-
{{ "groups" | i18n }}
-
+
+ {{ "groups" | i18n }}
+
+
{{ "permission" | i18n }}
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
index 71a97f1ff44..9f19a0319a5 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
@@ -1,13 +1,17 @@
import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core";
-import { CollectionView, Unassigned } from "@bitwarden/admin-console/common";
+import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
-import { TableDataSource } from "@bitwarden/components";
+import { SortDirection, TableDataSource } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core";
+import {
+ CollectionPermission,
+ convertToPermission,
+} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { VaultItem } from "./vault-item";
import { VaultItemEvent } from "./vault-item-event";
@@ -17,6 +21,8 @@ export const RowHeightClass = `tw-h-[65px]`;
const MaxSelectionCount = 500;
+type ItemPermission = CollectionPermission | "NoAccess";
+
@Component({
selector: "app-vault-items",
templateUrl: "vault-items.component.html",
@@ -333,6 +339,119 @@ export class VaultItemsComponent {
return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected;
}
+ /**
+ * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
+ */
+ protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
+ // Collections before ciphers
+ const collectionCompare = this.prioritizeCollections(a, b, direction);
+ if (collectionCompare !== 0) {
+ return collectionCompare;
+ }
+
+ return this.compareNames(a, b);
+ };
+
+ /**
+ * Sorts VaultItems based on group names
+ */
+ protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
+ if (
+ !(a.collection instanceof CollectionAdminView) &&
+ !(b.collection instanceof CollectionAdminView)
+ ) {
+ return 0;
+ }
+
+ const getFirstGroupName = (collection: CollectionAdminView): string => {
+ if (collection.groups.length > 0) {
+ return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0];
+ }
+ return null;
+ };
+
+ // Collections before ciphers
+ const collectionCompare = this.prioritizeCollections(a, b, direction);
+ if (collectionCompare !== 0) {
+ return collectionCompare;
+ }
+
+ const aGroupName = getFirstGroupName(a.collection as CollectionAdminView);
+ const bGroupName = getFirstGroupName(b.collection as CollectionAdminView);
+
+ // Collections with groups come before collections without groups.
+ // If a collection has no groups, getFirstGroupName returns null.
+ if (aGroupName === null) {
+ return 1;
+ }
+
+ if (bGroupName === null) {
+ return -1;
+ }
+
+ return aGroupName.localeCompare(bGroupName);
+ };
+
+ /**
+ * Sorts VaultItems based on their permissions, with higher permissions taking precedence.
+ * If permissions are equal, it falls back to sorting by name.
+ */
+ protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
+ const getPermissionPriority = (item: VaultItem): number => {
+ const permission = item.collection
+ ? this.getCollectionPermission(item.collection)
+ : this.getCipherPermission(item.cipher);
+
+ const priorityMap = {
+ [CollectionPermission.Manage]: 5,
+ [CollectionPermission.Edit]: 4,
+ [CollectionPermission.EditExceptPass]: 3,
+ [CollectionPermission.View]: 2,
+ [CollectionPermission.ViewExceptPass]: 1,
+ NoAccess: 0,
+ };
+
+ return priorityMap[permission] ?? -1;
+ };
+
+ // Collections before ciphers
+ const collectionCompare = this.prioritizeCollections(a, b, direction);
+ if (collectionCompare !== 0) {
+ return collectionCompare;
+ }
+
+ const priorityA = getPermissionPriority(a);
+ const priorityB = getPermissionPriority(b);
+
+ // Higher priority first
+ if (priorityA !== priorityB) {
+ return priorityA - priorityB;
+ }
+
+ return this.compareNames(a, b);
+ };
+
+ private compareNames(a: VaultItem, b: VaultItem): number {
+ const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name;
+ return getName(a).localeCompare(getName(b));
+ }
+
+ /**
+ * Sorts VaultItems by prioritizing collections over ciphers.
+ * Collections are always placed before ciphers, regardless of the sorting direction.
+ */
+ private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number {
+ if (a.collection && !b.collection) {
+ return direction === "asc" ? -1 : 1;
+ }
+
+ if (!a.collection && b.collection) {
+ return direction === "asc" ? 1 : -1;
+ }
+
+ return 0;
+ }
+
private hasPersonalItems(): boolean {
return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null);
}
@@ -346,4 +465,58 @@ export class VaultItemsComponent {
private getUniqueOrganizationIds(): Set {
return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? []));
}
+
+ private getGroupName(groupId: string): string | undefined {
+ return this.allGroups.find((g) => g.id === groupId)?.name;
+ }
+
+ private getCollectionPermission(collection: CollectionView): ItemPermission {
+ const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
+
+ if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) {
+ return CollectionPermission.Edit;
+ }
+
+ if (collection.assigned) {
+ return convertToPermission(collection);
+ }
+
+ return "NoAccess";
+ }
+
+ private getCipherPermission(cipher: CipherView): ItemPermission {
+ if (!cipher.organizationId || cipher.collectionIds.length === 0) {
+ return CollectionPermission.Manage;
+ }
+
+ const filteredCollections = this.allCollections?.filter((collection) => {
+ if (collection.assigned) {
+ return cipher.collectionIds.find((id) => {
+ if (collection.id === id) {
+ return collection;
+ }
+ });
+ }
+ });
+
+ if (filteredCollections?.length === 1) {
+ return convertToPermission(filteredCollections[0]);
+ }
+
+ if (filteredCollections?.length > 0) {
+ const permissions = filteredCollections.map((collection) => convertToPermission(collection));
+
+ const orderedPermissions = [
+ CollectionPermission.Manage,
+ CollectionPermission.Edit,
+ CollectionPermission.EditExceptPass,
+ CollectionPermission.View,
+ CollectionPermission.ViewExceptPass,
+ ];
+
+ return orderedPermissions.find((perm) => permissions.includes(perm));
+ }
+
+ return "NoAccess";
+ }
}
diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts
index b2e456953b1..c6d60f155b2 100644
--- a/libs/components/src/table/sortable.component.ts
+++ b/libs/components/src/table/sortable.component.ts
@@ -1,7 +1,7 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, HostBinding, Input, OnInit } from "@angular/core";
-import type { SortFn } from "./table-data-source";
+import type { SortDirection, SortFn } from "./table-data-source";
import { TableComponent } from "./table.component";
@Component({
@@ -19,12 +19,16 @@ export class SortableComponent implements OnInit {
*/
@Input() bitSortable: string;
- private _default: boolean;
+ private _default: SortDirection | boolean = false;
/**
* Mark the column as the default sort column
*/
- @Input() set default(value: boolean | "") {
- this._default = coerceBooleanProperty(value);
+ @Input() set default(value: SortDirection | boolean | "") {
+ if (value === "desc" || value === "asc") {
+ this._default = value;
+ } else {
+ this._default = coerceBooleanProperty(value) ? "asc" : false;
+ }
}
/**
@@ -32,6 +36,11 @@ export class SortableComponent implements OnInit {
*
* @example
* fn = (a, b) => a.name.localeCompare(b.name)
+ *
+ * fn = (a, b, direction) => {
+ * const result = a.name.localeCompare(b.name)
+ * return direction === 'asc' ? result : -result;
+ * }
*/
@Input() fn: SortFn;
@@ -52,8 +61,18 @@ export class SortableComponent implements OnInit {
protected setActive() {
if (this.table.dataSource) {
- const direction = this.isActive && this.direction === "asc" ? "desc" : "asc";
- this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn };
+ const defaultDirection = this._default === "desc" ? "desc" : "asc";
+ const direction = this.isActive
+ ? this.direction === "asc"
+ ? "desc"
+ : "asc"
+ : defaultDirection;
+
+ this.table.dataSource.sort = {
+ column: this.bitSortable,
+ direction: direction,
+ fn: this.fn,
+ };
}
}
diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts
index 6501c9bffbd..8a5d994dc18 100644
--- a/libs/components/src/table/table-data-source.ts
+++ b/libs/components/src/table/table-data-source.ts
@@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections";
import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs";
export type SortDirection = "asc" | "desc";
-export type SortFn = (a: any, b: any) => number;
+export type SortFn = (a: any, b: any, direction?: SortDirection) => number;
export type Sort = {
column?: string;
direction: SortDirection;
@@ -166,7 +166,7 @@ export class TableDataSource extends DataSource {
return data.sort((a, b) => {
// If a custom sort function is provided, use it instead of the default.
if (sort.fn) {
- return sort.fn(a, b) * directionModifier;
+ return sort.fn(a, b, sort.direction) * directionModifier;
}
let valueA = this.sortingDataAccessor(a, column);
diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx
index 3f28dd93b68..8d784190ed9 100644
--- a/libs/components/src/table/table.mdx
+++ b/libs/components/src/table/table.mdx
@@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`.
We provide a simple component for displaying sortable column headers. The `bitSortable` component
wires up to the `TableDataSource` and will automatically sort the data when clicked and display an
-indicator for which column is currently sorted. The dafault sorting can be specified by setting the
+indicator for which column is currently sorted. The default sorting can be specified by setting the
`default`.
```html
@@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci
Name
```
+For default sorting in descending order, set default="desc"
+
+```html
+
Name
+```
+
It's also possible to define a custom sorting function by setting the `fn` input.
```ts
+// Basic sort function
const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
+
+// Direction aware sort function
+const sortByName = (a: T, b: T, direction?: SortDirection) => {
+ const result = a.name.localeCompare(b.name);
+ return direction === "asc" ? result : -result;
+};
```
### Filtering
From 05a79d58bb1e7310261582827e730a26e7abd33f Mon Sep 17 00:00:00 2001
From: Jonathan Prusik
Date: Thu, 7 Nov 2024 10:38:49 -0500
Subject: [PATCH 05/50] do not do browser extension permission check if the
Show autofill suggestions setting is being turned off (#11889)
---
.../src/autofill/popup/settings/autofill.component.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts
index b395808f57a..ac247609b13 100644
--- a/apps/browser/src/autofill/popup/settings/autofill.component.ts
+++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts
@@ -219,7 +219,11 @@ export class AutofillComponent implements OnInit {
: AutofillOverlayVisibility.Off;
await this.autofillSettingsService.setInlineMenuVisibility(newInlineMenuVisibilityValue);
- await this.requestPrivacyPermission();
+
+ // No need to initiate browser permission request if a feature is being turned off
+ if (newInlineMenuVisibilityValue !== AutofillOverlayVisibility.Off) {
+ await this.requestPrivacyPermission();
+ }
}
async updateAutofillOnPageLoad() {
From b42741f313864496cfe30a55231cd63168c4da03 Mon Sep 17 00:00:00 2001
From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Date: Thu, 7 Nov 2024 10:22:35 -0600
Subject: [PATCH 06/50] [PM-13839][PM-13840] Admin Console Collections (#11649)
* allow admin console to see all collections when viewing a cipher
- When "manage all" option is selected all collections should be editable
* update cipher form service to use admin endpoints
* when saving a cipher, choose to move to collections first before saving any other edits
- This handles the case where a cipher is moving from unassigned to assigned and needs to have a collection to save any other edits
* set admin flag when the original cipher has zero collections
- handling the case where the user un-assigns themselves from a cipher
* add check for the users ability to edit items within the collection
* save cipher edit first to handle when the user unassigns themselves from the cipher
* update filter order of collections
* use cipher returned from the collections endpoint rather than re-fetching it
* fix unit tests by adding canEditItems
* re-enable collection control when orgId is present
* fetch the updated cipher from the respective service for editing a cipher
---
.../vault-item-dialog.component.ts | 19 ++-
...console-cipher-form-config.service.spec.ts | 64 ++++----
...dmin-console-cipher-form-config.service.ts | 39 +----
libs/common/src/services/api.service.ts | 2 +-
.../src/vault/abstractions/cipher.service.ts | 2 +-
.../src/vault/services/cipher.service.ts | 6 +-
.../item-details-section.component.spec.ts | 153 ++++++++++++++++--
.../item-details-section.component.ts | 22 ++-
.../services/default-cipher-form.service.ts | 16 +-
.../src/cipher-view/cipher-view.component.ts | 1 +
10 files changed, 240 insertions(+), 84 deletions(-)
diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts
index ae2cf88fd1f..bf623e729a1 100644
--- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts
+++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts
@@ -6,6 +6,7 @@ import { firstValueFrom, Observable, Subject } from "rxjs";
import { map } from "rxjs/operators";
import { CollectionView } from "@bitwarden/admin-console/common";
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
@@ -17,6 +18,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
+import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
+import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
@@ -231,6 +234,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private premiumUpgradeService: PremiumUpgradePromptService,
private cipherAuthorizationService: CipherAuthorizationService,
+ private apiService: ApiService,
) {
this.updateTitle();
}
@@ -278,7 +282,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
if (this._originalFormMode === "add" || this._originalFormMode === "clone") {
this.formConfig.mode = "edit";
}
- this.formConfig.originalCipher = await this.cipherService.get(cipherView.id);
+
+ let cipher: Cipher;
+
+ // When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint
+ if (this.formConfig.isAdminConsole) {
+ const cipherResponse = await this.apiService.getCipherAdmin(cipherView.id);
+ const cipherData = new CipherData(cipherResponse);
+ cipher = new Cipher(cipherData);
+ } else {
+ cipher = await this.cipherService.get(cipherView.id);
+ }
+
+ // Store the updated cipher so any following edits use the most up to date cipher
+ this.formConfig.originalCipher = cipher;
this._cipherModified = true;
await this.changeMode("view");
}
diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts
index 02d280f5ff9..05c40fe2e79 100644
--- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts
+++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts
@@ -1,14 +1,13 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
-import { CollectionAdminService } from "@bitwarden/admin-console/common";
+import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherId } from "@bitwarden/common/types/guid";
-import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
@@ -35,27 +34,41 @@ describe("AdminConsoleCipherFormConfigService", () => {
status: OrganizationUserStatusType.Confirmed,
};
const policyAppliesToActiveUser$ = new BehaviorSubject(true);
+ const collection = {
+ id: "12345-5555",
+ organizationId: "234534-34334",
+ name: "Test Collection 1",
+ assigned: false,
+ readOnly: true,
+ } as CollectionAdminView;
+ const collection2 = {
+ id: "12345-6666",
+ organizationId: "22222-2222",
+ name: "Test Collection 2",
+ assigned: true,
+ readOnly: false,
+ } as CollectionAdminView;
+
const organization$ = new BehaviorSubject(testOrg as Organization);
const organizations$ = new BehaviorSubject([testOrg, testOrg2] as Organization[]);
const getCipherAdmin = jest.fn().mockResolvedValue(null);
- const getCipher = jest.fn().mockResolvedValue(null);
beforeEach(async () => {
getCipherAdmin.mockClear();
- getCipher.mockClear();
- getCipher.mockResolvedValue({ id: cipherId, name: "Test Cipher - (non-admin)" });
getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" });
await TestBed.configureTestingModule({
providers: [
AdminConsoleCipherFormConfigService,
+ { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } },
+ {
+ provide: CollectionAdminService,
+ useValue: { getAll: () => Promise.resolve([collection, collection2]) },
+ },
{
provide: PolicyService,
useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ },
},
- { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } },
- { provide: CipherService, useValue: { get: getCipher } },
- { provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } },
{
provide: RoutedVaultFilterService,
useValue: { filter$: new BehaviorSubject({ organizationId: testOrg.id }) },
@@ -86,6 +99,12 @@ describe("AdminConsoleCipherFormConfigService", () => {
expect(mode).toBe("edit");
});
+ it("returns all collections", async () => {
+ const { collections } = await adminConsoleConfigService.buildConfig("edit", cipherId);
+
+ expect(collections).toEqual([collection, collection2]);
+ });
+
it("sets admin flag based on `canEditAllCiphers`", async () => {
// Disable edit all ciphers on org
testOrg.canEditAllCiphers = false;
@@ -153,33 +172,14 @@ describe("AdminConsoleCipherFormConfigService", () => {
expect(result.organizations).toEqual([testOrg, testOrg2]);
});
- describe("getCipher", () => {
- it("retrieves the cipher from the cipher service", async () => {
- testOrg.canEditAllCiphers = false;
+ it("retrieves the cipher from the admin service", async () => {
+ getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" });
- adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService);
+ adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService);
- const result = await adminConsoleConfigService.buildConfig("clone", cipherId);
+ await adminConsoleConfigService.buildConfig("add", cipherId);
- expect(getCipher).toHaveBeenCalledWith(cipherId);
- expect(result.originalCipher.name).toBe("Test Cipher - (non-admin)");
-
- // Admin service not needed when cipher service can return the cipher
- expect(getCipherAdmin).not.toHaveBeenCalled();
- });
-
- it("retrieves the cipher from the admin service", async () => {
- getCipher.mockResolvedValueOnce(null);
- getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" });
-
- adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService);
-
- await adminConsoleConfigService.buildConfig("add", cipherId);
-
- expect(getCipherAdmin).toHaveBeenCalledWith(cipherId);
-
- expect(getCipher).toHaveBeenCalledWith(cipherId);
- });
+ expect(getCipherAdmin).toHaveBeenCalledWith(cipherId);
});
});
});
diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts
index 328ab4475dc..457b4e83d03 100644
--- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts
+++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts
@@ -6,9 +6,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType, OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
-import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherId } from "@bitwarden/common/types/guid";
-import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -25,7 +23,6 @@ import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/se
export class AdminConsoleCipherFormConfigService implements CipherFormConfigService {
private policyService: PolicyService = inject(PolicyService);
private organizationService: OrganizationService = inject(OrganizationService);
- private cipherService: CipherService = inject(CipherService);
private routedVaultFilterService: RoutedVaultFilterService = inject(RoutedVaultFilterService);
private collectionAdminService: CollectionAdminService = inject(CollectionAdminService);
private apiService: ApiService = inject(ApiService);
@@ -51,20 +48,8 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
map(([orgs, orgId]) => orgs.find((o) => o.id === orgId)),
);
- private editableCollections$ = this.organization$.pipe(
- switchMap(async (org) => {
- if (!org) {
- return [];
- }
-
- const collections = await this.collectionAdminService.getAll(org.id);
- // Users that can edit all ciphers can implicitly add to / edit within any collection
- if (org.canEditAllCiphers) {
- return collections;
- }
- // The user is only allowed to add/edit items to assigned collections that are not readonly
- return collections.filter((c) => c.assigned && !c.readOnly);
- }),
+ private allCollections$ = this.organization$.pipe(
+ switchMap(async (org) => await this.collectionAdminService.getAll(org.id)),
);
async buildConfig(
@@ -72,21 +57,17 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
cipherId?: CipherId,
cipherType?: CipherType,
): Promise {
+ const cipher = await this.getCipher(cipherId);
const [organization, allowPersonalOwnership, allOrganizations, allCollections] =
await firstValueFrom(
combineLatest([
this.organization$,
this.allowPersonalOwnership$,
this.allOrganizations$,
- this.editableCollections$,
+ this.allCollections$,
]),
);
- const cipher = await this.getCipher(organization, cipherId);
-
- const collections = allCollections.filter(
- (c) => c.organizationId === organization.id && c.assigned && !c.readOnly,
- );
// When cloning from within the Admin Console, all organizations should be available.
// Otherwise only the one in context should be
const organizations = mode === "clone" ? allOrganizations : [organization];
@@ -100,7 +81,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
admin: organization.canEditAllCiphers ?? false,
allowPersonalOwnership: allowPersonalOwnershipOnlyForClone,
originalCipher: cipher,
- collections,
+ collections: allCollections,
organizations,
folders: [], // folders not applicable in the admin console
hideIndividualVaultFields: true,
@@ -108,19 +89,11 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
};
}
- private async getCipher(organization: Organization, id?: CipherId): Promise {
+ private async getCipher(id?: CipherId): Promise {
if (id == null) {
return Promise.resolve(null);
}
- // Check to see if the user has direct access to the cipher
- const cipherFromCipherService = await this.cipherService.get(id);
-
- // If the organization doesn't allow admin/owners to edit all ciphers return the cipher
- if (!organization.canEditAllCiphers && cipherFromCipherService != null) {
- return cipherFromCipherService;
- }
-
// Retrieve the cipher through the means of an admin
const cipherResponse = await this.apiService.getCipherAdmin(id);
cipherResponse.edit = true;
diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts
index 2d4a0522636..f9e05e7635c 100644
--- a/libs/common/src/services/api.service.ts
+++ b/libs/common/src/services/api.service.ts
@@ -584,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction {
}
putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise {
- return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false);
+ return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true);
}
postPurgeCiphers(
diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts
index 444c922fe31..5221f4cf0a6 100644
--- a/libs/common/src/vault/abstractions/cipher.service.ts
+++ b/libs/common/src/vault/abstractions/cipher.service.ts
@@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise;
+ saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise;
/**
* Bulk update collections for many ciphers with the server
* @param orgId
diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts
index 154042601e9..6b618e25502 100644
--- a/libs/common/src/vault/services/cipher.service.ts
+++ b/libs/common/src/vault/services/cipher.service.ts
@@ -880,9 +880,11 @@ export class CipherService implements CipherServiceAbstraction {
return new Cipher(updated[cipher.id as CipherId], cipher.localData);
}
- async saveCollectionsWithServerAdmin(cipher: Cipher): Promise {
+ async saveCollectionsWithServerAdmin(cipher: Cipher): Promise {
const request = new CipherCollectionsRequest(cipher.collectionIds);
- await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
+ const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
+ const data = new CipherData(response);
+ return new Cipher(data);
}
/**
diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts
index b62557a4329..93229bda6c3 100644
--- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts
+++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts
@@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
- { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
- { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
- { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ {
+ id: "col2",
+ name: "Collection 2",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
- { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
- { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
- { id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView,
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
+ {
+ id: "col2",
+ name: "Collection 2",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
+ {
+ id: "col3",
+ name: "Collection 3",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
];
fixture.detectChanges();
@@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
- { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
];
fixture.detectChanges();
@@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
- { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
- { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
+ {
+ id: "col2",
+ name: "Collection 2",
+ organizationId: "org1",
+ canEditItems: (_org) => true,
+ } as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
+ canEditItems: (_org) => true,
} as CollectionView,
];
@@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => {
expect(collectionHint).not.toBeNull();
});
+
+ it("should allow all collections to be altered when `config.admin` is true", async () => {
+ component.config.admin = true;
+ component.config.allowPersonalOwnership = true;
+ component.config.organizations = [{ id: "org1" } as Organization];
+ component.config.collections = [
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ readOnly: true,
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ {
+ id: "col2",
+ name: "Collection 2",
+ organizationId: "org1",
+ readOnly: true,
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ {
+ id: "col3",
+ name: "Collection 3",
+ organizationId: "org1",
+ readOnly: false,
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ ];
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ component.itemDetailsForm.controls.organizationId.setValue("org1");
+
+ expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
+ });
+ });
+
+ describe("readonlyCollections", () => {
+ beforeEach(() => {
+ component.config.mode = "edit";
+ component.config.admin = true;
+ component.config.collections = [
+ {
+ id: "col1",
+ name: "Collection 1",
+ organizationId: "org1",
+ readOnly: true,
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ {
+ id: "col2",
+ name: "Collection 2",
+ organizationId: "org1",
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ {
+ id: "col3",
+ name: "Collection 3",
+ organizationId: "org1",
+ readOnly: true,
+ canEditItems: (_org) => false,
+ } as CollectionView,
+ ];
+ component.originalCipherView = {
+ name: "cipher1",
+ organizationId: "org1",
+ folderId: "folder1",
+ collectionIds: ["col1", "col2", "col3"],
+ favorite: true,
+ } as CipherView;
+ component.config.organizations = [{ id: "org1" } as Organization];
+ });
+
+ it("should not show collections as readonly when `config.admin` is true", async () => {
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ // Filters out all collections
+ expect(component["readOnlyCollections"]).toEqual([]);
+
+ // Non-admin, keep readonly collections
+ component.config.admin = false;
+
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]);
+ });
});
});
diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts
index 86a8818bbe3..ea82aa0cae4 100644
--- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts
+++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts
@@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit {
} else if (this.config.mode === "edit") {
this.readOnlyCollections = this.collections
.filter(
- (c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId),
+ // When the configuration is set up for admins, they can alter read only collections
+ (c) =>
+ c.readOnly &&
+ !this.config.admin &&
+ this.originalCipherView.collectionIds.includes(c.id as CollectionId),
)
.map((c) => c.name);
}
@@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit {
collectionsControl.disable();
this.showCollectionsControl = false;
return;
+ } else {
+ collectionsControl.enable();
+ this.showCollectionsControl = true;
}
+ const organization = this.organizations.find((o) => o.id === orgId);
+
this.collectionOptions = this.collections
.filter((c) => {
- // If partial edit mode, show all org collections because the control is disabled.
- return c.organizationId === orgId && (this.partialEdit || !c.readOnly);
+ // Filter criteria:
+ // - The collection belongs to the organization
+ // - When in partial edit mode, show all org collections because the control is disabled.
+ // - The user can edit items within the collection
+ // - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections
+ return (
+ c.organizationId === orgId &&
+ (this.partialEdit || c.canEditItems(organization) || this.config.admin)
+ );
})
.map((c) => ({
id: c.id,
diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts
index 8e73d9edd40..1b7e86f82a7 100644
--- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts
+++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts
@@ -1,6 +1,7 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -17,6 +18,7 @@ function isSetEqual(a: Set, b: Set) {
export class DefaultCipherFormService implements CipherFormService {
private cipherService: CipherService = inject(CipherService);
private accountService: AccountService = inject(AccountService);
+ private apiService: ApiService = inject(ApiService);
async decryptCipher(cipher: Cipher): Promise {
const activeUserId = await firstValueFrom(
@@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService {
// Updating a cipher with collection changes is not supported with a single request currently
// First update the cipher with the original collectionIds
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
- await this.cipherService.updateWithServer(encryptedCipher, config.admin);
+ await this.cipherService.updateWithServer(
+ encryptedCipher,
+ config.admin || originalCollectionIds.size === 0,
+ config.mode !== "clone",
+ );
// Then save the new collection changes separately
encryptedCipher.collectionIds = cipher.collectionIds;
- savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
+
+ if (config.admin || originalCollectionIds.size === 0) {
+ // When using an admin config or the cipher was unassigned, update collections as an admin
+ savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);
+ } else {
+ savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
+ }
}
// Its possible the cipher was made no longer available due to collection assignment changes
diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts
index 5d61caf52f3..0871fd8e788 100644
--- a/libs/vault/src/cipher-view/cipher-view.component.ts
+++ b/libs/vault/src/cipher-view/cipher-view.component.ts
@@ -98,6 +98,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
async loadCipherData() {
// Load collections if not provided and the cipher has collectionIds
if (
+ this.cipher.collectionIds &&
this.cipher.collectionIds.length > 0 &&
(!this.collections || this.collections.length === 0)
) {
From db40f20160f9f2b0136fc82176a051368dd990fe Mon Sep 17 00:00:00 2001
From: Matt Bishop
Date: Thu, 7 Nov 2024 13:01:54 -0500
Subject: [PATCH 07/50] Check run permissions for build artifact generation
secrets usage (#11897)
---
.github/workflows/build-browser.yml | 28 ++++++++++++----
.github/workflows/build-cli.yml | 25 ++++++++++----
.github/workflows/build-desktop.yml | 51 ++++++++++++++++++++++-------
.github/workflows/build-web.yml | 32 ++++++++++++++----
4 files changed, 105 insertions(+), 31 deletions(-)
diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml
index 34c69912f50..647f1980819 100644
--- a/.github/workflows/build-browser.yml
+++ b/.github/workflows/build-browser.yml
@@ -1,7 +1,8 @@
name: Build Browser
on:
- pull_request:
+ pull_request_target:
+ types: [opened, synchronize]
branches-ignore:
- 'l10n_master'
- 'cf-pages'
@@ -33,6 +34,10 @@ defaults:
shell: bash
jobs:
+ check-run:
+ name: Check PR run
+ uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
+
setup:
name: Setup
runs-on: ubuntu-22.04
@@ -41,8 +46,10 @@ jobs:
adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }}
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Get Package Version
id: gen_vars
@@ -71,8 +78,10 @@ jobs:
run:
working-directory: apps/browser
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Testing locales - extName length
run: |
@@ -109,8 +118,10 @@ jobs:
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -225,12 +236,15 @@ jobs:
needs:
- setup
- locales-test
+ - check-run
env:
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -342,8 +356,10 @@ jobs:
- build
- build-safari
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml
index 7994e508b3c..1b0679dfbeb 100644
--- a/.github/workflows/build-cli.yml
+++ b/.github/workflows/build-cli.yml
@@ -1,7 +1,8 @@
name: Build CLI
on:
- pull_request:
+ pull_request_target:
+ types: [opened, synchronize]
branches-ignore:
- 'l10n_master'
- 'cf-pages'
@@ -34,6 +35,10 @@ defaults:
working-directory: apps/cli
jobs:
+ check-run:
+ name: Check PR run
+ uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
+
setup:
name: Setup
runs-on: ubuntu-22.04
@@ -41,8 +46,10 @@ jobs:
package_version: ${{ steps.retrieve-package-version.outputs.package_version }}
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Get Package Version
id: retrieve-package-version
@@ -58,7 +65,6 @@ jobs:
NODE_VERSION=${NODE_NVMRC/v/''}
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
-
cli:
name: "${{ matrix.os.base }} - ${{ matrix.license_type.readable }}"
strategy:
@@ -82,8 +88,10 @@ jobs:
_WIN_PKG_FETCH_VERSION: 20.11.1
_WIN_PKG_VERSION: 3.5
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Unix Vars
run: |
@@ -160,8 +168,10 @@ jobs:
_WIN_PKG_FETCH_VERSION: 20.11.1
_WIN_PKG_VERSION: 3.5
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Windows builder
run: |
@@ -310,8 +320,10 @@ jobs:
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Print environment
run: |
@@ -386,6 +398,7 @@ jobs:
- cli
- cli-windows
- snap
+ - check-run
steps:
- name: Check if any job failed
working-directory: ${{ github.workspace }}
diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml
index 4667a937113..ca64dbc0a4d 100644
--- a/.github/workflows/build-desktop.yml
+++ b/.github/workflows/build-desktop.yml
@@ -1,7 +1,8 @@
name: Build Desktop
on:
- pull_request:
+ pull_request_target:
+ types: [opened, synchronize]
branches-ignore:
- 'l10n_master'
- 'cf-pages'
@@ -32,12 +33,18 @@ defaults:
shell: bash
jobs:
+ check-run:
+ name: Check PR run
+ uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
+
electron-verify:
name: Verify Electron Version
runs-on: ubuntu-22.04
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Verify
run: |
@@ -65,8 +72,10 @@ jobs:
run:
working-directory: apps/desktop
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Get Package Version
id: retrieve-version
@@ -138,8 +147,10 @@ jobs:
run:
working-directory: apps/desktop
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -238,7 +249,9 @@ jobs:
windows:
name: Windows Build
runs-on: windows-2022
- needs: setup
+ needs:
+ - setup
+ - check-run
defaults:
run:
shell: pwsh
@@ -248,8 +261,10 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
NODE_OPTIONS: --max_old_space_size=4096
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -447,7 +462,9 @@ jobs:
macos-build:
name: MacOS Build
runs-on: macos-13
- needs: setup
+ needs:
+ - setup
+ - check-run
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -456,8 +473,10 @@ jobs:
run:
working-directory: apps/desktop
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -622,8 +641,10 @@ jobs:
run:
working-directory: apps/desktop
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -841,8 +862,10 @@ jobs:
run:
working-directory: apps/desktop
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -1088,8 +1111,10 @@ jobs:
run:
working-directory: apps/desktop
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -1279,8 +1304,10 @@ jobs:
- macos-package-mas
runs-on: ubuntu-22.04
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index 31f800d5b37..be2789dd1dc 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -1,7 +1,8 @@
name: Build Web
on:
- pull_request:
+ pull_request_target:
+ types: [opened, synchronize]
branches-ignore:
- 'l10n_master'
- 'cf-pages'
@@ -36,6 +37,10 @@ env:
_AZ_REGISTRY: bitwardenprod.azurecr.io
jobs:
+ check-run:
+ name: Check PR run
+ uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
+
setup:
name: Setup
runs-on: ubuntu-22.04
@@ -43,8 +48,10 @@ jobs:
version: ${{ steps.version.outputs.value }}
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Get GitHub sha as version
id: version
@@ -89,8 +96,10 @@ jobs:
git_metadata: true
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -142,6 +151,7 @@ jobs:
needs:
- setup
- build-artifacts
+ - check-run
strategy:
fail-fast: false
matrix:
@@ -155,8 +165,10 @@ jobs:
env:
_VERSION: ${{ needs.setup.outputs.version }}
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Check Branch to Publish
env:
@@ -250,11 +262,15 @@ jobs:
crowdin-push:
name: Crowdin Push
if: github.ref == 'refs/heads/main'
- needs: build-artifacts
+ needs:
+ - build-artifacts
+ - check-run
runs-on: ubuntu-22.04
steps:
- - name: Checkout repo
+ - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@@ -284,7 +300,9 @@ jobs:
name: Trigger web vault deploy
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
- needs: build-artifacts
+ needs:
+ - build-artifacts
+ - check-run
steps:
- name: Login to Azure - CI Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
From ef0fd6067695d8f022c06421c903d148f796f4fe Mon Sep 17 00:00:00 2001
From: Brandon Treston
Date: Thu, 7 Nov 2024 14:09:56 -0500
Subject: [PATCH 08/50] [PM-11409] prevent managed user from leaving
organization (#11895)
* add check to prevent managed user from leaving managing org
* remove unused vaiable
* add null check
---
.../organization-options.component.ts | 31 ++++++++++++++++---
1 file changed, 26 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts
index 3b7db72a09d..57eb9b1bdd9 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts
@@ -1,5 +1,5 @@
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
-import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
+import { combineLatest, map, Observable, of, Subject, switchMap, takeUntil } from "rxjs";
import {
OrganizationUserApiService,
@@ -8,11 +8,14 @@ import {
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -53,6 +56,8 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
private resetPasswordService: OrganizationUserResetPasswordService,
private userVerificationService: UserVerificationService,
private toastService: ToastService,
+ private configService: ConfigService,
+ private organizationService: OrganizationService,
) {}
async ngOnInit() {
@@ -60,23 +65,39 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)),
);
+ const managingOrg$ = this.configService
+ .getFeatureFlag$(FeatureFlag.AccountDeprovisioning)
+ .pipe(
+ switchMap((isAccountDeprovisioningEnabled) =>
+ isAccountDeprovisioningEnabled
+ ? this.organizationService.organizations$.pipe(
+ map((organizations) =>
+ organizations.find((o) => o.userIsManagedByOrganization === true),
+ ),
+ )
+ : of(null),
+ ),
+ );
+
combineLatest([
this.organization$,
resetPasswordPolicies$,
this.userDecryptionOptionsService.userDecryptionOptions$,
+ managingOrg$,
])
.pipe(takeUntil(this.destroy$))
- .subscribe(([organization, resetPasswordPolicies, decryptionOptions]) => {
+ .subscribe(([organization, resetPasswordPolicies, decryptionOptions, managingOrg]) => {
this.organization = organization;
this.resetPasswordPolicy = resetPasswordPolicies.find(
(p) => p.organizationId === organization.id,
);
- // A user can leave an organization if they are NOT using TDE and Key Connector, or they have a master password.
+ // A user can leave an organization if they are NOT a managed user and they are NOT using TDE and Key Connector, or they have a master password.
this.showLeaveOrgOption =
- (decryptionOptions.trustedDeviceOption == undefined &&
+ managingOrg?.id !== organization.id &&
+ ((decryptionOptions.trustedDeviceOption == undefined &&
decryptionOptions.keyConnectorOption == undefined) ||
- decryptionOptions.hasMasterPassword;
+ decryptionOptions.hasMasterPassword);
// Hide the 3 dot menu if the user has no available actions
this.hideMenu =
From 668ede2dfb2ea7b4a34c6ce4e433f29d6f223545 Mon Sep 17 00:00:00 2001
From: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Date: Thu, 7 Nov 2024 14:38:05 -0500
Subject: [PATCH 09/50] Add event_name check to Deploy Web trigger job (#11901)
---
.github/workflows/build-web.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index be2789dd1dc..46dff21433f 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -298,7 +298,7 @@ jobs:
trigger-web-vault-deploy:
name: Trigger web vault deploy
- if: github.ref == 'refs/heads/main'
+ if: github.event_name != pull_request_target && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs:
- build-artifacts
From 771bfdaccd699efdc7c4b6a433ce296d93524a5a Mon Sep 17 00:00:00 2001
From: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Date: Thu, 7 Nov 2024 14:42:10 -0500
Subject: [PATCH 10/50] Fix quotes (#11902)
---
.github/workflows/build-web.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index 46dff21433f..bb6909f9864 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -298,7 +298,7 @@ jobs:
trigger-web-vault-deploy:
name: Trigger web vault deploy
- if: github.event_name != pull_request_target && github.ref == 'refs/heads/main'
+ if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs:
- build-artifacts
From e95af8269f6160bf66e48e213ae26a00f363ba18 Mon Sep 17 00:00:00 2001
From: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Date: Thu, 7 Nov 2024 15:15:44 -0500
Subject: [PATCH 11/50] Add check for trigger event (#11904)
---
.github/workflows/build-browser.yml | 5 ++++-
.github/workflows/build-cli.yml | 5 ++++-
.github/workflows/build-desktop.yml | 5 ++++-
.github/workflows/build-web.yml | 5 ++++-
4 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml
index 647f1980819..ecd1e404944 100644
--- a/.github/workflows/build-browser.yml
+++ b/.github/workflows/build-browser.yml
@@ -397,7 +397,10 @@ jobs:
- crowdin-push
steps:
- name: Check if any job failed
- if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
+ if: |
+ github.event_name != 'pull_request_target'
+ && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
+ && contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml
index 1b0679dfbeb..98ba5b9fd8a 100644
--- a/.github/workflows/build-cli.yml
+++ b/.github/workflows/build-cli.yml
@@ -402,7 +402,10 @@ jobs:
steps:
- name: Check if any job failed
working-directory: ${{ github.workspace }}
- if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
+ if: |
+ github.event_name != 'pull_request_target'
+ && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
+ && contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml
index ca64dbc0a4d..83389c5bbec 100644
--- a/.github/workflows/build-desktop.yml
+++ b/.github/workflows/build-desktop.yml
@@ -1350,7 +1350,10 @@ jobs:
- crowdin-push
steps:
- name: Check if any job failed
- if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
+ if: |
+ github.event_name != 'pull_request_target'
+ && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
+ && contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index bb6909f9864..4ce5bad790f 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -344,7 +344,10 @@ jobs:
- trigger-web-vault-deploy
steps:
- name: Check if any job failed
- if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
+ if: |
+ github.event_name != 'pull_request_target'
+ && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
+ && contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
From b2811e07ceabebb2b6329e53feacd6bc2cf194f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?=
Date: Thu, 7 Nov 2024 15:23:01 -0500
Subject: [PATCH 12/50] [PM-14198] zero minimums when the character category is
disabled (#11906)
---
.../core/src/policies/constraints.ts | 2 ++
...ynamic-password-policy-constraints.spec.ts | 26 ++++---------------
.../dynamic-password-policy-constraints.ts | 6 ++---
3 files changed, 10 insertions(+), 24 deletions(-)
diff --git a/libs/tools/generator/core/src/policies/constraints.ts b/libs/tools/generator/core/src/policies/constraints.ts
index 6071b57048f..d320329938f 100644
--- a/libs/tools/generator/core/src/policies/constraints.ts
+++ b/libs/tools/generator/core/src/policies/constraints.ts
@@ -2,6 +2,7 @@ import { Constraint } from "@bitwarden/common/tools/types";
import { sum } from "../util";
+const Zero: Constraint = { min: 0, max: 0 };
const AtLeastOne: Constraint = { min: 1 };
const RequiresTrue: Constraint = { requiredValue: true };
@@ -159,6 +160,7 @@ export {
enforceConstant,
readonlyTrueWhen,
fitLength,
+ Zero,
AtLeastOne,
RequiresTrue,
};
diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts
index 96f590f8ed6..d05d75ffb76 100644
--- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts
+++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts
@@ -1,6 +1,6 @@
import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data";
-import { AtLeastOne } from "./constraints";
+import { AtLeastOne, Zero } from "./constraints";
import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
describe("DynamicPasswordPolicyConstraints", () => {
@@ -207,7 +207,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber);
});
- it("disables the minNumber constraint when the state's number flag is false", () => {
+ it("outputs the zero constraint when the state's number flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
@@ -216,7 +216,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
const calibrated = dynamic.calibrate(state);
- expect(calibrated.constraints.minNumber).toBeUndefined();
+ expect(calibrated.constraints.minNumber).toEqual(Zero);
});
it("outputs the minSpecial constraint when the state's special flag is true", () => {
@@ -231,7 +231,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial);
});
- it("disables the minSpecial constraint when the state's special flag is false", () => {
+ it("outputs the zero constraint when the state's special flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
@@ -240,23 +240,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
const calibrated = dynamic.calibrate(state);
- expect(calibrated.constraints.minSpecial).toBeUndefined();
- });
-
- it("copies the minimum length constraint", () => {
- const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
-
- const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
-
- expect(calibrated.constraints.minSpecial).toBeUndefined();
- });
-
- it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => {
- const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
-
- const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
-
- expect(calibrated.constraints.minSpecial).toBeUndefined();
+ expect(calibrated.constraints.minSpecial).toEqual(Zero);
});
});
});
diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts
index daff9882547..7fe76061885 100644
--- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts
+++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts
@@ -7,7 +7,7 @@ import {
import { DefaultPasswordBoundaries } from "../data";
import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types";
-import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints";
+import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "./constraints";
import { PasswordPolicyConstraints } from "./password-policy-constraints";
/** Creates state constraints by blending policy and password settings. */
@@ -68,8 +68,8 @@ export class DynamicPasswordPolicyConstraints
...this.constraints,
minLowercase: maybe(lowercase, this.constraints.minLowercase ?? AtLeastOne),
minUppercase: maybe(uppercase, this.constraints.minUppercase ?? AtLeastOne),
- minNumber: maybe(number, this.constraints.minNumber),
- minSpecial: maybe(special, this.constraints.minSpecial),
+ minNumber: maybe(number, this.constraints.minNumber) ?? Zero,
+ minSpecial: maybe(special, this.constraints.minSpecial) ?? Zero,
};
// lower bound of length must always at least fit its sub-lengths
From ec92f8250199e0f34e33b8bbf37f3abdb5bf5950 Mon Sep 17 00:00:00 2001
From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Date: Thu, 7 Nov 2024 21:28:40 +0100
Subject: [PATCH 13/50] Fix wrong import of SendFormModule (#11893)
Co-authored-by: Daniel James Smith
---
.../src/tools/popup/send-v2/add-edit/send-add-edit.component.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts
index 585f6067e3d..d1005883651 100644
--- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts
+++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts
@@ -24,9 +24,9 @@ import {
SendFormConfig,
SendFormConfigService,
SendFormMode,
+ SendFormModule,
} from "@bitwarden/send-ui";
-import { SendFormModule } from "../../../../../../../libs/tools/send/send-ui/src/send-form/send-form.module";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
From f9098558a6f3b765545a80752770ba4a4491a903 Mon Sep 17 00:00:00 2001
From: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Date: Thu, 7 Nov 2024 16:00:48 -0500
Subject: [PATCH 14/50] Update Is-Prerelease header to be integer instead of
string (#11909)
---
libs/common/src/services/api.service.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts
index f9e05e7635c..0c508bfeb88 100644
--- a/libs/common/src/services/api.service.ts
+++ b/libs/common/src/services/api.service.ts
@@ -1886,7 +1886,7 @@ export class ApiService implements ApiServiceAbstraction {
});
if (flagEnabled("prereleaseBuild")) {
- headers.set("Is-Prerelease", "true");
+ headers.set("Is-Prerelease", "1");
}
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);
From f206e0f817a19d12bd8db7f9a9466c7adc809498 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Thu, 7 Nov 2024 16:41:47 -0500
Subject: [PATCH 15/50] Move Packages to Platform & KM (#11907)
---
.github/renovate.json | 50 ++++++++++++++++++++++++++-----------------
1 file changed, 30 insertions(+), 20 deletions(-)
diff --git a/.github/renovate.json b/.github/renovate.json
index c9cfd548956..0172403f0f1 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -41,16 +41,12 @@
},
{
"matchPackageNames": [
- "@ngtools/webpack",
"base64-loader",
"buffer",
"bufferutil",
- "copy-webpack-plugin",
"core-js",
"css-loader",
"html-loader",
- "html-webpack-injector",
- "html-webpack-plugin",
"mini-css-extract-plugin",
"ngx-infinite-scroll",
"postcss",
@@ -60,20 +56,15 @@
"sass-loader",
"style-loader",
"ts-loader",
- "tsconfig-paths-webpack-plugin",
"url",
- "util",
- "webpack",
- "webpack-cli",
- "webpack-dev-server",
- "webpack-node-externals"
+ "util"
],
"description": "Admin Console owned dependencies",
"commitMessagePrefix": "[deps] AC:",
"reviewers": ["team:team-admin-console-dev"]
},
{
- "matchPackageNames": ["@types/node-ipc", "node-ipc", "qrious"],
+ "matchPackageNames": ["qrious"],
"description": "Auth owned dependencies",
"commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"]
@@ -110,27 +101,43 @@
},
{
"matchPackageNames": [
+ "@babel/core",
+ "@babel/preset-env",
"@electron/notarize",
"@electron/rebuild",
- "@types/argon2-browser",
+ "@ngtools/webpack",
"@types/chrome",
"@types/firefox-webext-browser",
+ "@types/glob",
"@types/jquery",
+ "@types/lowdb",
"@types/node",
"@types/node-forge",
- "argon2",
- "argon2-browser",
- "big-integer",
+ "@types/node-ipc",
+ "@yao-pkg",
+ "babel-loader",
+ "browserslist",
+ "copy-webpack-plugin",
+ "electron",
"electron-builder",
"electron-log",
"electron-reload",
"electron-store",
"electron-updater",
- "electron",
+ "html-webpack-injector",
+ "html-webpack-plugin",
+ "lowdb",
"node-forge",
+ "node-ipc",
+ "pkg",
"rxjs",
+ "tsconfig-paths-webpack-plugin",
"type-fest",
- "typescript"
+ "typescript",
+ "webpack",
+ "webpack-cli",
+ "webpack-dev-server",
+ "webpack-node-externals"
],
"description": "Platform owned dependencies",
"commitMessagePrefix": "[deps] Platform:",
@@ -231,7 +238,6 @@
"@types/koa__router",
"@types/koa-bodyparser",
"@types/koa-json",
- "@types/lowdb",
"@types/lunr",
"@types/node-fetch",
"@types/proper-lockfile",
@@ -244,18 +250,22 @@
"koa",
"koa-bodyparser",
"koa-json",
- "lowdb",
"lunr",
"multer",
"node-fetch",
"open",
- "pkg",
"proper-lockfile",
"qrcode-parser"
],
"description": "Vault owned dependencies",
"commitMessagePrefix": "[deps] Vault:",
"reviewers": ["team:team-vault-dev"]
+ },
+ {
+ "matchPackageNames": ["@types/argon2-browser", "argon2", "argon2-browser", "big-integer"],
+ "description": "Key Management owned dependencies",
+ "commitMessagePrefix": "[deps] KM:",
+ "reviewers": ["team:team-key-management-dev"]
}
],
"ignoreDeps": ["@types/koa-bodyparser", "bootstrap", "node-ipc", "node", "npm"]
From e8dac0cc122d35d6a279fd64adda10b2df97e402 Mon Sep 17 00:00:00 2001
From: Victoria League
Date: Thu, 7 Nov 2024 16:54:49 -0500
Subject: [PATCH 16/50] [CL-500] Add disclosure component and directive
(#11865)
---
.../disclosure-trigger-for.directive.ts | 27 +++++++++
.../src/disclosure/disclosure.component.ts | 21 +++++++
libs/components/src/disclosure/disclosure.mdx | 55 +++++++++++++++++++
.../src/disclosure/disclosure.stories.ts | 29 ++++++++++
libs/components/src/disclosure/index.ts | 2 +
.../src/icon-button/icon-button.component.ts | 4 ++
.../src/icon-button/icon-button.mdx | 20 +++----
.../src/icon-button/icon-button.stories.ts | 8 +--
libs/components/src/index.ts | 1 +
9 files changed, 152 insertions(+), 15 deletions(-)
create mode 100644 libs/components/src/disclosure/disclosure-trigger-for.directive.ts
create mode 100644 libs/components/src/disclosure/disclosure.component.ts
create mode 100644 libs/components/src/disclosure/disclosure.mdx
create mode 100644 libs/components/src/disclosure/disclosure.stories.ts
create mode 100644 libs/components/src/disclosure/index.ts
diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts
new file mode 100644
index 00000000000..05470281729
--- /dev/null
+++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts
@@ -0,0 +1,27 @@
+import { Directive, HostBinding, HostListener, Input } from "@angular/core";
+
+import { DisclosureComponent } from "./disclosure.component";
+
+@Directive({
+ selector: "[bitDisclosureTriggerFor]",
+ exportAs: "disclosureTriggerFor",
+ standalone: true,
+})
+export class DisclosureTriggerForDirective {
+ /**
+ * Accepts template reference for a bit-disclosure component instance
+ */
+ @Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent;
+
+ @HostBinding("attr.aria-expanded") get ariaExpanded() {
+ return this.disclosure.open;
+ }
+
+ @HostBinding("attr.aria-controls") get ariaControls() {
+ return this.disclosure.id;
+ }
+
+ @HostListener("click") click() {
+ this.disclosure.open = !this.disclosure.open;
+ }
+}
diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts
new file mode 100644
index 00000000000..58c67ad0f0e
--- /dev/null
+++ b/libs/components/src/disclosure/disclosure.component.ts
@@ -0,0 +1,21 @@
+import { Component, HostBinding, Input, booleanAttribute } from "@angular/core";
+
+let nextId = 0;
+
+@Component({
+ selector: "bit-disclosure",
+ standalone: true,
+ template: ``,
+})
+export class DisclosureComponent {
+ /**
+ * Optionally init the disclosure in its opened state
+ */
+ @Input({ transform: booleanAttribute }) open?: boolean = false;
+
+ @HostBinding("class") get classList() {
+ return this.open ? "" : "tw-hidden";
+ }
+
+ @HostBinding("id") id = `bit-disclosure-${nextId++}`;
+}
diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx
new file mode 100644
index 00000000000..8df8e7025b8
--- /dev/null
+++ b/libs/components/src/disclosure/disclosure.mdx
@@ -0,0 +1,55 @@
+import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
+
+import * as stories from "./disclosure.stories";
+
+
+
+```ts
+import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components";
+```
+
+# Disclosure
+
+The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to
+create an accessible content area whose visibility is controlled by a trigger button.
+
+To compose a disclosure and trigger:
+
+1. Create a trigger component (see "Supported Trigger Components" section below)
+2. Create a `bit-disclosure`
+3. Set a template reference on the `bit-disclosure`
+4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the
+ `bit-disclosure` template reference
+5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently
+ expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to
+ being hidden.
+
+```
+
+click button to hide this content
+```
+
+
+
+
+
+
+## Supported Trigger Components
+
+This is the list of currently supported trigger components:
+
+- Icon button `muted` variant
+
+## Accessibility
+
+The disclosure and trigger directive functionality follow the
+[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for
+accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button`
+element must be used as the trigger for the disclosure. The `button` element must also have an
+accessible label/title -- please follow the accessibility guidelines for whatever trigger component
+you choose.
diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts
new file mode 100644
index 00000000000..974589a667c
--- /dev/null
+++ b/libs/components/src/disclosure/disclosure.stories.ts
@@ -0,0 +1,29 @@
+import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
+
+import { IconButtonModule } from "../icon-button";
+
+import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive";
+import { DisclosureComponent } from "./disclosure.component";
+
+export default {
+ title: "Component Library/Disclosure",
+ component: DisclosureComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule],
+ }),
+ ],
+} as Meta;
+
+type Story = StoryObj;
+
+export const DisclosureWithIconButton: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ click button to hide this content
+ `,
+ }),
+};
diff --git a/libs/components/src/disclosure/index.ts b/libs/components/src/disclosure/index.ts
new file mode 100644
index 00000000000..b5bdf68725f
--- /dev/null
+++ b/libs/components/src/disclosure/index.ts
@@ -0,0 +1,2 @@
+export * from "./disclosure-trigger-for.directive";
+export * from "./disclosure.component";
diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts
index 54f6dfda963..d036e1c77ca 100644
--- a/libs/components/src/icon-button/icon-button.component.ts
+++ b/libs/components/src/icon-button/icon-button.component.ts
@@ -52,10 +52,14 @@ const styles: Record = {
"tw-bg-transparent",
"!tw-text-muted",
"tw-border-transparent",
+ "aria-expanded:tw-bg-text-muted",
+ "aria-expanded:!tw-text-contrast",
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
+ "aria-expanded:hover:tw-bg-secondary-700",
+ "aria-expanded:hover:tw-border-secondary-700",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,
diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx
index 8361d4c3997..a45160d7884 100644
--- a/libs/components/src/icon-button/icon-button.mdx
+++ b/libs/components/src/icon-button/icon-button.mdx
@@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the
[dialog](?path=/docs/component-library-dialogs--docs), and
[table](?path=/docs/component-library-table--docs).
-
-
## Styles
There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the
@@ -40,48 +38,48 @@ button component styles.
Used for general icon buttons appearing on the theme’s main `background`
-
+
### Muted
Used for low emphasis icon buttons appearing on the theme’s main `background`
-
+
### Contrast
Used on a theme’s colored or contrasting backgrounds such as in the navigation or on toasts and
banners.
-
+
### Danger
Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of
the dialog component.
-
+
### Primary
Used in place of the main button component if no text is used. This allows the button to display
square.
-
+
### Secondary
Used in place of the main button component if no text is used. This allows the button to display
square.
-
+
### Light
Used on a background that is dark in both light theme and dark theme. Example: end user navigation
styles.
-
+
**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus
indicator does not meet WCAG graphic contrast guidelines.
@@ -95,11 +93,11 @@ with less padding around the icon, such as in the navigation component.
### Small
-
+
### Default
-
+
## Accessibility
diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts
index 0f25d2de583..b5542f78600 100644
--- a/libs/components/src/icon-button/icon-button.stories.ts
+++ b/libs/components/src/icon-button/icon-button.stories.ts
@@ -23,7 +23,7 @@ type Story = StoryObj;
export const Default: Story = {
render: (args) => ({
props: args,
- template: `
+ template: /*html*/ `