mirror of
https://github.com/bitwarden/browser
synced 2026-02-19 02:44:01 +00:00
Merge branch 'main' into km/sdk-key-rotation
This commit is contained in:
@@ -56,6 +56,7 @@ import {
|
||||
UserDecryptionOptionsService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
@@ -1079,6 +1080,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AuthRequestAnsweringService,
|
||||
ConfigService,
|
||||
InternalPolicyService,
|
||||
AutomaticUserConfirmationService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AutoConfirmState } from "../models/auto-confirm-state.model";
|
||||
@@ -27,12 +27,12 @@ export abstract class AutomaticUserConfirmationService {
|
||||
/**
|
||||
* Calls the API endpoint to initiate automatic user confirmation.
|
||||
* @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks.
|
||||
* @param confirmingUserId The userId of the user being confirmed.
|
||||
* @param organization the organization the user is being auto confirmed to.
|
||||
* @param confirmedUserId The userId of the member being confirmed.
|
||||
* @param organization the organization the member is being auto confirmed to.
|
||||
**/
|
||||
abstract autoConfirmUser(
|
||||
userId: UserId,
|
||||
confirmingUserId: UserId,
|
||||
organization: Organization,
|
||||
confirmedUserId: UserId,
|
||||
organization: OrganizationId,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ import { Router, UrlTree } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "../abstractions";
|
||||
|
||||
import { canAccessAutoConfirmSettings } from "./automatic-user-confirmation-settings.guard";
|
||||
|
||||
describe("canAccessAutoConfirmSettings", () => {
|
||||
@@ -2,13 +2,12 @@ import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { map, switchMap } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "../abstractions";
|
||||
|
||||
export const canAccessAutoConfirmSettings: CanActivateFn = () => {
|
||||
const accountService = inject(AccountService);
|
||||
const autoConfirmService = inject(AutomaticUserConfirmationService);
|
||||
8
libs/auto-confirm/src/angular/index.ts
Normal file
8
libs/auto-confirm/src/angular/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Re-export core auto-confirm functionality for convenience
|
||||
export * from "../abstractions";
|
||||
export * from "../models";
|
||||
export * from "../services";
|
||||
|
||||
// Angular-specific exports
|
||||
export * from "./components";
|
||||
export * from "./guards";
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./components";
|
||||
export * from "./guards";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
|
||||
@@ -377,48 +377,70 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
defaultUserCollectionName: "encrypted-collection",
|
||||
} as OrganizationUserConfirmRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
// Enable auto-confirm configuration for the user
|
||||
const enabledConfig = new AutoConfirmState();
|
||||
enabledConfig.enabled = true;
|
||||
await stateProvider.setUserState(
|
||||
AUTO_CONFIRM_STATE,
|
||||
{ [mockUserId]: enabledConfig },
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
apiService.getUserPublicKey.mockResolvedValue({
|
||||
publicKey: mockPublicKey,
|
||||
} as UserKeyResponse);
|
||||
jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray);
|
||||
organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest));
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
|
||||
organizationUserApiService.postOrganizationUserAutoConfirm.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should successfully auto-confirm a user", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
|
||||
it("should successfully auto-confirm a user with organizationId", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).toHaveBeenCalledWith(mockUserId);
|
||||
expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
mockPublicKeyArray,
|
||||
);
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
mockConfirmingUserId,
|
||||
mockConfirmRequest,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not confirm user when canManageAutoConfirm returns false", async () => {
|
||||
it("should return early when canManageAutoConfirm returns false", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
await expect(
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
|
||||
).rejects.toThrow("Cannot automatically confirm user (insufficient permissions)");
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return early when auto-confirm is disabled in configuration", async () => {
|
||||
const disabledConfig = new AutoConfirmState();
|
||||
disabledConfig.enabled = false;
|
||||
await stateProvider.setUserState(
|
||||
AUTO_CONFIRM_STATE,
|
||||
{ [mockUserId]: disabledConfig },
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(apiService.getUserPublicKey).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should build confirm request with organization and public key", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
@@ -427,10 +449,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
});
|
||||
|
||||
it("should call API with correct parameters", async () => {
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization);
|
||||
await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId);
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
mockConfirmingUserId,
|
||||
mockConfirmRequest,
|
||||
);
|
||||
@@ -441,10 +463,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
apiService.getUserPublicKey.mockRejectedValue(apiError);
|
||||
|
||||
await expect(
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId),
|
||||
).rejects.toThrow("API Error");
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle buildConfirmRequest errors gracefully", async () => {
|
||||
@@ -452,10 +474,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
organizationUserService.buildConfirmRequest.mockReturnValue(throwError(() => buildError));
|
||||
|
||||
await expect(
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization),
|
||||
service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganizationId),
|
||||
).rejects.toThrow("Build Error");
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
|
||||
expect(organizationUserApiService.postOrganizationUserAutoConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,11 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { InternalOrganizationServiceAbstraction } 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
@@ -66,26 +67,44 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
|
||||
|
||||
async autoConfirmUser(
|
||||
userId: UserId,
|
||||
confirmingUserId: UserId,
|
||||
organization: Organization,
|
||||
confirmedUserId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<void> {
|
||||
const canManage = await firstValueFrom(this.canManageAutoConfirm$(userId));
|
||||
|
||||
if (!canManage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only initiate auto confirmation if the local client setting has been turned on
|
||||
const autoConfirmEnabled = await firstValueFrom(
|
||||
this.configuration$(userId).pipe(map((state) => state.enabled)),
|
||||
);
|
||||
|
||||
if (!autoConfirmEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organization$ = this.organizationService.organizations$(userId).pipe(
|
||||
getById(organizationId),
|
||||
map((organization) => {
|
||||
if (organization == null) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
);
|
||||
|
||||
const publicKeyResponse = await this.apiService.getUserPublicKey(userId);
|
||||
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
|
||||
|
||||
await firstValueFrom(
|
||||
this.canManageAutoConfirm$(userId).pipe(
|
||||
map((canManage) => {
|
||||
if (!canManage) {
|
||||
throw new Error("Cannot automatically confirm user (insufficient permissions)");
|
||||
}
|
||||
return canManage;
|
||||
}),
|
||||
switchMap(() => this.apiService.getUserPublicKey(userId)),
|
||||
map((publicKeyResponse) => Utils.fromB64ToArray(publicKeyResponse.publicKey)),
|
||||
switchMap((publicKey) =>
|
||||
this.organizationUserService.buildConfirmRequest(organization, publicKey),
|
||||
),
|
||||
organization$.pipe(
|
||||
switchMap((org) => this.organizationUserService.buildConfirmRequest(org, publicKey)),
|
||||
switchMap((request) =>
|
||||
this.organizationUserApiService.postOrganizationUserConfirm(
|
||||
organization.id,
|
||||
confirmingUserId,
|
||||
this.organizationUserApiService.postOrganizationUserAutoConfirm(
|
||||
organizationId,
|
||||
confirmedUserId,
|
||||
request,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,6 @@ export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
DefaultUserCollectionRestore = "pm-30883-my-items-restored-users",
|
||||
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
|
||||
BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements",
|
||||
|
||||
/* Auth */
|
||||
@@ -110,7 +109,6 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.DefaultUserCollectionRestore]: FALSE,
|
||||
[FeatureFlag.MembersComponentRefactor]: FALSE,
|
||||
[FeatureFlag.BulkReinviteUI]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
|
||||
@@ -35,4 +35,5 @@ export enum NotificationType {
|
||||
ProviderBankAccountVerified = 24,
|
||||
|
||||
SyncPolicy = 25,
|
||||
AutoConfirmMember = 26,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NotificationType } from "../../enums";
|
||||
|
||||
import { AutoConfirmMemberNotification, NotificationResponse } from "./notification.response";
|
||||
|
||||
describe("NotificationResponse", () => {
|
||||
describe("AutoConfirmMemberNotification", () => {
|
||||
it("should parse AutoConfirmMemberNotification payload", () => {
|
||||
const response = {
|
||||
ContextId: "context-123",
|
||||
Type: NotificationType.AutoConfirmMember,
|
||||
Payload: {
|
||||
TargetUserId: "target-user-id",
|
||||
UserId: "user-id",
|
||||
OrganizationId: "org-id",
|
||||
},
|
||||
};
|
||||
|
||||
const notification = new NotificationResponse(response);
|
||||
|
||||
expect(notification.type).toBe(NotificationType.AutoConfirmMember);
|
||||
expect(notification.payload).toBeInstanceOf(AutoConfirmMemberNotification);
|
||||
expect(notification.payload.targetUserId).toBe("target-user-id");
|
||||
expect(notification.payload.userId).toBe("user-id");
|
||||
expect(notification.payload.organizationId).toBe("org-id");
|
||||
});
|
||||
|
||||
it("should handle stringified JSON payload", () => {
|
||||
const response = {
|
||||
ContextId: "context-123",
|
||||
Type: NotificationType.AutoConfirmMember,
|
||||
Payload: JSON.stringify({
|
||||
TargetUserId: "target-user-id-2",
|
||||
UserId: "user-id-2",
|
||||
OrganizationId: "org-id-2",
|
||||
}),
|
||||
};
|
||||
|
||||
const notification = new NotificationResponse(response);
|
||||
|
||||
expect(notification.type).toBe(NotificationType.AutoConfirmMember);
|
||||
expect(notification.payload).toBeInstanceOf(AutoConfirmMemberNotification);
|
||||
expect(notification.payload.targetUserId).toBe("target-user-id-2");
|
||||
expect(notification.payload.userId).toBe("user-id-2");
|
||||
expect(notification.payload.organizationId).toBe("org-id-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutoConfirmMemberNotification constructor", () => {
|
||||
it("should extract all properties from response", () => {
|
||||
const response = {
|
||||
TargetUserId: "target-user-id",
|
||||
UserId: "user-id",
|
||||
OrganizationId: "org-id",
|
||||
};
|
||||
|
||||
const notification = new AutoConfirmMemberNotification(response);
|
||||
|
||||
expect(notification.targetUserId).toBe("target-user-id");
|
||||
expect(notification.userId).toBe("user-id");
|
||||
expect(notification.organizationId).toBe("org-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,9 @@ export class NotificationResponse extends BaseResponse {
|
||||
case NotificationType.SyncPolicy:
|
||||
this.payload = new SyncPolicyNotification(payload);
|
||||
break;
|
||||
case NotificationType.AutoConfirmMember:
|
||||
this.payload = new AutoConfirmMemberNotification(payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -210,3 +213,16 @@ export class LogOutNotification extends BaseResponse {
|
||||
this.reason = this.getResponseProperty("Reason");
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoConfirmMemberNotification extends BaseResponse {
|
||||
userId: string;
|
||||
targetUserId: string;
|
||||
organizationId: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.targetUserId = this.getResponseProperty("TargetUserId");
|
||||
this.userId = this.getResponseProperty("UserId");
|
||||
this.organizationId = this.getResponseProperty("OrganizationId");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
@@ -36,6 +37,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let policyService: MockProxy<InternalPolicyService>;
|
||||
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
|
||||
let activeUserAccount$: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
|
||||
let userAccounts$: BehaviorSubject<ObservedValueOf<AccountService["accounts$"]>>;
|
||||
@@ -131,6 +133,8 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
autoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
|
||||
defaultServerNotificationsService = new DefaultServerNotificationsService(
|
||||
mock<LogService>(),
|
||||
syncService,
|
||||
@@ -145,6 +149,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
authRequestAnsweringService,
|
||||
configService,
|
||||
policyService,
|
||||
autoConfirmService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subj
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
@@ -45,6 +46,7 @@ describe("NotificationsService", () => {
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let policyService: MockProxy<InternalPolicyService>;
|
||||
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
|
||||
let activeAccount: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
|
||||
let accounts: BehaviorSubject<ObservedValueOf<AccountService["accounts$"]>>;
|
||||
@@ -75,6 +77,7 @@ describe("NotificationsService", () => {
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
|
||||
configService = mock<ConfigService>();
|
||||
policyService = mock<InternalPolicyService>();
|
||||
autoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
|
||||
// For these tests, use the active-user implementation (feature flag disabled)
|
||||
configService.getFeatureFlag$.mockImplementation(() => of(true));
|
||||
@@ -128,6 +131,7 @@ describe("NotificationsService", () => {
|
||||
authRequestAnsweringService,
|
||||
configService,
|
||||
policyService,
|
||||
autoConfirmService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -507,5 +511,29 @@ describe("NotificationsService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationType.AutoConfirmMember", () => {
|
||||
it("should call autoConfirmService.autoConfirmUser with correct parameters", async () => {
|
||||
autoConfirmService.autoConfirmUser.mockResolvedValue();
|
||||
|
||||
const notification = new NotificationResponse({
|
||||
type: NotificationType.AutoConfirmMember,
|
||||
payload: {
|
||||
UserId: mockUser1,
|
||||
TargetUserId: "target-user-id",
|
||||
OrganizationId: "org-id",
|
||||
},
|
||||
contextId: "different-app-id",
|
||||
});
|
||||
|
||||
await sut["processNotification"](notification, mockUser1);
|
||||
|
||||
expect(autoConfirmService.autoConfirmUser).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"target-user-id",
|
||||
"org-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
@@ -49,6 +50,7 @@ export const DISABLED_NOTIFICATIONS_URL = "http://-";
|
||||
|
||||
export const AllowedMultiUserNotificationTypes = new Set<NotificationType>([
|
||||
NotificationType.AuthRequest,
|
||||
NotificationType.AutoConfirmMember,
|
||||
]);
|
||||
|
||||
export class DefaultServerNotificationsService implements ServerNotificationsService {
|
||||
@@ -70,6 +72,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
private readonly authRequestAnsweringService: AuthRequestAnsweringService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly policyService: InternalPolicyService,
|
||||
private autoConfirmService: AutomaticUserConfirmationService,
|
||||
) {
|
||||
this.notifications$ = this.accountService.accounts$.pipe(
|
||||
map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
|
||||
@@ -292,6 +295,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
case NotificationType.SyncPolicy:
|
||||
await this.policyService.syncPolicy(PolicyData.fromPolicy(notification.payload.policy));
|
||||
break;
|
||||
case NotificationType.AutoConfirmMember:
|
||||
await this.autoConfirmService.autoConfirmUser(
|
||||
notification.payload.userId,
|
||||
notification.payload.targetUserId,
|
||||
notification.payload.organizationId,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -183,6 +183,8 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
|
||||
const response = await this.inFlightApiCalls.sync;
|
||||
|
||||
await this.cipherService.clear(response.profile.id);
|
||||
|
||||
await this.syncUserDecryption(response.profile.id, response.userDecryption);
|
||||
await this.syncProfile(response.profile);
|
||||
await this.syncFolders(response.folders, response.profile.id);
|
||||
|
||||
@@ -105,7 +105,7 @@ export class SendApiService implements SendApiServiceAbstraction {
|
||||
"POST",
|
||||
"/sends/access/file/" + send.file.id,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
apiUrl,
|
||||
setAuthTokenHeader,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Observable } from "rxjs";
|
||||
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { IndexedEntityId, UserId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class SearchService {
|
||||
@@ -20,7 +19,7 @@ export abstract class SearchService {
|
||||
abstract isSearchable(userId: UserId, query: string | null): Promise<boolean>;
|
||||
abstract indexCiphers(
|
||||
userId: UserId,
|
||||
ciphersToIndex: CipherView[],
|
||||
ciphersToIndex: CipherViewLike[],
|
||||
indexedEntityGuid?: string,
|
||||
): Promise<void>;
|
||||
abstract searchCiphers<C extends CipherViewLike>(
|
||||
|
||||
@@ -173,13 +173,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
decryptStartTime = performance.now();
|
||||
}),
|
||||
switchMap(async (ciphers) => {
|
||||
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
void this.setFailedDecryptedCiphers(failures, userId);
|
||||
// Trigger full decryption and indexing in background
|
||||
void this.getAllDecrypted(userId);
|
||||
return decrypted;
|
||||
return await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
}),
|
||||
tap((decrypted) => {
|
||||
tap(([decrypted, failures]) => {
|
||||
void Promise.all([
|
||||
this.setFailedDecryptedCiphers(failures, userId),
|
||||
this.searchService.indexCiphers(userId, decrypted),
|
||||
]);
|
||||
|
||||
this.logService.measure(
|
||||
decryptStartTime,
|
||||
"Vault",
|
||||
@@ -188,10 +189,11 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
[["Items", decrypted.length]],
|
||||
);
|
||||
}),
|
||||
map(([decrypted]) => decrypted),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, this.clearCipherViewsForUser$);
|
||||
|
||||
/**
|
||||
* Observable that emits an array of decrypted ciphers for the active user.
|
||||
@@ -530,6 +532,10 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
ciphers: Cipher[],
|
||||
userId: UserId,
|
||||
): Promise<[CipherView[], CipherView[]] | null> {
|
||||
if (ciphers.length === 0) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) {
|
||||
const decryptStartTime = performance.now();
|
||||
|
||||
@@ -2401,6 +2407,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
userId: UserId,
|
||||
fullDecryption: boolean = true,
|
||||
): Promise<[CipherViewLike[], CipherView[]]> {
|
||||
// Short-circuit if there are no ciphers to decrypt
|
||||
// Observables reacting to key changes may attempt to decrypt with a stale SDK reference.
|
||||
if (ciphers.length === 0) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
if (fullDecryption) {
|
||||
const [decryptedViews, failedViews] = await this.cipherEncryptionService.decryptManyLegacy(
|
||||
ciphers,
|
||||
|
||||
@@ -95,6 +95,7 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
vault: jest.fn().mockReturnValue({
|
||||
ciphers: jest.fn().mockReturnValue({
|
||||
encrypt: jest.fn(),
|
||||
encrypt_list: jest.fn(),
|
||||
encrypt_cipher_for_rotation: jest.fn(),
|
||||
set_fido2_credentials: jest.fn(),
|
||||
decrypt: jest.fn(),
|
||||
@@ -280,10 +281,23 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
name: "encrypted-name-3",
|
||||
} as unknown as Cipher;
|
||||
|
||||
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
});
|
||||
mockSdkClient
|
||||
.vault()
|
||||
.ciphers()
|
||||
.encrypt_list.mockReturnValue([
|
||||
{
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
},
|
||||
{
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
},
|
||||
{
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(Cipher, "fromSdkCipher")
|
||||
@@ -299,7 +313,8 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
expect(results[1].cipher).toEqual(expectedCipher2);
|
||||
expect(results[2].cipher).toEqual(expectedCipher3);
|
||||
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt_list).toHaveBeenCalledTimes(1);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
|
||||
expect(results[0].encryptedFor).toBe(userId);
|
||||
expect(results[1].encryptedFor).toBe(userId);
|
||||
@@ -311,7 +326,7 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(0);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
expect(mockSdkClient.vault().ciphers().encrypt_list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,21 +65,14 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
|
||||
using ref = sdk.take();
|
||||
|
||||
const results: EncryptionContext[] = [];
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
|
||||
// Replace this loop with a native SDK encryptMany method for better performance.
|
||||
for (const model of models) {
|
||||
const sdkCipherView = this.toSdkCipherView(model, ref.value);
|
||||
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
|
||||
|
||||
results.push({
|
||||
return ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.encrypt_list(models.map((model) => this.toSdkCipherView(model, ref.value)))
|
||||
.map((encryptionContext) => ({
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}));
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { IndexedEntityId, UserId } from "../../types/guid";
|
||||
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
|
||||
|
||||
// Time to wait before performing a search after the user stops typing.
|
||||
@@ -169,7 +168,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
|
||||
async indexCiphers(
|
||||
userId: UserId,
|
||||
ciphers: CipherView[],
|
||||
ciphers: CipherViewLike[],
|
||||
indexedEntityId?: string,
|
||||
): Promise<void> {
|
||||
if (await this.getIsIndexing(userId)) {
|
||||
@@ -182,34 +181,47 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
const builder = new lunr.Builder();
|
||||
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
|
||||
builder.ref("id");
|
||||
builder.field("shortid", { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) });
|
||||
builder.field("shortid", {
|
||||
boost: 100,
|
||||
extractor: (c: CipherViewLike) => uuidAsString(c.id).substr(0, 8),
|
||||
});
|
||||
builder.field("name", {
|
||||
boost: 10,
|
||||
});
|
||||
builder.field("subtitle", {
|
||||
boost: 5,
|
||||
extractor: (c: CipherView) => {
|
||||
if (c.subTitle != null && c.type === CipherType.Card) {
|
||||
return c.subTitle.replace(/\*/g, "");
|
||||
extractor: (c: CipherViewLike) => {
|
||||
const subtitle = CipherViewLikeUtils.subtitle(c);
|
||||
if (subtitle != null && CipherViewLikeUtils.getType(c) === CipherType.Card) {
|
||||
return subtitle.replace(/\*/g, "");
|
||||
}
|
||||
return c.subTitle;
|
||||
return subtitle;
|
||||
},
|
||||
});
|
||||
builder.field("notes");
|
||||
builder.field("notes", { extractor: (c: CipherViewLike) => CipherViewLikeUtils.getNotes(c) });
|
||||
builder.field("login.username", {
|
||||
extractor: (c: CipherView) =>
|
||||
c.type === CipherType.Login && c.login != null ? c.login.username : null,
|
||||
extractor: (c: CipherViewLike) => {
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
return login?.username ?? null;
|
||||
},
|
||||
});
|
||||
builder.field("login.uris", {
|
||||
boost: 2,
|
||||
extractor: (c: CipherViewLike) => this.uriExtractor(c),
|
||||
});
|
||||
builder.field("fields", {
|
||||
extractor: (c: CipherViewLike) => this.fieldExtractor(c, false),
|
||||
});
|
||||
builder.field("fields_joined", {
|
||||
extractor: (c: CipherViewLike) => this.fieldExtractor(c, true),
|
||||
});
|
||||
builder.field("login.uris", { boost: 2, extractor: (c: CipherView) => this.uriExtractor(c) });
|
||||
builder.field("fields", { extractor: (c: CipherView) => this.fieldExtractor(c, false) });
|
||||
builder.field("fields_joined", { extractor: (c: CipherView) => this.fieldExtractor(c, true) });
|
||||
builder.field("attachments", {
|
||||
extractor: (c: CipherView) => this.attachmentExtractor(c, false),
|
||||
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, false),
|
||||
});
|
||||
builder.field("attachments_joined", {
|
||||
extractor: (c: CipherView) => this.attachmentExtractor(c, true),
|
||||
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, true),
|
||||
});
|
||||
builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId });
|
||||
builder.field("organizationid", { extractor: (c: CipherViewLike) => c.organizationId });
|
||||
ciphers = ciphers || [];
|
||||
ciphers.forEach((c) => builder.add(c));
|
||||
const index = builder.build();
|
||||
@@ -400,37 +412,44 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return await firstValueFrom(this.searchIsIndexing$(userId));
|
||||
}
|
||||
|
||||
private fieldExtractor(c: CipherView, joined: boolean) {
|
||||
if (!c.hasFields) {
|
||||
private fieldExtractor(c: CipherViewLike, joined: boolean) {
|
||||
const fields = CipherViewLikeUtils.getFields(c);
|
||||
if (!fields || fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let fields: string[] = [];
|
||||
c.fields.forEach((f) => {
|
||||
let fieldStrings: string[] = [];
|
||||
fields.forEach((f) => {
|
||||
if (f.name != null) {
|
||||
fields.push(f.name);
|
||||
fieldStrings.push(f.name);
|
||||
}
|
||||
if (f.type === FieldType.Text && f.value != null) {
|
||||
fields.push(f.value);
|
||||
// For CipherListView, value is only populated for Text fields
|
||||
// For CipherView, we check the type explicitly
|
||||
if (f.value != null) {
|
||||
const fieldType = (f as { type?: FieldType }).type;
|
||||
if (fieldType === undefined || fieldType === FieldType.Text) {
|
||||
fieldStrings.push(f.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
fields = fields.filter((f) => f.trim() !== "");
|
||||
if (fields.length === 0) {
|
||||
fieldStrings = fieldStrings.filter((f) => f.trim() !== "");
|
||||
if (fieldStrings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return joined ? fields.join(" ") : fields;
|
||||
return joined ? fieldStrings.join(" ") : fieldStrings;
|
||||
}
|
||||
|
||||
private attachmentExtractor(c: CipherView, joined: boolean) {
|
||||
if (!c.hasAttachments) {
|
||||
private attachmentExtractor(c: CipherViewLike, joined: boolean) {
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(c);
|
||||
if (!attachmentNames || attachmentNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let attachments: string[] = [];
|
||||
c.attachments.forEach((a) => {
|
||||
if (a != null && a.fileName != null) {
|
||||
if (joined && a.fileName.indexOf(".") > -1) {
|
||||
attachments.push(a.fileName.substr(0, a.fileName.lastIndexOf(".")));
|
||||
attachmentNames.forEach((fileName) => {
|
||||
if (fileName != null) {
|
||||
if (joined && fileName.indexOf(".") > -1) {
|
||||
attachments.push(fileName.substring(0, fileName.lastIndexOf(".")));
|
||||
} else {
|
||||
attachments.push(a.fileName);
|
||||
attachments.push(fileName);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -441,43 +460,39 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return joined ? attachments.join(" ") : attachments;
|
||||
}
|
||||
|
||||
private uriExtractor(c: CipherView) {
|
||||
if (c.type !== CipherType.Login || c.login == null || !c.login.hasUris) {
|
||||
private uriExtractor(c: CipherViewLike) {
|
||||
if (CipherViewLikeUtils.getType(c) !== CipherType.Login) {
|
||||
return null;
|
||||
}
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
if (!login?.uris?.length) {
|
||||
return null;
|
||||
}
|
||||
const uris: string[] = [];
|
||||
c.login.uris.forEach((u) => {
|
||||
login.uris.forEach((u) => {
|
||||
if (u.uri == null || u.uri === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Match ports
|
||||
// Extract port from URI
|
||||
const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/);
|
||||
const port = portMatch?.[1];
|
||||
|
||||
let uri = u.uri;
|
||||
|
||||
if (u.hostname !== null) {
|
||||
uris.push(u.hostname);
|
||||
const hostname = CipherViewLikeUtils.getUriHostname(u);
|
||||
if (hostname !== undefined) {
|
||||
uris.push(hostname);
|
||||
if (port) {
|
||||
uris.push(`${u.hostname}:${port}`);
|
||||
uris.push(port);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const slash = uri.indexOf("/");
|
||||
const hostPart = slash > -1 ? uri.substring(0, slash) : uri;
|
||||
uris.push(hostPart);
|
||||
if (port) {
|
||||
uris.push(`${hostPart}`);
|
||||
uris.push(`${hostname}:${port}`);
|
||||
uris.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Add processed URI (strip protocol and query params for non-regex matches)
|
||||
let uri = u.uri;
|
||||
if (u.match !== UriMatchStrategy.RegularExpression) {
|
||||
const protocolIndex = uri.indexOf("://");
|
||||
if (protocolIndex > -1) {
|
||||
uri = uri.substr(protocolIndex + 3);
|
||||
uri = uri.substring(protocolIndex + 3);
|
||||
}
|
||||
const queryIndex = uri.search(/\?|&|#/);
|
||||
if (queryIndex > -1) {
|
||||
@@ -486,6 +501,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
uris.push(uri);
|
||||
});
|
||||
|
||||
return uris.length > 0 ? uris : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -651,4 +651,198 @@ describe("CipherViewLikeUtils", () => {
|
||||
expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotes", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns notes when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.notes = "This is a test note";
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherView)).toBe("This is a test note");
|
||||
});
|
||||
|
||||
it("returns undefined when notes are not present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.notes = undefined;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns notes when present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
notes: "List view notes",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBe("List view notes");
|
||||
});
|
||||
|
||||
it("returns undefined when notes are not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFields", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns fields when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.fields = [
|
||||
{ name: "Field1", value: "Value1" } as any,
|
||||
{ name: "Field2", value: "Value2" } as any,
|
||||
];
|
||||
|
||||
const fields = CipherViewLikeUtils.getFields(cipherView);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields?.[0].name).toBe("Field1");
|
||||
expect(fields?.[0].value).toBe("Value1");
|
||||
expect(fields?.[1].name).toBe("Field2");
|
||||
expect(fields?.[1].value).toBe("Value2");
|
||||
});
|
||||
|
||||
it("returns empty array when fields array is empty", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.fields = [];
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherView)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns fields when present", () => {
|
||||
const cipherListView = {
|
||||
type: { login: {} },
|
||||
fields: [
|
||||
{ name: "Username", value: "user@example.com" },
|
||||
{ name: "API Key", value: "abc123" },
|
||||
],
|
||||
} as CipherListView;
|
||||
|
||||
const fields = CipherViewLikeUtils.getFields(cipherListView);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields?.[0].name).toBe("Username");
|
||||
expect(fields?.[0].value).toBe("user@example.com");
|
||||
expect(fields?.[1].name).toBe("API Key");
|
||||
expect(fields?.[1].value).toBe("abc123");
|
||||
});
|
||||
|
||||
it("returns empty array when fields array is empty", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
fields: [],
|
||||
} as unknown as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherListView)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined when fields are not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentNames", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns attachment filenames when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
attachment1.fileName = "document.pdf";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
attachment2.fileName = "image.png";
|
||||
const attachment3 = new AttachmentView();
|
||||
attachment3.id = "3";
|
||||
attachment3.fileName = "spreadsheet.xlsx";
|
||||
cipherView.attachments = [attachment1, attachment2, attachment3];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual(["document.pdf", "image.png", "spreadsheet.xlsx"]);
|
||||
});
|
||||
|
||||
it("filters out null and undefined filenames", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
attachment1.fileName = "valid.pdf";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
attachment2.fileName = null as any;
|
||||
const attachment3 = new AttachmentView();
|
||||
attachment3.id = "3";
|
||||
attachment3.fileName = undefined;
|
||||
const attachment4 = new AttachmentView();
|
||||
attachment4.id = "4";
|
||||
attachment4.fileName = "another.txt";
|
||||
cipherView.attachments = [attachment1, attachment2, attachment3, attachment4];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual(["valid.pdf", "another.txt"]);
|
||||
});
|
||||
|
||||
it("returns empty array when attachments have no filenames", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
cipherView.attachments = [attachment1, attachment2];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty attachments array", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.attachments = [];
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherView)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns attachment names when present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
attachmentNames: ["report.pdf", "photo.jpg", "data.csv"],
|
||||
} as CipherListView;
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherListView);
|
||||
|
||||
expect(attachmentNames).toEqual(["report.pdf", "photo.jpg", "data.csv"]);
|
||||
});
|
||||
|
||||
it("returns empty array when attachmentNames is empty", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
attachmentNames: [],
|
||||
} as unknown as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined when attachmentNames is not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LoginUriView as LoginListUriView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CipherType } from "../enums";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { CardView } from "../models/view/card.view";
|
||||
@@ -290,6 +291,71 @@ export class CipherViewLikeUtils {
|
||||
static decryptionFailure = (cipher: CipherViewLike): boolean => {
|
||||
return "decryptionFailure" in cipher ? cipher.decryptionFailure : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the notes from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract notes from (either `CipherView` or `CipherListView`)
|
||||
* @returns The notes string if present, or `undefined` if not set
|
||||
*/
|
||||
static getNotes = (cipher: CipherViewLike): string | undefined => {
|
||||
return cipher.notes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the fields from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract fields from (either `CipherView` or `CipherListView`)
|
||||
* @returns Array of field objects with `name` and `value` properties, `undefined` if not set
|
||||
*/
|
||||
static getFields = (
|
||||
cipher: CipherViewLike,
|
||||
): { name?: string | null; value?: string | undefined }[] | undefined => {
|
||||
if (this.isCipherListView(cipher)) {
|
||||
return cipher.fields;
|
||||
}
|
||||
return cipher.fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns attachment filenames from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract attachment names from (either `CipherView` or `CipherListView`)
|
||||
* @returns Array of attachment filenames, `undefined` if attachments are not present
|
||||
*/
|
||||
static getAttachmentNames = (cipher: CipherViewLike): string[] | undefined => {
|
||||
if (this.isCipherListView(cipher)) {
|
||||
return cipher.attachmentNames;
|
||||
}
|
||||
|
||||
return cipher.attachments
|
||||
?.map((a) => a.fileName)
|
||||
.filter((name): name is string => name != null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts hostname from a login URI.
|
||||
*
|
||||
* @param uri - The URI object (either `LoginUriView` class or `LoginListUriView`)
|
||||
* @returns The hostname if available, `undefined` otherwise
|
||||
*
|
||||
* @remarks
|
||||
* - For `LoginUriView` (CipherView): Uses the built-in `hostname` getter
|
||||
* - For `LoginListUriView` (CipherListView): Computes hostname using `Utils.getHostname()`
|
||||
* - Returns `undefined` for RegularExpression match types or when hostname cannot be extracted
|
||||
*/
|
||||
static getUriHostname = (uri: LoginListUriView | LoginUriView): string | undefined => {
|
||||
if ("hostname" in uri && typeof uri.hostname !== "undefined") {
|
||||
return uri.hostname ?? undefined;
|
||||
}
|
||||
|
||||
if (uri.match !== UriMatchStrategy.RegularExpression && uri.uri) {
|
||||
const hostname = Utils.getHostname(uri.uri);
|
||||
return hostname === "" ? undefined : hostname;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,7 @@ type SubscriptionCardAction =
|
||||
| "contact-support"
|
||||
| "manage-invoices"
|
||||
| "reinstate-subscription"
|
||||
| "resubscribe"
|
||||
| "update-payment"
|
||||
| "upgrade-plan";
|
||||
```
|
||||
@@ -279,7 +280,7 @@ Payment issue expired, subscription has been suspended:
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Actions available:** Contact Support
|
||||
**Actions available:** Resubscribe
|
||||
|
||||
### Past Due
|
||||
|
||||
@@ -370,7 +371,7 @@ Subscription that has been canceled:
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Note:** Canceled subscriptions display no callout or actions.
|
||||
**Actions available:** Resubscribe
|
||||
|
||||
### Enterprise
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ describe("SubscriptionCardComponent", () => {
|
||||
unpaid: "Unpaid",
|
||||
weCouldNotProcessYourPayment: "We could not process your payment",
|
||||
contactSupportShort: "Contact support",
|
||||
yourSubscriptionHasExpired: "Your subscription has expired",
|
||||
yourSubscriptionIsExpired: "Your subscription is expired",
|
||||
yourSubscriptionIsCanceled: "Your subscription is canceled",
|
||||
yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${params[0]}`,
|
||||
reinstateSubscription: "Reinstate subscription",
|
||||
resubscribe: "Resubscribe",
|
||||
upgradeYourPlan: "Upgrade your plan",
|
||||
premiumShareEvenMore: "Premium share even more",
|
||||
upgradeNow: "Upgrade now",
|
||||
@@ -253,7 +255,7 @@ describe("SubscriptionCardComponent", () => {
|
||||
expect(buttons[1].nativeElement.textContent.trim()).toBe("Contact support");
|
||||
});
|
||||
|
||||
it("should display incomplete_expired callout with contact support action", () => {
|
||||
it("should display incomplete_expired callout with resubscribe action", () => {
|
||||
setupComponent({
|
||||
...baseSubscription,
|
||||
status: "incomplete_expired",
|
||||
@@ -265,18 +267,18 @@ describe("SubscriptionCardComponent", () => {
|
||||
expect(calloutData).toBeTruthy();
|
||||
expect(calloutData!.type).toBe("danger");
|
||||
expect(calloutData!.title).toBe("Expired");
|
||||
expect(calloutData!.description).toContain("Your subscription has expired");
|
||||
expect(calloutData!.description).toContain("Your subscription is expired");
|
||||
expect(calloutData!.callsToAction?.length).toBe(1);
|
||||
|
||||
const callout = fixture.debugElement.query(By.css("bit-callout"));
|
||||
expect(callout).toBeTruthy();
|
||||
|
||||
const description = callout.query(By.css("p"));
|
||||
expect(description.nativeElement.textContent).toContain("Your subscription has expired");
|
||||
expect(description.nativeElement.textContent).toContain("Your subscription is expired");
|
||||
|
||||
const buttons = callout.queryAll(By.css("button"));
|
||||
expect(buttons.length).toBe(1);
|
||||
expect(buttons[0].nativeElement.textContent.trim()).toBe("Contact support");
|
||||
expect(buttons[0].nativeElement.textContent.trim()).toBe("Resubscribe");
|
||||
});
|
||||
|
||||
it("should display pending cancellation callout for active status with cancelAt", () => {
|
||||
@@ -364,15 +366,29 @@ describe("SubscriptionCardComponent", () => {
|
||||
expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices");
|
||||
});
|
||||
|
||||
it("should not display callout for canceled status", () => {
|
||||
it("should display canceled callout with resubscribe action", () => {
|
||||
setupComponent({
|
||||
...baseSubscription,
|
||||
status: "canceled",
|
||||
canceled: new Date("2025-01-15"),
|
||||
});
|
||||
|
||||
const calloutData = component.callout();
|
||||
expect(calloutData).toBeTruthy();
|
||||
expect(calloutData!.type).toBe("danger");
|
||||
expect(calloutData!.title).toBe("Canceled");
|
||||
expect(calloutData!.description).toContain("Your subscription is canceled");
|
||||
expect(calloutData!.callsToAction?.length).toBe(1);
|
||||
|
||||
const callout = fixture.debugElement.query(By.css("bit-callout"));
|
||||
expect(callout).toBeFalsy();
|
||||
expect(callout).toBeTruthy();
|
||||
|
||||
const description = callout.query(By.css("p"));
|
||||
expect(description.nativeElement.textContent).toContain("Your subscription is canceled");
|
||||
|
||||
const buttons = callout.queryAll(By.css("button"));
|
||||
expect(buttons.length).toBe(1);
|
||||
expect(buttons[0].nativeElement.textContent.trim()).toBe("Resubscribe");
|
||||
});
|
||||
|
||||
it("should display unpaid callout with manage invoices action", () => {
|
||||
@@ -489,6 +505,39 @@ describe("SubscriptionCardComponent", () => {
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("manage-invoices");
|
||||
});
|
||||
|
||||
it("should emit resubscribe action when button is clicked for incomplete_expired status", () => {
|
||||
setupComponent({
|
||||
...baseSubscription,
|
||||
status: "incomplete_expired",
|
||||
suspension: new Date("2025-01-15"),
|
||||
gracePeriod: 7,
|
||||
});
|
||||
|
||||
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
|
||||
|
||||
const button = fixture.debugElement.query(By.css("bit-callout button"));
|
||||
button.triggerEventHandler("click", { button: 0 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("resubscribe");
|
||||
});
|
||||
|
||||
it("should emit resubscribe action when button is clicked for canceled status", () => {
|
||||
setupComponent({
|
||||
...baseSubscription,
|
||||
status: "canceled",
|
||||
canceled: new Date("2025-01-15"),
|
||||
});
|
||||
|
||||
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
|
||||
|
||||
const button = fixture.debugElement.query(By.css("bit-callout button"));
|
||||
button.triggerEventHandler("click", { button: 0 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("resubscribe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cart summary header content", () => {
|
||||
|
||||
@@ -51,10 +51,13 @@ export default {
|
||||
weCouldNotProcessYourPayment:
|
||||
"We could not process your payment. Please update your payment method or contact the support team for assistance.",
|
||||
contactSupportShort: "Contact Support",
|
||||
yourSubscriptionHasExpired:
|
||||
"Your subscription has expired. Please contact the support team for assistance.",
|
||||
yourSubscriptionIsExpired:
|
||||
"Your subscription is expired. Please resubscribe to continue using premium features.",
|
||||
yourSubscriptionIsCanceled:
|
||||
"Your subscription is canceled. Please resubscribe to continue using premium features.",
|
||||
yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${args[0]}. You can reinstate it anytime before then.`,
|
||||
reinstateSubscription: "Reinstate subscription",
|
||||
resubscribe: "Resubscribe",
|
||||
upgradeYourPlan: "Upgrade your plan",
|
||||
premiumShareEvenMore:
|
||||
"Share even more with Families, or get powerful, trusted password security with Teams or Enterprise.",
|
||||
|
||||
@@ -20,6 +20,7 @@ export const SubscriptionCardActions = {
|
||||
ContactSupport: "contact-support",
|
||||
ManageInvoices: "manage-invoices",
|
||||
ReinstateSubscription: "reinstate-subscription",
|
||||
Resubscribe: "resubscribe",
|
||||
UpdatePayment: "update-payment",
|
||||
UpgradePlan: "upgrade-plan",
|
||||
} as const;
|
||||
@@ -154,12 +155,12 @@ export class SubscriptionCardComponent {
|
||||
return {
|
||||
title: this.i18nService.t("expired"),
|
||||
type: "danger",
|
||||
description: this.i18nService.t("yourSubscriptionHasExpired"),
|
||||
description: this.i18nService.t("yourSubscriptionIsExpired"),
|
||||
callsToAction: [
|
||||
{
|
||||
text: this.i18nService.t("contactSupportShort"),
|
||||
text: this.i18nService.t("resubscribe"),
|
||||
buttonType: "unstyled",
|
||||
action: SubscriptionCardActions.ContactSupport,
|
||||
action: SubscriptionCardActions.Resubscribe,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -218,7 +219,18 @@ export class SubscriptionCardComponent {
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Canceled: {
|
||||
return null;
|
||||
return {
|
||||
title: this.i18nService.t("canceled"),
|
||||
type: "danger",
|
||||
description: this.i18nService.t("yourSubscriptionIsCanceled"),
|
||||
callsToAction: [
|
||||
{
|
||||
text: this.i18nService.t("resubscribe"),
|
||||
buttonType: "unstyled",
|
||||
action: SubscriptionCardActions.Resubscribe,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Unpaid: {
|
||||
return {
|
||||
|
||||
@@ -21,20 +21,6 @@
|
||||
[originalSendView]="originalSendView"
|
||||
></tools-send-file-details>
|
||||
|
||||
<bit-form-field *ngIf="sendLink">
|
||||
<bit-label>{{ "sendLink" | i18n }}</bit-label>
|
||||
<input data-testid="send-link" bitInput type="text" [value]="sendLink" disabled />
|
||||
<button
|
||||
type="button"
|
||||
bitSuffix
|
||||
showToast
|
||||
bitIconButton="bwi-clone"
|
||||
[appCopyClick]="sendLink"
|
||||
[valueLabel]="'sendLink' | i18n"
|
||||
[label]="'copySendLink' | i18n"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "deletionDate" | i18n }}</bit-label>
|
||||
<bit-select
|
||||
@@ -126,6 +112,20 @@
|
||||
<bit-hint>{{ "enterMultipleEmailsSeparatedByComma" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
}
|
||||
|
||||
<bit-form-field *ngIf="sendLink" class="tw-mt-4">
|
||||
<bit-label>{{ "sendLink" | i18n }}</bit-label>
|
||||
<input data-testid="send-link" bitInput type="text" [value]="sendLink" disabled />
|
||||
<button
|
||||
type="button"
|
||||
bitSuffix
|
||||
showToast
|
||||
bitIconButton="bwi-clone"
|
||||
[appCopyClick]="sendLink"
|
||||
[valueLabel]="'sendLink' | i18n"
|
||||
[label]="'copySendLink' | i18n"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
<tools-send-options [config]="config" [originalSendView]="originalSendView"></tools-send-options>
|
||||
</bit-section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div *ngIf="canAccessPremium$ | async" role="toolbar" [ariaLabel]="'filters' | i18n">
|
||||
<div *ngIf="canAccessPremium$ | async" role="toolbar" [attr.aria-label]="'filters' | i18n">
|
||||
<form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mt-2">
|
||||
<bit-chip-select
|
||||
formControlName="sendType"
|
||||
|
||||
@@ -54,8 +54,6 @@ describe("SendListFiltersComponent", () => {
|
||||
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
],
|
||||
// FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports
|
||||
errorOnUnknownProperties: false,
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendListFiltersComponent);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
);
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "itemWasSentToArchive",
|
||||
message: "itemArchiveToast",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
);
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "itemWasUnarchived",
|
||||
message: "itemUnarchivedToast",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export class ArchiveCipherUtilitiesService {
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
message: this.i18nService.t("itemArchiveToast"),
|
||||
});
|
||||
return cipherResponse;
|
||||
} catch {
|
||||
@@ -90,7 +90,7 @@ export class ArchiveCipherUtilitiesService {
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasUnarchived"),
|
||||
message: this.i18nService.t("itemUnarchivedToast"),
|
||||
});
|
||||
return cipherResponse;
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user