1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 03:43:58 +00:00

Merge remote-tracking branch 'origin' into auth/pm-26578/http-redirect-cloud

This commit is contained in:
Patrick Pimentel
2026-01-08 12:25:23 -05:00
111 changed files with 2928 additions and 1145 deletions

View File

@@ -1,70 +0,0 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
/**
* This guard is intended to prevent members of an organization from accessing
* routes based on compliance with organization
* policies. e.g Emergency access, which is a non-organization
* feature is restricted by the Auto Confirm policy.
*/
export function organizationPolicyGuard(
featureCallback: (
userId: UserId,
configService: ConfigService,
policyService: PolicyService,
) => Observable<boolean>,
): CanActivateFn {
return async () => {
const router = inject(Router);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
const accountService = inject(AccountService);
const policyService = inject(PolicyService);
const configService = inject(ConfigService);
const syncService = inject(SyncService);
const synced = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => syncService.lastSync$(userId)),
),
);
if (synced == null) {
await syncService.fullSync(false);
}
const compliant = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => featureCallback(userId, configService, policyService)),
tap((compliant) => {
if (typeof compliant !== "boolean") {
throw new Error("Feature callback must return a boolean.");
}
}),
),
);
if (!compliant) {
toastService.showToast({
variant: "error",
message: i18nService.t("noPageAccess"),
});
return router.createUrlTree(["/"]);
}
return compliant;
};
}

View File

@@ -1 +1,2 @@
export * from "./members.module";
export * from "./pipes";

View File

@@ -102,15 +102,25 @@
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
@@ -352,13 +362,16 @@
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<div class="tw-w-[32px]"></div>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">

View File

@@ -35,6 +35,7 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -55,7 +56,11 @@ import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import { MemberDialogManagerService, OrganizationMembersService } from "./services";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
@@ -119,6 +124,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private fileDownloadService: FileDownloadService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
@@ -593,4 +600,36 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
exportMembers = async (): Promise<void> => {
try {
const members = this.dataSource.data;
if (!members || members.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noMembersToExport"),
});
return;
}
const csvData = this.memberExportService.getMemberExport(members);
const fileName = this.memberExportService.getFileName("org-members");
this.fileDownloadService.download({
fileName: fileName,
blobData: csvData,
blobOptions: { type: "text/plain" },
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("dataExportSuccess"),
});
} catch (e) {
this.validationService.showError(e);
this.logService.error(`Failed to export members: ${e}`);
}
};
}

View File

@@ -19,10 +19,12 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import { UserStatusPipe } from "./pipes";
import {
OrganizationMembersService,
MemberActionsService,
MemberDialogManagerService,
MemberExportService,
} from "./services";
@NgModule({
@@ -45,12 +47,15 @@ import {
BulkStatusComponent,
MembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
MemberExportService,
UserStatusPipe,
],
})
export class MembersModule {}

View File

@@ -0,0 +1 @@
export * from "./user-status.pipe";

View File

@@ -0,0 +1,47 @@
import { MockProxy, mock } from "jest-mock-extended";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserStatusPipe } from "./user-status.pipe";
describe("UserStatusPipe", () => {
let pipe: UserStatusPipe;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
pipe = new UserStatusPipe(i18nService);
});
it("transforms OrganizationUserStatusType.Invited to 'invited'", () => {
expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited");
expect(i18nService.t).toHaveBeenCalledWith("invited");
});
it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => {
expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted");
expect(i18nService.t).toHaveBeenCalledWith("accepted");
});
it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => {
expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed");
expect(i18nService.t).toHaveBeenCalledWith("confirmed");
});
it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => {
expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked");
expect(i18nService.t).toHaveBeenCalledWith("revoked");
});
it("transforms null to 'unknown'", () => {
expect(pipe.transform(null)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
it("transforms undefined to 'unknown'", () => {
expect(pipe.transform(undefined)).toBe("unknown");
expect(i18nService.t).toHaveBeenCalledWith("unknown");
});
});

View File

@@ -0,0 +1,30 @@
import { Pipe, PipeTransform } from "@angular/core";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Pipe({
name: "userStatus",
standalone: false,
})
export class UserStatusPipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserStatusType): string {
if (value == null) {
return this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserStatusType.Invited:
return this.i18nService.t("invited");
case OrganizationUserStatusType.Accepted:
return this.i18nService.t("accepted");
case OrganizationUserStatusType.Confirmed:
return this.i18nService.t("confirmed");
case OrganizationUserStatusType.Revoked:
return this.i18nService.t("revoked");
default:
return this.i18nService.t("unknown");
}
}
}

View File

@@ -1,4 +1,5 @@
export { OrganizationMembersService } from "./organization-members-service/organization-members.service";
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { MemberExportService } from "./member-export";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";

View File

@@ -0,0 +1,2 @@
export * from "./member.export";
export * from "./member-export.service";

View File

@@ -0,0 +1,151 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExportService } from "./member-export.service";
describe("MemberExportService", () => {
let service: MemberExportService;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
// Setup common i18n translations
i18nService.t.mockImplementation((key: string) => {
const translations: Record<string, string> = {
// Column headers
email: "Email",
name: "Name",
status: "Status",
role: "Role",
twoStepLogin: "Two-step Login",
accountRecovery: "Account Recovery",
secretsManager: "Secrets Manager",
groups: "Groups",
// Status values
invited: "Invited",
accepted: "Accepted",
confirmed: "Confirmed",
revoked: "Revoked",
// Role values
owner: "Owner",
admin: "Admin",
user: "User",
custom: "Custom",
// Boolean states
enabled: "Enabled",
disabled: "Disabled",
enrolled: "Enrolled",
notEnrolled: "Not Enrolled",
};
return translations[key] || key;
});
TestBed.configureTestingModule({
providers: [
MemberExportService,
{ provide: I18nService, useValue: i18nService },
UserTypePipe,
UserStatusPipe,
],
});
service = TestBed.inject(MemberExportService);
});
describe("getMemberExport", () => {
it("should export members with all fields populated", () => {
const members: OrganizationUserView[] = [
{
email: "user1@example.com",
name: "User One",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
twoFactorEnabled: true,
resetPasswordEnrolled: true,
accessSecretsManager: true,
groupNames: ["Group A", "Group B"],
} as OrganizationUserView,
{
email: "user2@example.com",
name: "User Two",
status: OrganizationUserStatusType.Invited,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: ["Group C"],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
expect(csvData).toContain("user1@example.com");
expect(csvData).toContain("User One");
expect(csvData).toContain("Confirmed");
expect(csvData).toContain("Admin");
expect(csvData).toContain("user2@example.com");
expect(csvData).toContain("User Two");
expect(csvData).toContain("Invited");
});
it("should handle members with null name", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: null,
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: [],
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
// Empty name is represented as an empty field in CSV
expect(csvData).toContain("user@example.com,,Confirmed");
});
it("should handle members with no groups", () => {
const members: OrganizationUserView[] = [
{
email: "user@example.com",
name: "User",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.User,
twoFactorEnabled: false,
resetPasswordEnrolled: false,
accessSecretsManager: false,
groupNames: null,
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
expect(csvData).toContain("user@example.com");
expect(csvData).toBeDefined();
});
it("should handle empty members array", () => {
const csvData = service.getMemberExport([]);
// When array is empty, papaparse returns an empty string
expect(csvData).toBe("");
});
});
});

View File

@@ -0,0 +1,49 @@
import { inject, Injectable } from "@angular/core";
import * as papa from "papaparse";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
import { MemberExport } from "./member.export";
@Injectable()
export class MemberExportService {
private i18nService = inject(I18nService);
private userTypePipe = inject(UserTypePipe);
private userStatusPipe = inject(UserStatusPipe);
getMemberExport(members: OrganizationUserView[]): string {
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
const headers: string[] = [
this.i18nService.t("email"),
this.i18nService.t("name"),
this.i18nService.t("status"),
this.i18nService.t("role"),
this.i18nService.t("twoStepLogin"),
this.i18nService.t("accountRecovery"),
this.i18nService.t("secretsManager"),
this.i18nService.t("groups"),
];
return papa.unparse(exportData, {
columns: headers,
header: true,
});
}
getFileName(prefix: string | null = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -0,0 +1,43 @@
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
export class MemberExport {
/**
* @param user Organization user to export
* @returns a Record<string, string> of each column header key, value
* All property members must be a string for export purposes. Null and undefined will appear as
* "null" in a .csv export, therefore an empty string is preferable to a nullish type.
*/
static fromOrganizationUserView(
i18nService: I18nService,
userTypePipe: UserTypePipe,
userStatusPipe: UserStatusPipe,
user: OrganizationUserView,
): Record<string, string> {
const result = {
[i18nService.t("email")]: user.email,
[i18nService.t("name")]: user.name ?? "",
[i18nService.t("status")]: userStatusPipe.transform(user.status),
[i18nService.t("role")]: userTypePipe.transform(user.type),
[i18nService.t("twoStepLogin")]: user.twoFactorEnabled
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("accountRecovery")]: user.resetPasswordEnrolled
? i18nService.t("enrolled")
: i18nService.t("notEnrolled"),
[i18nService.t("secretsManager")]: user.accessSecretsManager
? i18nService.t("optionEnabled")
: i18nService.t("disabled"),
[i18nService.t("groups")]: user.groupNames?.join(", ") ?? "",
};
return result;
}
}

View File

@@ -22,7 +22,7 @@ import {
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";

View File

@@ -188,7 +188,7 @@ describe("PoliciesComponent", () => {
});
describe("orgPolicies$", () => {
it("should fetch policies from API for current organization", async () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: newGuid(),
@@ -206,39 +206,63 @@ describe("PoliciesComponent", () => {
},
];
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
beforeEach(async () => {
const listResponse = new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
mockPolicyApiService.getPolicies.mockResolvedValue(listResponse);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual(listResponse.data);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should fetch policies from API for current organization", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies.length).toBe(2);
expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId);
});
});
it("should return empty array when API returns no data", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns no data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
it("should return empty array when API returns null data", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
describe("with null data", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse),
);
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should return empty array when API returns null data", async () => {
const policies = await firstValueFrom(component["orgPolicies$"]);
expect(policies).toEqual([]);
});
});
});
describe("policiesEnabledMap$", () => {
it("should create a map of policy types to their enabled status", async () => {
describe("with multiple policies", () => {
const mockPolicyResponsesData = [
{
id: "policy-1",
@@ -263,27 +287,43 @@ describe("PoliciesComponent", () => {
},
];
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse(
{ Data: mockPolicyResponsesData, ContinuationToken: null },
PolicyResponse,
),
);
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create a map of policy types to their enabled status", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(3);
expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true);
expect(map.get(PolicyType.RequireSso)).toBe(false);
expect(map.get(PolicyType.SingleOrg)).toBe(true);
});
});
it("should create empty map when no policies exist", async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
describe("with no policies", () => {
beforeEach(async () => {
mockPolicyApiService.getPolicies.mockResolvedValue(
new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse),
);
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create empty map when no policies exist", async () => {
const map = await firstValueFrom(component.policiesEnabledMap$);
expect(map.size).toBe(0);
});
});
});
@@ -292,31 +332,36 @@ describe("PoliciesComponent", () => {
expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId);
});
it("should refresh policies when policyService emits", async () => {
const policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
describe("when policyService emits", () => {
let policiesSubject: BehaviorSubject<any[]>;
let callCount: number;
let callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
beforeEach(async () => {
policiesSubject = new BehaviorSubject<any[]>([]);
mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable());
callCount = 0;
mockPolicyApiService.getPolicies.mockImplementation(() => {
callCount++;
return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse));
});
fixture = TestBed.createComponent(PoliciesComponent);
fixture.detectChanges();
});
const newFixture = TestBed.createComponent(PoliciesComponent);
newFixture.detectChanges();
it("should refresh policies when policyService emits", () => {
const initialCallCount = callCount;
const initialCallCount = callCount;
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]);
expect(callCount).toBeGreaterThan(initialCallCount);
newFixture.destroy();
expect(callCount).toBeGreaterThan(initialCallCount);
});
});
});
describe("handleLaunchEvent", () => {
it("should open policy dialog when policyId is in query params", async () => {
describe("when policyId is in query params", () => {
const mockPolicyId = newGuid();
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
@@ -335,54 +380,59 @@ describe("PoliciesComponent", () => {
data: null,
};
queryParamsSubject.next({ policyId: mockPolicyId });
let dialogOpenSpy: jest.SpyInstance;
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
beforeEach(async () => {
queryParamsSubject.next({ policyId: mockPolicyId });
mockPolicyApiService.getPolicies.mockReturnValue(
of(
new ListResponse(
{ Data: [mockPolicyResponseData], ContinuationToken: null },
PolicyResponse,
),
),
),
);
);
const dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
dialogOpenSpy = jest
.spyOn(PolicyEditDialogComponent, "open")
.mockReturnValue({ close: jest.fn() } as any);
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PoliciesComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: PolicyListService, useValue: mockPolicyListService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] },
],
schemas: [NO_ERRORS_SCHEMA],
})
.compileComponents();
.overrideComponent(PoliciesComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
const newFixture = TestBed.createComponent(PoliciesComponent);
newFixture.detectChanges();
fixture = TestBed.createComponent(PoliciesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
newFixture.destroy();
it("should open policy dialog when policyId is in query params", () => {
expect(dialogOpenSpy).toHaveBeenCalled();
const callArgs = dialogOpenSpy.mock.calls[0][1];
expect(callArgs.data?.policy.type).toBe(mockPolicy.type);
expect(callArgs.data?.organizationId).toBe(mockOrgId);
});
});
it("should not open dialog when policyId is not in query params", async () => {

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, Observable, of, switchMap, first, map } from "rxjs";
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
@@ -70,6 +70,7 @@ export class PoliciesComponent {
switchMap(() => this.organizationId$),
switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)),
map((response) => (response.data != null && response.data.length > 0 ? response.data : [])),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected policiesEnabledMap$: Observable<Map<PolicyType, boolean>> = this.orgPolicies$.pipe(

View File

@@ -40,21 +40,27 @@
{{ i.amount | currency: "$" }}
</td>
<td bitCell class="tw-text-right">
<ng-container *ngIf="isSecretsManagerTrial(); else calculateElse">
<ng-container
*ngIf="
isSecretsManagerTrial() && i.productName === 'passwordManager';
else calculateElse
"
>
{{ "freeForOneYear" | i18n }}
</ng-container>
<ng-template #calculateElse>
<div class="tw-flex tw-flex-col">
<span>
{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
{{ i.quantity * i.amount | currency: "$" }} /
{{ i.interval | i18n }}
</span>
<span
*ngIf="customerDiscount?.percentOff"
*ngIf="
customerDiscount?.percentOff && discountAppliesToProduct(i.productId)
"
class="tw-line-through !tw-text-muted"
>{{
calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$"
}}
/ {{ "year" | i18n }}</span
>{{ i.quantity * i.originalAmount | currency: "$" }} /
{{ "year" | i18n }}</span
>
</div>
</ng-template>

View File

@@ -19,11 +19,9 @@ import {
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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response";
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 { DialogService, ToastService } from "@bitwarden/components";
@@ -82,9 +80,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private organizationApiService: OrganizationApiServiceAbstraction,
private route: ActivatedRoute,
private dialogService: DialogService,
private configService: ConfigService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
@@ -218,6 +214,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
get subscriptionLineItems() {
return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({
name: lineItem.name,
originalAmount: lineItem.amount,
amount: this.discountPrice(lineItem.amount, lineItem.productId),
quantity: lineItem.quantity,
interval: lineItem.interval,
@@ -406,12 +403,16 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone";
const appliesToProduct =
this.sub?.subscription?.items?.some((item) =>
this.sub?.customerDiscount?.appliesTo?.includes(item.productId),
this.discountAppliesToProduct(item.productId),
) ?? false;
return isSmStandalone && appliesToProduct;
}
discountAppliesToProduct(productId: string): boolean {
return this.sub?.customerDiscount?.appliesTo?.includes(productId) ?? false;
}
closeChangePlan() {
this.showChangePlan = false;
}
@@ -438,10 +439,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
await this.load();
}
calculateTotalAppliedDiscount(total: number) {
return total / (1 - this.customerDiscount?.percentOff / 100);
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, {

View File

@@ -9,8 +9,6 @@ import {
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
OrganizationUserService,
DefaultOrganizationUserService,
} from "@bitwarden/admin-console/common";
@@ -46,6 +44,10 @@ import {
InternalUserDecryptionOptionsServiceAbstraction,
LoginEmailService,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@@ -376,6 +378,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({

View File

@@ -4,12 +4,12 @@ import { CommonModule } from "@angular/common";
import { Component, OnInit, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { RouterModule } from "@angular/router";
import { combineLatest, map, Observable, switchMap } from "rxjs";
import { Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { canAccessEmergencyAccess } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
@@ -58,21 +58,11 @@ export class UserLayoutComponent implements OnInit {
);
this.showEmergencyAccess = toSignal(
combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
canAccessEmergencyAccess(userId, this.configService, this.policyService),
),
]).pipe(
map(([enabled, policyAppliesToUser]) => {
if (!enabled || !policyAppliesToUser) {
return true;
}
return false;
}),
),
);

View File

@@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { Route, RouterModule, Routes } from "@angular/router";
import { organizationPolicyGuard } from "@bitwarden/angular/admin-console/guards";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import { AuthRoute } from "@bitwarden/angular/auth/constants";
import {
@@ -56,7 +57,6 @@ import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/gua
import { flagEnabled, Flags } from "../utils/flags";
import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard";
import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";

View File

@@ -4,6 +4,7 @@ import { Injectable } from "@angular/core";
import * as papa from "papaparse";
import { EventView } from "@bitwarden/common/models/view/event.view";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { EventExport } from "./event.export";
@@ -16,25 +17,6 @@ export class EventExportService {
}
getFileName(prefix: string = null, extension = "csv"): string {
const now = new Date();
const dateString =
now.getFullYear() +
"" +
this.padNumber(now.getMonth() + 1, 2) +
"" +
this.padNumber(now.getDate(), 2) +
this.padNumber(now.getHours(), 2) +
"" +
this.padNumber(now.getMinutes(), 2) +
this.padNumber(now.getSeconds(), 2);
return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension;
}
private padNumber(num: number, width: number, padCharacter = "0"): string {
const numString = num.toString();
return numString.length >= width
? numString
: new Array(width - numString.length + 1).join(padCharacter) + numString;
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -1,52 +1,14 @@
<form [formGroup]="formGroup" [bitSubmit]="load">
<bit-callout *ngIf="hideEmail" type="warning" title="{{ 'warning' | i18n }}">
{{ "viewSendHiddenEmailWarning" | i18n }}
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
"learnMore" | i18n
}}</a
>.
</bit-callout>
<ng-container *ngIf="!loading; else spinner">
<app-send-access-password
(setPasswordEvent)="setPassword($event)"
*ngIf="passwordRequired && !error"
></app-send-access-password>
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
<div class="tw-text-main tw-text-center" *ngIf="error">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
<div *ngIf="!passwordRequired && send && !error && !unavailable">
<p class="tw-text-center">
<b>{{ send.name }}</b>
</p>
<hr />
<!-- Text -->
<ng-container *ngIf="send.type === sendType.Text">
<app-send-access-text [send]="send"></app-send-access-text>
</ng-container>
<!-- File -->
<ng-container *ngIf="send.type === sendType.File">
<app-send-access-file
[send]="send"
[decKey]="decKey"
[accessRequest]="accessRequest"
></app-send-access-file>
</ng-container>
<p *ngIf="expirationDate" class="tw-text-center tw-text-muted">
Expires: {{ expirationDate | date: "medium" }}
</p>
</div>
</ng-container>
<ng-template #spinner>
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
</ng-template>
</form>
@switch (viewState) {
@case ("auth") {
<app-send-auth [id]="id" [key]="key" (accessGranted)="onAccessGranted($event)"></app-send-auth>
}
@case ("view") {
<app-send-view
[id]="id"
[key]="key"
[sendResponse]="sendAccessResponse"
[accessRequest]="sendAccessRequest"
(authRequired)="onAuthRequired()"
></app-send-view>
}
}

View File

@@ -1,161 +1,60 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { SendAccessFileComponent } from "./send-access-file.component";
import { SendAccessPasswordComponent } from "./send-access-password.component";
import { SendAccessTextComponent } from "./send-access-text.component";
import { SendAuthComponent } from "./send-auth.component";
import { SendViewComponent } from "./send-view.component";
const SendViewState = Object.freeze({
View: "view",
Auth: "auth",
} as const);
type SendViewState = (typeof SendViewState)[keyof typeof SendViewState];
// 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-send-access",
templateUrl: "access.component.html",
imports: [
SendAccessFileComponent,
SendAccessTextComponent,
SendAccessPasswordComponent,
SharedModule,
],
imports: [SendAuthComponent, SendViewComponent, SharedModule],
})
export class AccessComponent implements OnInit {
protected send: SendAccessView;
protected sendType = SendType;
protected loading = true;
protected passwordRequired = false;
protected formPromise: Promise<SendAccessResponse>;
protected password: string;
protected unavailable = false;
protected error = false;
protected hideEmail = false;
protected decKey: SymmetricCryptoKey;
protected accessRequest: SendAccessRequest;
viewState: SendViewState = SendViewState.View;
id: string;
key: string;
protected formGroup = this.formBuilder.group({});
sendAccessResponse: SendAccessResponse | null = null;
sendAccessRequest: SendAccessRequest = new SendAccessRequest();
private id: string;
private key: string;
constructor(
private cryptoFunctionService: CryptoFunctionService,
private route: ActivatedRoute,
private keyService: KeyService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
private layoutWrapperDataService: AnonLayoutWrapperDataService,
protected formBuilder: FormBuilder,
) {}
protected get expirationDate() {
if (this.send == null || this.send.expirationDate == null) {
return null;
}
return this.send.expirationDate;
}
protected get creatorIdentifier() {
if (this.send == null || this.send.creatorIdentifier == null) {
return null;
}
return this.send.creatorIdentifier;
}
constructor(private route: ActivatedRoute) {}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.params.subscribe(async (params) => {
this.id = params.sendId;
this.key = params.key;
if (this.key == null || this.id == null) {
return;
if (this.id && this.key) {
this.viewState = SendViewState.View;
this.sendAccessResponse = null;
this.sendAccessRequest = new SendAccessRequest();
}
await this.load();
});
}
protected load = async () => {
this.unavailable = false;
this.error = false;
this.hideEmail = false;
try {
const keyArray = Utils.fromUrlB64ToArray(this.key);
this.accessRequest = new SendAccessRequest();
if (this.password != null) {
const passwordHash = await this.cryptoFunctionService.pbkdf2(
this.password,
keyArray,
"sha256",
SEND_KDF_ITERATIONS,
);
this.accessRequest.password = Utils.fromBufferToB64(passwordHash);
}
let sendResponse: SendAccessResponse = null;
if (this.loading) {
sendResponse = await this.sendApiService.postSendAccess(this.id, this.accessRequest);
} else {
this.formPromise = this.sendApiService.postSendAccess(this.id, this.accessRequest);
sendResponse = await this.formPromise;
}
this.passwordRequired = false;
const sendAccess = new SendAccess(sendResponse);
this.decKey = await this.keyService.makeSendKey(keyArray);
this.send = await sendAccess.decrypt(this.decKey);
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
this.passwordRequired = true;
} else if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
}
}
this.loading = false;
this.hideEmail =
this.creatorIdentifier == null &&
!this.passwordRequired &&
!this.loading &&
!this.unavailable;
onAuthRequired() {
this.viewState = SendViewState.Auth;
}
if (this.creatorIdentifier != null) {
this.layoutWrapperDataService.setAnonLayoutWrapperData({
pageSubtitle: {
key: "sendAccessCreatorIdentifier",
placeholders: [this.creatorIdentifier],
},
});
}
};
protected setPassword(password: string) {
this.password = password;
onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) {
this.sendAccessResponse = event.response;
this.sendAccessRequest = event.request;
this.viewState = SendViewState.View;
}
}

View File

@@ -0,0 +1,14 @@
<form (ngSubmit)="onSubmit(password)">
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
<div class="tw-text-main tw-text-center" *ngIf="error">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
<app-send-access-password
*ngIf="!unavailable"
(setPasswordEvent)="password = $event"
[loading]="loading"
></app-send-access-password>
</form>

View File

@@ -0,0 +1,86 @@
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { SendAccessPasswordComponent } from "./send-access-password.component";
@Component({
selector: "app-send-auth",
templateUrl: "send-auth.component.html",
imports: [SendAccessPasswordComponent, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendAuthComponent {
readonly id = input.required<string>();
readonly key = input.required<string>();
accessGranted = output<{
response: SendAccessResponse;
request: SendAccessRequest;
}>();
loading = false;
error = false;
unavailable = false;
password?: string;
private accessRequest!: SendAccessRequest;
constructor(
private cryptoFunctionService: CryptoFunctionService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
async onSubmit(password: string) {
this.password = password;
this.loading = true;
this.error = false;
this.unavailable = false;
try {
const keyArray = Utils.fromUrlB64ToArray(this.key());
this.accessRequest = new SendAccessRequest();
const passwordHash = await this.cryptoFunctionService.pbkdf2(
this.password,
keyArray,
"sha256",
SEND_KDF_ITERATIONS,
);
this.accessRequest.password = Utils.fromBufferToB64(passwordHash);
const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest);
this.accessGranted.emit({ response: sendResponse, request: this.accessRequest });
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
}
} finally {
this.loading = false;
}
}
}

View File

@@ -0,0 +1,47 @@
<bit-callout *ngIf="hideEmail" type="warning" title="{{ 'warning' | i18n }}">
{{ "viewSendHiddenEmailWarning" | i18n }}
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
"learnMore" | i18n
}}</a
>.
</bit-callout>
<ng-container *ngIf="!loading; else spinner">
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
<div class="tw-text-main tw-text-center" *ngIf="error">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
<div *ngIf="send && !error && !unavailable">
<p class="tw-text-center">
<b>{{ send.name }}</b>
</p>
<hr />
<!-- Text -->
<ng-container *ngIf="send.type === sendType.Text">
<app-send-access-text [send]="send"></app-send-access-text>
</ng-container>
<!-- File -->
<ng-container *ngIf="send.type === sendType.File">
<app-send-access-file
[send]="send"
[decKey]="decKey"
[accessRequest]="accessRequest()"
></app-send-access-file>
</ng-container>
<p *ngIf="expirationDate" class="tw-text-center tw-text-muted">
Expires: {{ expirationDate | date: "medium" }}
</p>
</div>
</ng-container>
<ng-template #spinner>
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
</ng-template>

View File

@@ -0,0 +1,131 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
input,
OnInit,
output,
} from "@angular/core";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { SendAccessFileComponent } from "./send-access-file.component";
import { SendAccessTextComponent } from "./send-access-text.component";
@Component({
selector: "app-send-view",
templateUrl: "send-view.component.html",
imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendViewComponent implements OnInit {
readonly id = input.required<string>();
readonly key = input.required<string>();
readonly sendResponse = input<SendAccessResponse | null>(null);
readonly accessRequest = input<SendAccessRequest>(new SendAccessRequest());
authRequired = output<void>();
send: SendAccessView | null = null;
sendType = SendType;
loading = true;
unavailable = false;
error = false;
hideEmail = false;
decKey!: SymmetricCryptoKey;
constructor(
private keyService: KeyService,
private sendApiService: SendApiService,
private toastService: ToastService,
private i18nService: I18nService,
private layoutWrapperDataService: AnonLayoutWrapperDataService,
private cdRef: ChangeDetectorRef,
) {}
get expirationDate() {
if (this.send == null || this.send.expirationDate == null) {
return null;
}
return this.send.expirationDate;
}
get creatorIdentifier() {
if (this.send == null || this.send.creatorIdentifier == null) {
return null;
}
return this.send.creatorIdentifier;
}
async ngOnInit() {
await this.load();
}
private async load() {
this.unavailable = false;
this.error = false;
this.hideEmail = false;
this.loading = true;
let response = this.sendResponse();
try {
if (!response) {
response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest());
}
const keyArray = Utils.fromUrlB64ToArray(this.key());
const sendAccess = new SendAccess(response);
this.decKey = await this.keyService.makeSendKey(keyArray);
this.send = await sendAccess.decrypt(this.decKey);
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
this.authRequired.emit();
} else if (e.statusCode === 404) {
this.unavailable = true;
} else if (e.statusCode === 400) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
} else {
this.error = true;
}
} else {
this.error = true;
}
}
this.loading = false;
this.hideEmail =
this.creatorIdentifier == null && !this.loading && !this.unavailable && !response;
this.hideEmail = this.send != null && this.creatorIdentifier == null;
if (this.creatorIdentifier != null) {
this.layoutWrapperDataService.setAnonLayoutWrapperData({
pageSubtitle: {
key: "sendAccessCreatorIdentifier",
placeholders: [this.creatorIdentifier],
},
});
}
this.cdRef.markForCheck();
}
}

View File

@@ -144,8 +144,9 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
}
// Archive button will not show in Admin Console
protected get showArchiveButton() {
if (!this.archiveEnabled()) {
if (!this.archiveEnabled() || this.viewingOrgVault) {
return false;
}

View File

@@ -26,7 +26,6 @@ import {
} from "rxjs/operators";
import {
AutomaticUserConfirmationService,
CollectionData,
CollectionDetailsResponse,
CollectionService,
@@ -42,6 +41,7 @@ import {
ItemTypes,
Icon,
} from "@bitwarden/assets/svg";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import {

View File

@@ -1749,6 +1749,9 @@
"noMembersInList": {
"message": "There are no members to list."
},
"noMembersToExport": {
"message": "There are no members to export."
},
"noEventsInList": {
"message": "There are no events to list."
},
@@ -2537,6 +2540,9 @@
"enabled": {
"message": "Turned on"
},
"optionEnabled": {
"message": "Enabled"
},
"restoreAccess": {
"message": "Restore access"
},
@@ -5649,6 +5655,9 @@
"revoked": {
"message": "Revoked"
},
"accepted": {
"message": "Accepted"
},
"sendLink": {
"message": "Send link",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -6307,6 +6316,12 @@
"enrolledAccountRecovery": {
"message": "Enrolled in account recovery"
},
"enrolled": {
"message": "Enrolled"
},
"notEnrolled": {
"message": "Not enrolled"
},
"withdrawAccountRecovery": {
"message": "Withdraw from account recovery"
},