1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 02:51:24 +00:00

Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval

This commit is contained in:
rr-bw
2025-10-29 14:08:38 -07:00
157 changed files with 2948 additions and 729 deletions

View File

@@ -794,6 +794,9 @@ export class VaultComponent implements OnInit, OnDestroy {
case "viewEvents":
await this.viewEvents(event.item);
break;
case "editCipher":
await this.editCipher(event.item);
break;
}
} finally {
this.processingEvent$.next(false);
@@ -856,7 +859,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* @param cipherView - When set, the cipher to be edited
* @param cloneCipher - `true` when the cipher should be cloned.
*/
async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) {
async editCipher(cipher: CipherView | undefined, cloneCipher?: boolean) {
if (
cipher &&
cipher.reprompt !== 0 &&

View File

@@ -1,13 +1,17 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import {
CollectionService,
OrganizationUserApiService,
OrganizationUserUserDetailsResponse,
} 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 { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { GroupApiService } from "../../../core";
@@ -18,6 +22,9 @@ describe("OrganizationMembersService", () => {
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let groupService: jest.Mocked<GroupApiService>;
let apiService: jest.Mocked<ApiService>;
let keyService: jest.Mocked<KeyService>;
let accountService: jest.Mocked<AccountService>;
let collectionService: jest.Mocked<CollectionService>;
const mockOrganizationId = "org-123" as OrganizationId;
@@ -51,6 +58,7 @@ describe("OrganizationMembersService", () => {
const createMockCollection = (id: string, name: string) => ({
id,
name,
organizationId: mockOrganizationId,
});
beforeEach(() => {
@@ -66,12 +74,27 @@ describe("OrganizationMembersService", () => {
getCollections: jest.fn(),
} as any;
keyService = {
orgKeys$: jest.fn(),
} as any;
accountService = {
activeAccount$: of({ id: "user-123" } as any),
} as any;
collectionService = {
decryptMany$: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationMembersService,
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: GroupApiService, useValue: groupService },
{ provide: ApiService, useValue: apiService },
{ provide: KeyService, useValue: keyService },
{ provide: AccountService, useValue: accountService },
{ provide: CollectionService, useValue: collectionService },
],
});
@@ -88,11 +111,15 @@ describe("OrganizationMembersService", () => {
data: [mockUser],
} as any;
const mockCollections = [createMockCollection("col-1", "Collection 1")];
const mockOrgKey = { [mockOrganizationId]: {} as any };
const mockDecryptedCollections = [{ id: "col-1", name: "Collection 1" }];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any));
const result = await service.loadUsers(organization);
@@ -171,11 +198,19 @@ describe("OrganizationMembersService", () => {
createMockCollection("col-2", "Alpha Collection"),
createMockCollection("col-3", "Beta Collection"),
];
const mockOrgKey = { [mockOrganizationId]: {} as any };
const mockDecryptedCollections = [
{ id: "col-1", name: "Zebra Collection" },
{ id: "col-2", name: "Alpha Collection" },
{ id: "col-3", name: "Beta Collection" },
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any));
const result = await service.loadUsers(organization);
@@ -223,11 +258,19 @@ describe("OrganizationMembersService", () => {
// col-2 is missing - should be filtered out
createMockCollection("col-3", "Collection 3"),
];
const mockOrgKey = { [mockOrganizationId]: {} as any };
const mockDecryptedCollections = [
{ id: "col-1", name: "Collection 1" },
// col-2 is missing - should be filtered out
{ id: "col-3", name: "Collection 3" },
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any));
const result = await service.loadUsers(organization);
@@ -269,11 +312,14 @@ describe("OrganizationMembersService", () => {
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: null as any,
} as any;
const mockOrgKey = { [mockOrganizationId]: {} as any };
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of([]));
const result = await service.loadUsers(organization);
@@ -285,11 +331,14 @@ describe("OrganizationMembersService", () => {
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: undefined as any,
} as any;
const mockOrgKey = { [mockOrganizationId]: {} as any };
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of([]));
const result = await service.loadUsers(organization);
@@ -322,11 +371,14 @@ describe("OrganizationMembersService", () => {
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockOrgKey = { [mockOrganizationId]: {} as any };
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of([]));
const result = await service.loadUsers(organization);

View File

@@ -1,8 +1,18 @@
import { Injectable } from "@angular/core";
import { combineLatest, firstValueFrom, from, map, switchMap } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
Collection,
CollectionData,
CollectionDetailsResponse,
CollectionService,
OrganizationUserApiService,
} 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 { getUserId } from "@bitwarden/common/auth/services/account.service";
import { KeyService } from "@bitwarden/key-management";
import { GroupApiService } from "../../../core";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@@ -13,6 +23,9 @@ export class OrganizationMembersService {
private organizationUserApiService: OrganizationUserApiService,
private groupService: GroupApiService,
private apiService: ApiService,
private keyService: KeyService,
private accountService: AccountService,
private collectionService: CollectionService,
) {}
async loadUsers(organization: Organization): Promise<OrganizationUserView[]> {
@@ -62,15 +75,38 @@ export class OrganizationMembersService {
}
private async getCollectionNameMap(organization: Organization): Promise<Map<string, string>> {
const response = this.apiService
.getCollections(organization.id)
.then((res) =>
res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })),
);
const collections$ = from(this.apiService.getCollections(organization.id)).pipe(
map((response) => {
return response.data.map((r) =>
Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)),
);
}),
);
const collections = await response;
const collectionMap = new Map<string, string>();
collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name));
return collectionMap;
const orgKey$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys;
}),
);
return await firstValueFrom(
combineLatest([orgKey$, collections$]).pipe(
switchMap(([orgKey, collections]) =>
this.collectionService.decryptMany$(collections, orgKey),
),
map((decryptedCollections) => {
const collectionMap: Map<string, string> = new Map<string, string>();
decryptedCollections.forEach((c) => {
collectionMap.set(c.id, c.name);
});
return collectionMap;
}),
),
);
}
}

View File

@@ -33,6 +33,8 @@ import { KeyService, BiometricStateService } from "@bitwarden/key-management";
const BroadcasterSubscriptionId = "AppComponent";
const IdleTimeout = 60000 * 10; // 10 minutes
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-root",
templateUrl: "app.component.html",

View File

@@ -38,7 +38,7 @@
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
[button]="{ type: 'secondary', text: ('upgradeToFamilies' | i18n) }"
[button]="{ type: 'secondary', text: ('startFreeFamiliesTrial' | i18n) }"
[features]="familiesData.features"
(buttonClick)="openUpgradeDialog('Families')"
>

View File

@@ -98,7 +98,7 @@ describe("UpgradeAccountComponent", () => {
expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12);
expect(sut["familiesCardDetails"].price.cadence).toBe("monthly");
expect(sut["familiesCardDetails"].button.type).toBe("secondary");
expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies");
expect(sut["familiesCardDetails"].button.text).toBe("startFreeFamiliesTrial");
expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]);
});

View File

@@ -119,7 +119,7 @@ export class UpgradeAccountComponent implements OnInit {
},
button: {
text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium",
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",
),
type: buttonType,
},

View File

@@ -161,7 +161,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });

View File

@@ -38,7 +38,6 @@ describe("FreeFamiliesPolicyService", () => {
describe("showSponsoredFamiliesDropdown$", () => {
it("should return true when all conditions are met", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets all criteria
@@ -58,7 +57,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when organization is not Enterprise", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that is not Enterprise tier
@@ -74,27 +72,8 @@ describe("FreeFamiliesPolicyService", () => {
expect(result).toBe(false);
});
it("should return false when feature flag is disabled", async () => {
// Configure mocks to disable feature flag
configService.getFeatureFlag$.mockReturnValue(of(false));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets other criteria
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
it("should return false when families feature is disabled by policy", async () => {
// Configure mocks with a policy that disables the feature
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(
of([{ organizationId: "org-id", enabled: true } as Policy]),
);
@@ -114,7 +93,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when useAdminSponsoredFamilies is false", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization with useAdminSponsoredFamilies set to false
@@ -132,7 +110,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return true when user is an owner but not admin", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user is owner but not admin
@@ -152,7 +129,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return true when user can manage users but is not admin or owner", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user can manage users but is not admin or owner
@@ -172,7 +148,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when user has no admin permissions", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user has no admin permissions

View File

@@ -8,8 +8,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
interface EnterpriseOrgStatus {
isFreeFamilyPolicyEnabled: boolean;
@@ -23,7 +21,6 @@ export class FreeFamiliesPolicyService {
private policyService: PolicyService,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
) {}
organizations$ = this.accountService.activeAccount$.pipe(
@@ -58,20 +55,14 @@ export class FreeFamiliesPolicyService {
userId,
);
return combineLatest([
enterpriseOrganization$,
this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships),
organization,
policies$,
]).pipe(
map(([isEnterprise, featureFlagEnabled, org, policies]) => {
return combineLatest([enterpriseOrganization$, organization, policies$]).pipe(
map(([isEnterprise, org, policies]) => {
const familiesFeatureDisabled = policies.some(
(policy) => policy.organizationId === org.id && policy.enabled,
);
return (
isEnterprise &&
featureFlagEnabled &&
!familiesFeatureDisabled &&
org.useAdminSponsoredFamilies &&
(org.isAdmin || org.isOwner || org.canManageUsers)

View File

@@ -8,6 +8,8 @@ import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.servic
import { SharedModule } from "../shared";
type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dynamic-avatar",
imports: [SharedModule],
@@ -25,10 +27,20 @@ type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall";
</span>`,
})
export class DynamicAvatarComponent implements OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() border = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() id: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() text: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() size: SizeTypes = "default";
private destroy$ = new Subject<void>();

View File

@@ -12,6 +12,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SharedModule } from "../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "environment-selector",
templateUrl: "environment-selector.component.html",

View File

@@ -121,4 +121,153 @@ describe("InactiveTwoFactorReportComponent", () => {
it("should call fullSync method of syncService", () => {
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(false);
});
describe("isInactive2faCipher", () => {
beforeEach(() => {
// Add both domain and host to services map
component.services.set("example.com", "https://example.com/2fa-doc");
component.services.set("sub.example.com", "https://sub.example.com/2fa-doc");
fixture.detectChanges();
});
it("should return true and documentation for cipher with matching domain", () => {
const cipher = createCipherView({
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(true);
expect(doc).toBe("https://example.com/2fa-doc");
});
it("should return true and documentation for cipher with matching host", () => {
const cipher = createCipherView({
login: {
uris: [{ uri: "https://sub.example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(true);
expect(doc).toBe("https://sub.example.com/2fa-doc");
});
it("should return false for cipher with non-matching domain or host", () => {
const cipher = createCipherView({
login: {
uris: [{ uri: "https://otherdomain.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher type is not Login", () => {
const cipher = createCipherView({
type: 2,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher has TOTP", () => {
const cipher = createCipherView({
login: {
totp: "some-totp",
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher is deleted", () => {
const cipher = createCipherView({
isDeleted: true,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher does not have edit access and no organization", () => {
component.organization = null;
const cipher = createCipherView({
edit: false,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher does not have viewPassword", () => {
const cipher = createCipherView({
viewPassword: false,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should check all uris and return true if any matches domain or host", () => {
const cipher = createCipherView({
login: {
uris: [
{ uri: "https://otherdomain.com/login" },
{ uri: "https://sub.example.com/dashboard" },
],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(true);
expect(doc).toBe("https://sub.example.com/2fa-doc");
});
it("should return false if uris array is empty", () => {
const cipher = createCipherView({
login: {
uris: [],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
function createCipherView({
type = 1,
login = {},
isDeleted = false,
edit = true,
viewPassword = true,
}: any): any {
return {
id: "test-id",
type,
login: {
totp: null,
hasUris: true,
uris: [],
...login,
},
isDeleted,
edit,
viewPassword,
};
}
});
});

View File

@@ -109,7 +109,18 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
const u = login.uris[i];
if (u.uri != null && u.uri !== "") {
const uri = u.uri.replace("www.", "");
const host = Utils.getHost(uri);
const domain = Utils.getDomain(uri);
// check host first
if (host != null && this.services.has(host)) {
if (this.services.get(host) != null) {
docFor2fa = this.services.get(host) || "";
}
isInactive2faCipher = true;
break;
}
// then check domain
if (domain != null && this.services.has(domain)) {
if (this.services.get(domain) != null) {
docFor2fa = this.services.get(domain) || "";

View File

@@ -19,6 +19,8 @@ import { RequestSMAccessRequest } from "../models/requests/request-sm-access.req
import { SmLandingApiService } from "./sm-landing-api.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-request-sm-access",
templateUrl: "request-sm-access.component.html",

View File

@@ -12,6 +12,8 @@ import { NoItemsModule, SearchModule } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared/shared.module";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-sm-landing",
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule],

View File

@@ -12,6 +12,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { HeaderModule } from "../layouts/header/header.module";
import { SharedModule } from "../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-domain-rules",
templateUrl: "domain-rules.component.html",

View File

@@ -39,6 +39,8 @@ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { HeaderModule } from "../layouts/header/header.module";
import { SharedModule } from "../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-preferences",
templateUrl: "preferences.component.html",

View File

@@ -169,10 +169,12 @@
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
@if (!viewingOrgVault) {
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
}
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}

View File

@@ -162,7 +162,7 @@
[showPremiumFeatures]="showPremiumFeatures"
[useEvents]="useEvents"
[viewingOrgVault]="viewingOrgVault"
[cloneable]="canClone(item)"
[cloneable]="canClone$(item) | async"
[organizations]="allOrganizations"
[collections]="allCollections"
[checked]="selection.isSelected(item)"

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { toSignal, takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
@@ -111,8 +111,6 @@ export class VaultItemsComponent<C extends CipherViewLike> {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() enforceOrgDataOwnershipPolicy: boolean;
private readonly restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
private _ciphers?: C[] = [];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@@ -390,37 +388,22 @@ export class VaultItemsComponent<C extends CipherViewLike> {
});
}
// TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead
protected canClone(vaultItem: VaultItem<C>) {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = this.restrictedPolicies().some(
(rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher),
protected canClone$(vaultItem: VaultItem<C>): Observable<boolean> {
return this.restrictedItemTypesService.restricted$.pipe(
switchMap((restrictedTypes) => {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = restrictedTypes.some(
(rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher),
);
if (isItemRestricted) {
return of(false);
}
return this.cipherAuthorizationService.canCloneCipher$(
vaultItem.cipher,
this.showAdminActions,
);
}),
);
if (isItemRestricted) {
return false;
}
if (vaultItem.cipher.organizationId == null) {
return true;
}
const org = this.allOrganizations.find((o) => o.id === vaultItem.cipher.organizationId);
// Admins and custom users can always clone in the Org Vault
if (this.viewingOrgVault && (org.isAdmin || org.permissions.editAnyCollection)) {
return true;
}
// Check if the cipher belongs to a collection with canManage permission
const orgCollections = this.allCollections.filter((c) => c.organizationId === org.id);
for (const collection of orgCollections) {
if (vaultItem.cipher.collectionIds.includes(collection.id as any) && collection.manage) {
return true;
}
}
return false;
}
protected canEditCipher(cipher: C) {

View File

@@ -6495,17 +6495,32 @@
"tdeDisabledMasterPasswordRequired": {
"message": "Your organization has updated your decryption options. Please set a master password to access your vault."
},
"maximumVaultTimeout": {
"message": "Vault timeout"
"sessionTimeoutPolicyTitle": {
"message": "Session timeout"
},
"maximumVaultTimeoutDesc": {
"message": "Set a maximum vault timeout for members."
"sessionTimeoutPolicyDescription": {
"message": "Set a maximum session timeout for all members except owners."
},
"maximumVaultTimeoutLabel": {
"message": "Maximum vault timeout"
"maximumAllowedTimeout": {
"message": "Maximum allowed timeout"
},
"invalidMaximumVaultTimeout": {
"message": "Invalid maximum vault timeout."
"maximumAllowedTimeoutRequired": {
"message": "Maximum allowed timeout is required."
},
"sessionTimeoutPolicyInvalidTime": {
"message": "Time is invalid. Change at least one value."
},
"sessionTimeoutAction": {
"message": "Session timeout action"
},
"immediately": {
"message": "Immediately"
},
"onSystemLock": {
"message": "On system lock"
},
"onAppRestart": {
"message": "On app restart"
},
"hours": {
"message": "Hours"
@@ -6513,6 +6528,21 @@
"minutes": {
"message": "Minutes"
},
"sessionTimeoutConfirmationNeverTitle": {
"message": "Are you certain you want to allow a maximum timeout of \"Never\" for all members?"
},
"sessionTimeoutConfirmationNeverDescription": {
"message": "This option will save your members' encryption keys on their devices. If you choose this option, ensure that their devices are adequately protected."
},
"learnMoreAboutDeviceProtection": {
"message": "Learn more about device protection"
},
"sessionTimeoutConfirmationOnSystemLockTitle": {
"message": "\"System lock\" will only apply to the browser and desktop app"
},
"sessionTimeoutConfirmationOnSystemLockDescription": {
"message": "The mobile and web app will use \"on app restart\" as their maximum allowed timeout, since the option is not supported."
},
"vaultTimeoutPolicyInEffect": {
"message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
@@ -11945,5 +11975,8 @@
},
"cardNumberLabel": {
"message": "Card number"
},
"startFreeFamiliesTrial": {
"message": "Start free Families trial"
}
}