diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index 1ff67671419..52230a12bcc 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -112,13 +112,48 @@ jobs:
echo "azure-login-creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
echo "retrieve-secrets-keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT
echo "environment-artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT
- echo "environment-name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT
+ echo "environment-name=Web Vault - US DEV Cloud" >> $GITHUB_OUTPUT
echo "environment-url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
;;
esac
# Set the sync utility to use for deployment to the environment (az-sync or azcopy)
echo "sync-utility=azcopy" >> $GITHUB_OUTPUT
+ - name: Environment Protection
+ env:
+ TAG: ${{ steps.project_tag.outputs.tag }}
+ run: |
+ BRANCH_OR_TAG_LOWER=$(echo ${{ inputs.branch-or-tag }} | awk '{print tolower($0)}')
+
+ PROD_ENV_PATTERN='USPROD|EUPROD'
+ PROD_ALLOWED_TAGS_PATTERN='web-v[0-9]+\.[0-9]+\.[0-9]+'
+
+ QA_ENV_PATTERN='USQA|EUQA'
+ QA_ALLOWED_TAGS_PATTERN='.*'
+
+ DEV_ENV_PATTERN='USDEV'
+ DEV_ALLOWED_TAGS_PATTERN='.*'
+
+ if [[ \
+ ${{ inputs.environment }} =~ \.*($PROD_ENV_PATTERN)\.* && \
+ ! "$BRANCH_OR_TAG_LOWER" =~ ^($PROD_ALLOWED_TAGS_PATTERN).* \
+ ]] || [[ \
+ ${{ inputs.environment }} =~ \.*($QA_ENV_PATTERN)\.* && \
+ ! "$BRANCH_OR_TAG_LOWER" =~ ^($QA_ALLOWED_TAGS_PATTERN).* \
+ ]] || [[ \
+ =~ \.*($DEV_ENV_PATTERN)\.* && \
+ ! "$BRANCH_OR_TAG_LOWER" =~ ^($DEV_ALLOWED_TAGS_PATTERN).* \
+ ]]; then
+ echo "!Deployment blocked!"
+ echo "Attempting to deploy a tag that is not allowed in ${{ inputs.environment }} environment"
+ echo
+ echo "Environment: ${{ inputs.environment }}
+ echo "Tag: ${{ inputs.branch-or-tag }}
+ exit 1
+ else
+ echo "${{ inputs.branch-or-tag }} is allowed to deployed on to ${{ inputs.environment }} environment"
+ fi
+
approval:
name: Approval for Deployment to ${{ needs.setup.outputs.environment-name }}
needs: setup
@@ -206,6 +241,31 @@ jobs:
echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT
fi
+ - name: Ensure artifact is from main branch for USDEV environment
+ if: ${{ 'inputs.environment' == 'USDEV'}}
+ run: |
+ # If run-id was used
+ if [ "${{ inputs.build-web-run-id }}" ]; then
+ if [ "${{ steps.download-latest-artifacts.outputs.artifact-build-branch }}" != "main" ]; then
+ echo "Artifact is not from main branch"
+ exit 1
+ fi
+
+ # If artifact download failed
+ elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then
+ branch=$(gh api /repos/bitwarden/clients/actions/runs/${{ steps.trigger-build-web.outputs.workflow_id }}/artifacts --jq '.artifacts[0].workflow_run.head_branch')
+ if [ "$branch" != "main" ]; then
+ echo "Artifact is not from main branch"
+ exit 1
+ fi
+
+ else
+ if [ "${{ steps.download-latest-artifacts.outputs.artifact-build-branch }}" != "main" ]; then
+ echo "Artifact is not from main branch"
+ exit 1
+ fi
+ fi
+
notify-start:
name: Notify Slack with start message
needs:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 12649b91ea9..7d841ca880e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,16 +8,27 @@ on:
- "main"
- "rc"
- "hotfix-rc-*"
- pull_request:
+ pull_request_target:
+ types: [opened, synchronize]
defaults:
run:
shell: bash
jobs:
+ check-run:
+ name: Check PR run
+ uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
+
test:
name: Run tests
runs-on: ubuntu-22.04
+ needs: check-run
+ permissions:
+ checks: write
+ contents: read
+ pull-requests: write
+
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index f9bf1bc523e..ecea2deb9ef 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -1434,6 +1434,24 @@
"typeIdentity": {
"message": "Identity"
},
+ "newItemHeader":{
+ "message": "New $TYPE$",
+ "placeholders": {
+ "type": {
+ "content": "$1",
+ "example": "Login"
+ }
+ }
+ },
+ "editItemHeader":{
+ "message": "Edit $TYPE$",
+ "placeholders": {
+ "type": {
+ "content": "$1",
+ "example": "Login"
+ }
+ }
+ },
"passwordHistory": {
"message": "Password history"
},
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts
index 97008ab96d9..1109ab73adf 100644
--- a/apps/browser/src/popup/app-routing.module.ts
+++ b/apps/browser/src/popup/app-routing.module.ts
@@ -57,6 +57,7 @@ import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filt
import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component";
import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component";
import { ViewComponent } from "../vault/popup/components/vault/view.component";
+import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component";
import { AppearanceComponent } from "../vault/popup/settings/appearance.component";
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
import { FoldersComponent } from "../vault/popup/settings/folders.component";
@@ -195,20 +196,18 @@ const routes: Routes = [
canActivate: [AuthGuard],
data: { state: "cipher-password-history" },
},
- {
+ ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, {
path: "add-cipher",
- component: AddEditComponent,
canActivate: [AuthGuard, debounceNavigationGuard()],
data: { state: "add-cipher" },
runGuardsAndResolvers: "always",
- },
- {
+ }),
+ ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, {
path: "edit-cipher",
- component: AddEditComponent,
canActivate: [AuthGuard, debounceNavigationGuard()],
data: { state: "edit-cipher" },
runGuardsAndResolvers: "always",
- },
+ }),
{
path: "share-cipher",
component: ShareComponent,
diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html
new file mode 100644
index 00000000000..09b764cbc8f
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts
new file mode 100644
index 00000000000..a3fad87c1b1
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts
@@ -0,0 +1,64 @@
+import { CommonModule } from "@angular/common";
+import { Component } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
+import { FormsModule } from "@angular/forms";
+import { ActivatedRoute } from "@angular/router";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { CipherType } from "@bitwarden/common/vault/enums";
+import { SearchModule, ButtonModule } from "@bitwarden/components";
+
+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";
+
+@Component({
+ selector: "app-add-edit-v2",
+ templateUrl: "add-edit-v2.component.html",
+ standalone: true,
+ imports: [
+ CommonModule,
+ SearchModule,
+ JslibModule,
+ FormsModule,
+ ButtonModule,
+ PopupPageComponent,
+ PopupHeaderComponent,
+ PopupFooterComponent,
+ ],
+})
+export class AddEditV2Component {
+ headerText: string;
+
+ constructor(
+ private route: ActivatedRoute,
+ private i18nService: I18nService,
+ ) {
+ this.subscribeToParams();
+ }
+
+ subscribeToParams(): void {
+ this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
+ const isNew = params.isNew.toLowerCase() === "true";
+ const cipherType = parseInt(params.type);
+
+ this.headerText = this.setHeader(isNew, cipherType);
+ });
+ }
+
+ setHeader(isNew: boolean, type: CipherType) {
+ const partOne = isNew ? "newItemHeader" : "editItemHeader";
+
+ switch (type) {
+ case CipherType.Login:
+ return this.i18nService.t(partOne, this.i18nService.t("typeLogin"));
+ case CipherType.Card:
+ return this.i18nService.t(partOne, this.i18nService.t("typeCard"));
+ case CipherType.Identity:
+ return this.i18nService.t(partOne, this.i18nService.t("typeIdentity"));
+ case CipherType.SecureNote:
+ return this.i18nService.t(partOne, this.i18nService.t("note"));
+ }
+ }
+}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html
new file mode 100644
index 00000000000..0bd85c21696
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html
@@ -0,0 +1,22 @@
+
+
+
+
+ {{ "typeLogin" | i18n }}
+
+
+
+ {{ "typeCard" | i18n }}
+
+
+
+ {{ "typeIdentity" | i18n }}
+
+
+
+ {{ "note" | i18n }}
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts
new file mode 100644
index 00000000000..e90afec5388
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts
@@ -0,0 +1,28 @@
+import { CommonModule } from "@angular/common";
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { Router, RouterLink } from "@angular/router";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { CipherType } from "@bitwarden/common/vault/enums";
+import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components";
+
+@Component({
+ selector: "app-new-item-dropdown",
+ templateUrl: "new-item-dropdown-v2.component.html",
+ standalone: true,
+ imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
+})
+export class NewItemDropdownV2Component implements OnInit, OnDestroy {
+ cipherType = CipherType;
+
+ constructor(private router: Router) {}
+
+ ngOnInit(): void {}
+
+ ngOnDestroy(): void {}
+
+ // TODO PM-6826: add selectedVault query param
+ newItemNavigate(type: CipherType) {
+ void this.router.navigate(["/add-cipher"], { queryParams: { type: type, isNew: true } });
+ }
+}
diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html
index 694c0e9be52..7dd06310159 100644
--- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html
+++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html
@@ -1,11 +1,8 @@
-
-
-
- {{ "new" | i18n }}
-
+
+
@@ -18,9 +15,7 @@
{{ "yourVaultIsEmpty" | i18n }}
{{ "autofillSuggestionsTip" | i18n }}
-
+
diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts
index f6f6872c1c5..9939727806b 100644
--- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts
+++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts
@@ -5,6 +5,7 @@ import { Router, RouterLink } from "@angular/router";
import { combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
@@ -13,6 +14,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
+import { NewItemDropdownV2Component } from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component";
import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
@@ -40,9 +42,11 @@ enum VaultState {
ButtonModule,
RouterLink,
VaultV2SearchComponent,
+ NewItemDropdownV2Component,
],
})
export class VaultV2Component implements OnInit, OnDestroy {
+ cipherType = CipherType;
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
@@ -86,9 +90,4 @@ export class VaultV2Component implements OnInit, OnDestroy {
ngOnInit(): void {}
ngOnDestroy(): void {}
-
- addCipher() {
- // TODO: Add currently filtered organization to query params if available
- void this.router.navigate(["/add-cipher"], {});
- }
}
diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts
index a225db0c11a..211bd8fc099 100644
--- a/apps/browser/src/vault/popup/components/vault/view.component.ts
+++ b/apps/browser/src/vault/popup/components/vault/view.component.ts
@@ -198,7 +198,9 @@ export class ViewComponent extends BaseViewComponent {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.router.navigate(["/edit-cipher"], { queryParams: { cipherId: this.cipher.id } });
+ this.router.navigate(["/edit-cipher"], {
+ queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false },
+ });
return true;
}
diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts
index b7091eb87bf..f08f4e836e1 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts
@@ -379,6 +379,54 @@ describe("VaultPopupItemsService", () => {
});
});
+ describe("loading$", () => {
+ let tracked: ObservableTracker;
+ let trackedCiphers: ObservableTracker;
+ beforeEach(() => {
+ // Start tracking loading$ emissions
+ tracked = new ObservableTracker(service.loading$);
+
+ // Track remainingCiphers$ to make cipher observables active
+ trackedCiphers = new ObservableTracker(service.remainingCiphers$);
+ });
+
+ it("should initialize with true first", async () => {
+ expect(tracked.emissions[0]).toBe(true);
+ });
+
+ it("should emit false once ciphers are available", async () => {
+ expect(tracked.emissions.length).toBe(2);
+ expect(tracked.emissions[0]).toBe(true);
+ expect(tracked.emissions[1]).toBe(false);
+ });
+
+ it("should cycle when cipherService.ciphers$ emits", async () => {
+ // Restart tracking
+ tracked = new ObservableTracker(service.loading$);
+ (cipherServiceMock.ciphers$ as BehaviorSubject).next(null);
+
+ await trackedCiphers.pauseUntilReceived(2);
+
+ expect(tracked.emissions.length).toBe(3);
+ expect(tracked.emissions[0]).toBe(false);
+ expect(tracked.emissions[1]).toBe(true);
+ expect(tracked.emissions[2]).toBe(false);
+ });
+
+ it("should cycle when filters are applied", async () => {
+ // Restart tracking
+ tracked = new ObservableTracker(service.loading$);
+ service.applyFilter("test");
+
+ await trackedCiphers.pauseUntilReceived(2);
+
+ expect(tracked.emissions.length).toBe(3);
+ expect(tracked.emissions[0]).toBe(false);
+ expect(tracked.emissions[1]).toBe(true);
+ expect(tracked.emissions[2]).toBe(false);
+ });
+ });
+
describe("applyFilter", () => {
it("should call search Service with the new search term", (done) => {
const searchText = "Hello";
diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts
index 189ce2c09f9..f96bb095b94 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts
@@ -2,6 +2,7 @@ import { inject, Injectable, NgZone } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
+ distinctUntilChanged,
distinctUntilKeyChanged,
from,
map,
@@ -12,6 +13,8 @@ import {
startWith,
Subject,
switchMap,
+ tap,
+ withLatestFrom,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -40,6 +43,13 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi
export class VaultPopupItemsService {
private _refreshCurrentTab$ = new Subject();
private _searchText$ = new BehaviorSubject("");
+
+ /**
+ * Subject that emits whenever new ciphers are being processed/filtered.
+ * @private
+ */
+ private _ciphersLoading$ = new Subject();
+
latestSearchText$: Observable = this._searchText$.asObservable();
/**
@@ -84,6 +94,7 @@ export class VaultPopupItemsService {
this.cipherService.localData$,
).pipe(
runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular
+ tap(() => this._ciphersLoading$.next()),
switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())),
switchMap((ciphers) =>
combineLatest([
@@ -112,6 +123,7 @@ export class VaultPopupItemsService {
this._searchText$,
this.vaultPopupListFiltersService.filterFunction$,
]).pipe(
+ tap(() => this._ciphersLoading$.next()),
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
filterFunction(ciphers),
searchText,
@@ -148,10 +160,8 @@ export class VaultPopupItemsService {
* List of favorite ciphers that are not currently suggested for autofill.
* Ciphers are sorted by last used date, then by name.
*/
- favoriteCiphers$: Observable = combineLatest([
- this.autoFillCiphers$,
- this._filteredCipherList$,
- ]).pipe(
+ favoriteCiphers$: Observable = this.autoFillCiphers$.pipe(
+ withLatestFrom(this._filteredCipherList$),
map(([autoFillCiphers, ciphers]) =>
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
),
@@ -165,12 +175,9 @@ export class VaultPopupItemsService {
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name.
*/
- remainingCiphers$: Observable = combineLatest([
- this.autoFillCiphers$,
- this.favoriteCiphers$,
- this._filteredCipherList$,
- ]).pipe(
- map(([autoFillCiphers, favoriteCiphers, ciphers]) =>
+ remainingCiphers$: Observable = this.favoriteCiphers$.pipe(
+ withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$),
+ map(([favoriteCiphers, ciphers, autoFillCiphers]) =>
ciphers.filter(
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
),
@@ -179,6 +186,14 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
+ /**
+ * Observable that indicates whether the service is currently loading ciphers.
+ */
+ loading$: Observable = merge(
+ this._ciphersLoading$.pipe(map(() => true)),
+ this.remainingCiphers$.pipe(map(() => false)),
+ ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
+
/**
* Observable that indicates whether a filter is currently applied to the ciphers.
*/
diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts
index 907ff9af8d6..b89de79a209 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts
@@ -3,6 +3,8 @@ import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skipWhile } from "rxjs";
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 { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -23,6 +25,7 @@ describe("VaultPopupListFiltersService", () => {
const folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({});
const decryptedCollections$ = new BehaviorSubject([]);
+ const policyAppliesToActiveUser$ = new BehaviorSubject(false);
const collectionService = {
decryptedCollections$,
@@ -45,9 +48,15 @@ describe("VaultPopupListFiltersService", () => {
t: (key: string) => key,
} as I18nService;
+ const policyService = {
+ policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$),
+ };
+
beforeEach(() => {
memberOrganizations$.next([]);
decryptedCollections$.next([]);
+ policyAppliesToActiveUser$.next(false);
+ policyService.policyAppliesToActiveUser$.mockClear();
collectionService.getAllNested = () => Promise.resolve([]);
TestBed.configureTestingModule({
@@ -72,6 +81,10 @@ describe("VaultPopupListFiltersService", () => {
provide: CollectionService,
useValue: collectionService,
},
+ {
+ provide: PolicyService,
+ useValue: policyService,
+ },
{ provide: FormBuilder, useClass: FormBuilder },
],
});
@@ -127,6 +140,65 @@ describe("VaultPopupListFiltersService", () => {
});
});
+ describe("PersonalOwnership policy", () => {
+ it('calls policyAppliesToActiveUser$ with "PersonalOwnership"', () => {
+ expect(policyService.policyAppliesToActiveUser$).toHaveBeenCalledWith(
+ PolicyType.PersonalOwnership,
+ );
+ });
+
+ it("returns an empty array when the policy applies and there is a single organization", (done) => {
+ policyAppliesToActiveUser$.next(true);
+ memberOrganizations$.next([
+ { name: "bobby's org", id: "1234-3323-23223" },
+ ] as Organization[]);
+
+ service.organizations$.subscribe((organizations) => {
+ expect(organizations).toEqual([]);
+ done();
+ });
+ });
+
+ it('adds "myVault" when the policy does not apply and there are multiple organizations', (done) => {
+ policyAppliesToActiveUser$.next(false);
+ const orgs = [
+ { name: "bobby's org", id: "1234-3323-23223" },
+ { name: "alice's org", id: "2223-4343-99888" },
+ ] as Organization[];
+
+ memberOrganizations$.next(orgs);
+
+ service.organizations$.subscribe((organizations) => {
+ expect(organizations.map((o) => o.label)).toEqual([
+ "myVault",
+ "alice's org",
+ "bobby's org",
+ ]);
+ done();
+ });
+ });
+
+ it('does not add "myVault" the policy applies and there are multiple organizations', (done) => {
+ policyAppliesToActiveUser$.next(true);
+ const orgs = [
+ { name: "bobby's org", id: "1234-3323-23223" },
+ { name: "alice's org", id: "2223-3242-99888" },
+ { name: "catherine's org", id: "77733-4343-99888" },
+ ] as Organization[];
+
+ memberOrganizations$.next(orgs);
+
+ service.organizations$.subscribe((organizations) => {
+ expect(organizations.map((o) => o.label)).toEqual([
+ "alice's org",
+ "bobby's org",
+ "catherine's org",
+ ]);
+ done();
+ });
+ });
+ });
+
describe("icons", () => {
it("sets family icon for family organizations", (done) => {
const orgs = [
diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts
index 6406e43446d..66e264dd6de 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts
@@ -13,6 +13,8 @@ import {
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
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 { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -88,6 +90,7 @@ export class VaultPopupListFiltersService {
private i18nService: I18nService,
private collectionService: CollectionService,
private formBuilder: FormBuilder,
+ private policyService: PolicyService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
@@ -167,44 +170,63 @@ export class VaultPopupListFiltersService {
/**
* Organization array structured to be directly passed to `ChipSelectComponent`
*/
- organizations$: Observable[]> =
- this.organizationService.memberOrganizations$.pipe(
- map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))),
- map((orgs) => {
- if (!orgs.length) {
- return [];
- }
+ organizations$: Observable[]> = combineLatest([
+ this.organizationService.memberOrganizations$,
+ this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
+ ]).pipe(
+ map(([orgs, personalOwnershipApplies]): [Organization[], boolean] => [
+ orgs.sort(Utils.getSortFunction(this.i18nService, "name")),
+ personalOwnershipApplies,
+ ]),
+ map(([orgs, personalOwnershipApplies]) => {
+ // When there are no organizations return an empty array,
+ // resulting in the org filter being hidden
+ if (!orgs.length) {
+ return [];
+ }
- return [
- // When the user is a member of an organization, make the "My Vault" option available
- {
- value: { id: MY_VAULT_ID } as Organization,
- label: this.i18nService.t("myVault"),
- icon: "bwi-user",
- },
- ...orgs.map((org) => {
- let icon = "bwi-business";
+ // When there is only one organization and personal ownership policy applies,
+ // return an empty array, resulting in the org filter being hidden
+ if (orgs.length === 1 && personalOwnershipApplies) {
+ return [];
+ }
- if (!org.enabled) {
- // Show a warning icon if the organization is deactivated
- icon = "bwi-exclamation-triangle tw-text-danger";
- } else if (
- org.planProductType === ProductType.Families ||
- org.planProductType === ProductType.Free
- ) {
- // Show a family icon if the organization is a family or free org
- icon = "bwi-family";
- }
+ const myVaultOrg: ChipSelectOption[] = [];
- return {
- value: org,
- label: org.name,
- icon,
- };
- }),
- ];
- }),
- );
+ // Only add "My vault" if personal ownership policy does not apply
+ if (!personalOwnershipApplies) {
+ myVaultOrg.push({
+ value: { id: MY_VAULT_ID } as Organization,
+ label: this.i18nService.t("myVault"),
+ icon: "bwi-user",
+ });
+ }
+
+ return [
+ ...myVaultOrg,
+ ...orgs.map((org) => {
+ let icon = "bwi-business";
+
+ if (!org.enabled) {
+ // Show a warning icon if the organization is deactivated
+ icon = "bwi-exclamation-triangle tw-text-danger";
+ } else if (
+ org.planProductType === ProductType.Families ||
+ org.planProductType === ProductType.Free
+ ) {
+ // Show a family icon if the organization is a family or free org
+ icon = "bwi-family";
+ }
+
+ return {
+ value: org,
+ label: org.name,
+ icon,
+ };
+ }),
+ ];
+ }),
+ );
/**
* Folder array structured to be directly passed to `ChipSelectComponent`
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 1ad09cc17a5..e992c3b6725 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -80,7 +80,7 @@
"papaparse": "5.4.1",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
- "tldts": "6.1.22",
+ "tldts": "6.1.25",
"zxcvbn": "4.4.2"
}
}
diff --git a/apps/cli/src/admin-console/models/request/organization-collection.request.ts b/apps/cli/src/admin-console/models/request/organization-collection.request.ts
index 7546d116092..1bb7a24ce77 100644
--- a/apps/cli/src/admin-console/models/request/organization-collection.request.ts
+++ b/apps/cli/src/admin-console/models/request/organization-collection.request.ts
@@ -9,8 +9,10 @@ export class OrganizationCollectionRequest extends CollectionExport {
req.name = "Collection name";
req.externalId = null;
req.groups = [SelectionReadOnly.template(), SelectionReadOnly.template()];
+ req.users = [SelectionReadOnly.template(), SelectionReadOnly.template()];
return req;
}
groups: SelectionReadOnly[];
+ users: SelectionReadOnly[];
}
diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts
index e64ff8b5512..75cd241207c 100644
--- a/apps/cli/src/commands/edit.command.ts
+++ b/apps/cli/src/commands/edit.command.ts
@@ -170,10 +170,17 @@ export class EditCommand {
: req.groups.map(
(g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage),
);
+ const users =
+ req.users == null
+ ? null
+ : req.users.map(
+ (u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
+ );
const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString;
request.externalId = req.externalId;
request.groups = groups;
+ request.users = users;
const response = await this.apiService.putCollection(req.organizationId, id, request);
const view = CollectionExport.toView(req);
view.id = response.id;
diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts
index 2b1e21f8abb..8949e5b71e5 100644
--- a/apps/cli/src/commands/serve.command.ts
+++ b/apps/cli/src/commands/serve.command.ts
@@ -87,6 +87,7 @@ export class ServeCommand {
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
this.serviceContainer.billingAccountProfileStateService,
+ this.serviceContainer.organizationService,
);
this.editCommand = new EditCommand(
this.serviceContainer.cipherService,
diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts
index c8e0701845b..04ca47ac1e1 100644
--- a/apps/cli/src/vault.program.ts
+++ b/apps/cli/src/vault.program.ts
@@ -226,6 +226,7 @@ export class VaultProgram extends BaseProgram {
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
this.serviceContainer.billingAccountProfileStateService,
+ this.serviceContainer.organizationService,
);
const response = await command.run(object, encodedJson, cmd);
this.processResponse(response);
diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts
index 78ee04e73c0..716c2b42bb1 100644
--- a/apps/cli/src/vault/create.command.ts
+++ b/apps/cli/src/vault/create.command.ts
@@ -4,6 +4,7 @@ import * as path from "path";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { CipherExport } from "@bitwarden/common/models/export/cipher.export";
@@ -32,6 +33,7 @@ export class CreateCommand {
private apiService: ApiService,
private folderApiService: FolderApiServiceAbstraction,
private accountProfileService: BillingAccountProfileStateService,
+ private organizationService: OrganizationService,
) {}
async run(
@@ -183,6 +185,8 @@ export class CreateCommand {
if (orgKey == null) {
throw new Error("No encryption key for this organization.");
}
+ const organization = await this.organizationService.get(req.organizationId);
+ const currentOrgUserId = organization.organizationUserId;
const groups =
req.groups == null
@@ -190,10 +194,17 @@ export class CreateCommand {
: req.groups.map(
(g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage),
);
+ const users =
+ req.users == null
+ ? [new SelectionReadOnlyRequest(currentOrgUserId, false, false, true)]
+ : req.users.map(
+ (u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
+ );
const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString;
request.externalId = req.externalId;
request.groups = groups;
+ request.users = users;
const response = await this.apiService.postCollection(req.organizationId, request);
const view = CollectionExport.toView(req);
view.id = response.id;
diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts
index 63431cd6abe..e06a9aa8dc7 100644
--- a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts
+++ b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts
@@ -80,7 +80,6 @@ export class InternalGroupService extends GroupService {
async save(group: GroupView): Promise {
const request = new GroupRequest();
request.name = group.name;
- request.accessAll = group.accessAll;
request.users = group.members;
request.collections = group.collections.map(
(c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords, c.manage),
diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts b/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts
index b59c8696928..40f253d9452 100644
--- a/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts
+++ b/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts
@@ -2,7 +2,6 @@ import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models
export class GroupRequest {
name: string;
- accessAll: boolean;
collections: SelectionReadOnlyRequest[] = [];
users: string[] = [];
}
diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts b/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts
index e969de4ad1f..eb62d83712f 100644
--- a/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts
+++ b/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts
@@ -5,11 +5,6 @@ export class GroupResponse extends BaseResponse {
id: string;
organizationId: string;
name: string;
- /**
- * @deprecated
- * To be removed after Flexible Collections.
- **/
- accessAll: boolean;
externalId: string;
constructor(response: any) {
@@ -17,7 +12,6 @@ export class GroupResponse extends BaseResponse {
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
- this.accessAll = this.getResponseProperty("AccessAll");
this.externalId = this.getResponseProperty("ExternalId");
}
}
diff --git a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts
index 399140e3ea6..52a522c89da 100644
--- a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts
+++ b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts
@@ -41,7 +41,6 @@ export class UserAdminService {
async save(user: OrganizationUserAdminView): Promise {
const request = new OrganizationUserUpdateRequest();
- request.accessAll = user.accessAll;
request.permissions = user.permissions;
request.type = user.type;
request.collections = user.collections;
@@ -54,7 +53,6 @@ export class UserAdminService {
async invite(emails: string[], user: OrganizationUserAdminView): Promise {
const request = new OrganizationUserInviteRequest();
request.emails = emails;
- request.accessAll = user.accessAll;
request.permissions = user.permissions;
request.type = user.type;
request.collections = user.collections;
@@ -77,7 +75,6 @@ export class UserAdminService {
view.type = u.type;
view.status = u.status;
view.externalId = u.externalId;
- view.accessAll = u.accessAll;
view.permissions = u.permissions;
view.resetPasswordEnrolled = u.resetPasswordEnrolled;
view.collections = u.collections.map((c) => ({
diff --git a/apps/web/src/app/admin-console/organizations/core/views/group.view.ts b/apps/web/src/app/admin-console/organizations/core/views/group.view.ts
index 25864cca348..1909b9a863c 100644
--- a/apps/web/src/app/admin-console/organizations/core/views/group.view.ts
+++ b/apps/web/src/app/admin-console/organizations/core/views/group.view.ts
@@ -8,12 +8,6 @@ export class GroupView implements View {
id: string;
organizationId: string;
name: string;
- /**
- * @deprecated
- * To be removed after Flexible Collections.
- * This will always return `false` if Flexible Collections is enabled.
- **/
- accessAll: boolean;
externalId: string;
collections: CollectionAccessSelectionView[] = [];
members: string[] = [];
diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts
index b4241826b3f..97e77d8543c 100644
--- a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts
+++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts
@@ -13,12 +13,6 @@ export class OrganizationUserAdminView {
type: OrganizationUserType;
status: OrganizationUserStatusType;
externalId: string;
- /**
- * @deprecated
- * To be removed after Flexible Collections.
- * This will always return `false` if Flexible Collections is enabled.
- **/
- accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
hasMasterPassword: boolean;
diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts
index 947ae9b13eb..86d1f4ded6b 100644
--- a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts
+++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts
@@ -12,12 +12,6 @@ export class OrganizationUserView {
userId: string;
type: OrganizationUserType;
status: OrganizationUserStatusType;
- /**
- * @deprecated
- * To be removed after Flexible Collections.
- * This will always return `false` if Flexible Collections is enabled.
- **/
- accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
name: string;
diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html
index 237e2c6e30c..445a0855c1b 100644
--- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html
+++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html
@@ -11,7 +11,7 @@
diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html
index 166467ada09..eaf10405dbf 100644
--- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html
+++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html
@@ -45,7 +45,6 @@
[columnHeader]="'member' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
- [flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
>
@@ -56,24 +55,14 @@
{{ "restrictedCollectionAssignmentDesc" | i18n }}
-
-
-
-
{{ "accessAllCollectionsHelp" | i18n }}
-
-
-
-
+
diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts
index 38ef0025349..8df770686f4 100644
--- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts
+++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts
@@ -96,9 +96,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private organization$ = this.organizationService
.get$(this.organizationId)
.pipe(shareReplay({ refCount: true }));
- protected flexibleCollectionsEnabled$ = this.organization$.pipe(
- map((o) => o?.flexibleCollections),
- );
private flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollectionsV1,
);
@@ -114,7 +111,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
group: GroupView;
groupForm = this.formBuilder.group({
- accessAll: [false],
name: ["", [Validators.required, Validators.maxLength(100)]],
externalId: this.formBuilder.control({ value: "", disabled: true }),
members: [[] as AccessItemValue[]],
@@ -188,7 +184,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.flexibleCollectionsV1Enabled$,
]).pipe(
map(([organization, flexibleCollectionsV1Enabled]) => {
- if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
+ if (!flexibleCollectionsV1Enabled) {
return true;
}
@@ -276,7 +272,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupForm.patchValue({
name: this.group.name,
externalId: this.group.externalId,
- accessAll: this.group.accessAll,
members: this.group.members.map((m) => ({
id: m,
type: AccessItemType.Member,
@@ -328,12 +323,8 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
const formValue = this.groupForm.value;
groupView.name = formValue.name;
- groupView.accessAll = formValue.accessAll;
groupView.members = formValue.members?.map((m) => m.id) ?? [];
-
- if (!groupView.accessAll) {
- groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
- }
+ groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
await this.groupService.save(groupView);
diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.html b/apps/web/src/app/admin-console/organizations/manage/groups.component.html
index f256c29b057..1a1a7cdb904 100644
--- a/apps/web/src/app/admin-console/organizations/manage/groups.component.html
+++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.html
@@ -74,12 +74,10 @@
- {{ "all" | i18n }}
|
|
|