1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 22:44:11 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Victoria League
2024-11-07 16:03:00 -05:00
committed by GitHub
20 changed files with 405 additions and 152 deletions

View File

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

View File

@@ -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,10 +398,14 @@ jobs:
- cli
- cli-windows
- snap
- check-run
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

View File

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

View File

@@ -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
@@ -282,9 +298,11 @@ 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
needs:
- build-artifacts
- check-run
steps:
- name: Login to Azure - CI Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@@ -326,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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<boolean>(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<Organization>(testOrg as Organization);
const organizations$ = new BehaviorSubject<Organization[]>([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);
});
});
});

View File

@@ -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<CipherFormConfig> {
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<Cipher | null> {
private async getCipher(id?: CipherId): Promise<Cipher | null> {
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;

View File

@@ -584,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction {
}
putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any> {
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false);
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true);
}
postPurgeCiphers(
@@ -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);

View File

@@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* Used for Unassigned ciphers or when the user only has admin access to the cipher (not assigned normally).
* @param cipher
*/
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<void>;
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<Cipher>;
/**
* Bulk update collections for many ciphers with the server
* @param orgId

View File

@@ -880,9 +880,11 @@ export class CipherService implements CipherServiceAbstraction {
return new Cipher(updated[cipher.id as CipherId], cipher.localData);
}
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<void> {
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher> {
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);
}
/**

View File

@@ -2,6 +2,7 @@ import { Constraint } from "@bitwarden/common/tools/types";
import { sum } from "../util";
const Zero: Constraint<number> = { min: 0, max: 0 };
const AtLeastOne: Constraint<number> = { min: 1 };
const RequiresTrue: Constraint<boolean> = { requiredValue: true };
@@ -159,6 +160,7 @@ export {
enforceConstant,
readonlyTrueWhen,
fitLength,
Zero,
AtLeastOne,
RequiresTrue,
};

View File

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

View File

@@ -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<number>(lowercase, this.constraints.minLowercase ?? AtLeastOne),
minUppercase: maybe<number>(uppercase, this.constraints.minUppercase ?? AtLeastOne),
minNumber: maybe<number>(number, this.constraints.minNumber),
minSpecial: maybe<number>(special, this.constraints.minSpecial),
minNumber: maybe<number>(number, this.constraints.minNumber) ?? Zero,
minSpecial: maybe<number>(special, this.constraints.minSpecial) ?? Zero,
};
// lower bound of length must always at least fit its sub-lengths

View File

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

View File

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

View File

@@ -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<string>, b: Set<string>) {
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<CipherView> {
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

View File

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