mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
Merge branch 'main' into feature/passkey-provider
This commit is contained in:
@@ -194,7 +194,10 @@ export abstract class ApiService {
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentResponse>;
|
||||
abstract getCiphersOrganization(organizationId: string): Promise<ListResponse<CipherResponse>>;
|
||||
abstract getCiphersOrganization(
|
||||
organizationId: string,
|
||||
includeMemberItems?: boolean,
|
||||
): Promise<ListResponse<CipherResponse>>;
|
||||
abstract postCipher(request: CipherRequest): Promise<CipherResponse>;
|
||||
abstract postCipherCreate(request: CipherCreateRequest): Promise<CipherResponse>;
|
||||
abstract postCipherAdmin(request: CipherCreateRequest): Promise<CipherResponse>;
|
||||
|
||||
@@ -19,4 +19,5 @@ export enum PolicyType {
|
||||
RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
|
||||
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
|
||||
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
|
||||
AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
useSecretsManager: false,
|
||||
usePasswordManager: false,
|
||||
useActivateAutofillPolicy: false,
|
||||
useAutomaticUserConfirmation: false,
|
||||
selfHost: false,
|
||||
usersGetPremium: false,
|
||||
seats: 0,
|
||||
|
||||
@@ -30,6 +30,7 @@ export class OrganizationData {
|
||||
useSecretsManager: boolean;
|
||||
usePasswordManager: boolean;
|
||||
useActivateAutofillPolicy: boolean;
|
||||
useAutomaticUserConfirmation: boolean;
|
||||
selfHost: boolean;
|
||||
usersGetPremium: boolean;
|
||||
seats: number;
|
||||
@@ -99,6 +100,7 @@ export class OrganizationData {
|
||||
this.useSecretsManager = response.useSecretsManager;
|
||||
this.usePasswordManager = response.usePasswordManager;
|
||||
this.useActivateAutofillPolicy = response.useActivateAutofillPolicy;
|
||||
this.useAutomaticUserConfirmation = response.useAutomaticUserConfirmation;
|
||||
this.selfHost = response.selfHost;
|
||||
this.usersGetPremium = response.usersGetPremium;
|
||||
this.seats = response.seats;
|
||||
|
||||
@@ -38,6 +38,7 @@ export class Organization {
|
||||
useSecretsManager: boolean;
|
||||
usePasswordManager: boolean;
|
||||
useActivateAutofillPolicy: boolean;
|
||||
useAutomaticUserConfirmation: boolean;
|
||||
selfHost: boolean;
|
||||
usersGetPremium: boolean;
|
||||
seats: number;
|
||||
@@ -124,6 +125,7 @@ export class Organization {
|
||||
this.useSecretsManager = obj.useSecretsManager;
|
||||
this.usePasswordManager = obj.usePasswordManager;
|
||||
this.useActivateAutofillPolicy = obj.useActivateAutofillPolicy;
|
||||
this.useAutomaticUserConfirmation = obj.useAutomaticUserConfirmation;
|
||||
this.selfHost = obj.selfHost;
|
||||
this.usersGetPremium = obj.usersGetPremium;
|
||||
this.seats = obj.seats;
|
||||
|
||||
@@ -23,6 +23,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
useSecretsManager: boolean;
|
||||
usePasswordManager: boolean;
|
||||
useActivateAutofillPolicy: boolean;
|
||||
useAutomaticUserConfirmation: boolean;
|
||||
selfHost: boolean;
|
||||
usersGetPremium: boolean;
|
||||
seats: number;
|
||||
@@ -82,6 +83,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.useSecretsManager = this.getResponseProperty("UseSecretsManager");
|
||||
this.usePasswordManager = this.getResponseProperty("UsePasswordManager");
|
||||
this.useActivateAutofillPolicy = this.getResponseProperty("UseActivateAutofillPolicy");
|
||||
this.useAutomaticUserConfirmation = this.getResponseProperty("UseAutomaticUserConfirmation");
|
||||
this.selfHost = this.getResponseProperty("SelfHost");
|
||||
this.usersGetPremium = this.getResponseProperty("UsersGetPremium");
|
||||
this.seats = this.getResponseProperty("Seats");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state";
|
||||
|
||||
export class AutoConfirmState {
|
||||
enabled: boolean;
|
||||
showSetupDialog: boolean;
|
||||
showBrowserNotification: boolean | undefined;
|
||||
|
||||
constructor() {
|
||||
this.enabled = false;
|
||||
this.showSetupDialog = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTO_CONFIRM_STATE = UserKeyDefinition.record<AutoConfirmState>(
|
||||
AUTO_CONFIRM,
|
||||
"autoConfirm",
|
||||
{
|
||||
deserializer: (autoConfirmState) => autoConfirmState,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||
@@ -25,6 +27,8 @@ export abstract class BillingApiServiceAbstraction {
|
||||
|
||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||
|
||||
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;
|
||||
|
||||
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
|
||||
|
||||
abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class PremiumPlanResponse extends BaseResponse {
|
||||
seat: {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
};
|
||||
storage: {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const seat = this.getResponseProperty("Seat");
|
||||
if (!seat || typeof seat !== "object") {
|
||||
throw new Error("PremiumPlanResponse: Missing or invalid 'Seat' property");
|
||||
}
|
||||
this.seat = new PurchasableResponse(seat);
|
||||
|
||||
const storage = this.getResponseProperty("Storage");
|
||||
if (!storage || typeof storage !== "object") {
|
||||
throw new Error("PremiumPlanResponse: Missing or invalid 'Storage' property");
|
||||
}
|
||||
this.storage = new PurchasableResponse(storage);
|
||||
}
|
||||
}
|
||||
|
||||
class PurchasableResponse extends BaseResponse {
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.stripePriceId = this.getResponseProperty("StripePriceId");
|
||||
if (!this.stripePriceId || typeof this.stripePriceId !== "string") {
|
||||
throw new Error("PurchasableResponse: Missing or invalid 'StripePriceId' property");
|
||||
}
|
||||
|
||||
this.price = this.getResponseProperty("Price");
|
||||
if (typeof this.price !== "number" || isNaN(this.price)) {
|
||||
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
@@ -61,10 +63,15 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async getPlans(): Promise<ListResponse<PlanResponse>> {
|
||||
const r = await this.apiService.send("GET", "/plans", null, false, true);
|
||||
const r = await this.apiService.send("GET", "/plans", null, true, true);
|
||||
return new ListResponse(r, PlanResponse);
|
||||
}
|
||||
|
||||
async getPremiumPlan(): Promise<PremiumPlanResponse> {
|
||||
const response = await this.apiService.send("GET", "/plans/premium", null, true, true);
|
||||
return new PremiumPlanResponse(response);
|
||||
}
|
||||
|
||||
async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
}, 10);
|
||||
});
|
||||
|
||||
it("does not trigger refresh when feature flag is disabled", async () => {
|
||||
it("does trigger refresh when feature flag is disabled", async () => {
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
const mockResponse1 = createMockMetadataResponse(false, 10);
|
||||
@@ -232,11 +232,10 @@ describe("DefaultOrganizationMetadataService", () => {
|
||||
|
||||
service.refreshMetadataCache();
|
||||
|
||||
// wait to ensure no additional invocations
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(invocationCount).toBe(1);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(invocationCount).toBe(2);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs";
|
||||
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
|
||||
@@ -18,57 +18,56 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
private refreshMetadataTrigger = new Subject<void>();
|
||||
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
|
||||
|
||||
refreshMetadataCache = () => this.refreshMetadataTrigger.next();
|
||||
refreshMetadataCache = () => {
|
||||
this.metadataCache.clear();
|
||||
this.refreshMetadataTrigger.next();
|
||||
};
|
||||
|
||||
getOrganizationMetadata$ = (
|
||||
organizationId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse> =>
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure)
|
||||
.pipe(
|
||||
switchMap((featureFlagEnabled) => {
|
||||
return merge(
|
||||
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled),
|
||||
this.refreshMetadataTrigger.pipe(
|
||||
filter(() => featureFlagEnabled),
|
||||
switchMap(() =>
|
||||
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
getOrganizationMetadata$(orgId: OrganizationId): Observable<OrganizationBillingMetadataResponse> {
|
||||
return combineLatest([
|
||||
this.refreshMetadataTrigger,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure),
|
||||
]).pipe(
|
||||
switchMap(([_, featureFlagEnabled]) =>
|
||||
featureFlagEnabled
|
||||
? this.vNextGetOrganizationMetadataInternal$(orgId)
|
||||
: this.getOrganizationMetadataInternal$(orgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getOrganizationMetadataInternal$(
|
||||
organizationId: OrganizationId,
|
||||
featureFlagEnabled: boolean,
|
||||
bypassCache: boolean = false,
|
||||
private vNextGetOrganizationMetadataInternal$(
|
||||
orgId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse> {
|
||||
if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) {
|
||||
return this.metadataCache.get(organizationId)!;
|
||||
const cacheHit = this.metadataCache.get(orgId);
|
||||
if (cacheHit) {
|
||||
return cacheHit;
|
||||
}
|
||||
|
||||
const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe(
|
||||
const result = from(this.fetchMetadata(orgId, true)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
if (featureFlagEnabled) {
|
||||
this.metadataCache.set(organizationId, metadata$);
|
||||
}
|
||||
this.metadataCache.set(orgId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return metadata$;
|
||||
private getOrganizationMetadataInternal$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse> {
|
||||
return from(this.fetchMetadata(organizationId, false)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchMetadata(
|
||||
organizationId: OrganizationId,
|
||||
featureFlagEnabled: boolean,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
if (featureFlagEnabled) {
|
||||
return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId);
|
||||
}
|
||||
|
||||
return await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
||||
return featureFlagEnabled
|
||||
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
|
||||
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
|
||||
/* Auth */
|
||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||
@@ -29,6 +30,7 @@ export enum FeatureFlag {
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -45,7 +47,7 @@ export enum FeatureFlag {
|
||||
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
|
||||
|
||||
/* DIRT */
|
||||
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
PhishingDetection = "phishing-detection",
|
||||
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
|
||||
|
||||
@@ -80,6 +82,7 @@ const FALSE = false as boolean;
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
@@ -91,7 +94,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
|
||||
|
||||
@@ -113,6 +116,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// 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 { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { Matrix } from "../../../spec/matrix";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
@@ -75,6 +75,7 @@ describe("DefaultSyncService", () => {
|
||||
let authService: MockProxy<AuthService>;
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let securityStateService: MockProxy<SecurityStateService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
|
||||
let sut: DefaultSyncService;
|
||||
|
||||
@@ -105,6 +106,7 @@ describe("DefaultSyncService", () => {
|
||||
authService = mock();
|
||||
stateProvider = mock();
|
||||
securityStateService = mock();
|
||||
kdfConfigService = mock();
|
||||
|
||||
sut = new DefaultSyncService(
|
||||
masterPasswordAbstraction,
|
||||
@@ -132,6 +134,7 @@ describe("DefaultSyncService", () => {
|
||||
authService,
|
||||
stateProvider,
|
||||
securityStateService,
|
||||
kdfConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -100,6 +100,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
authService: AuthService,
|
||||
stateProvider: StateProvider,
|
||||
private securityStateService: SecurityStateService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
) {
|
||||
super(
|
||||
tokenService,
|
||||
@@ -434,6 +435,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
masterPasswordUnlockData,
|
||||
userId,
|
||||
);
|
||||
await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,14 +408,15 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new CipherResponse(r);
|
||||
}
|
||||
|
||||
async getCiphersOrganization(organizationId: string): Promise<ListResponse<CipherResponse>> {
|
||||
const r = await this.send(
|
||||
"GET",
|
||||
"/ciphers/organization-details?organizationId=" + organizationId,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
async getCiphersOrganization(
|
||||
organizationId: string,
|
||||
includeMemberItems?: boolean,
|
||||
): Promise<ListResponse<CipherResponse>> {
|
||||
let url = "/ciphers/organization-details?organizationId=" + organizationId;
|
||||
if (includeMemberItems) {
|
||||
url += `&includeMemberItems=${includeMemberItems}`;
|
||||
}
|
||||
const r = await this.send("GET", url, null, true, true);
|
||||
return new ListResponse(r, CipherResponse);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
/** When true, will override the match strategy for the cipher if it is Never. */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<C[]>;
|
||||
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
|
||||
abstract getAllFromApiForOrganization(
|
||||
organizationId: string,
|
||||
includeMemberItems?: boolean,
|
||||
): Promise<CipherView[]>;
|
||||
/**
|
||||
* Gets ciphers belonging to the specified organization that the user has explicit collection level access to.
|
||||
* Ciphers that are not assigned to any collections are only included for users with admin access.
|
||||
|
||||
@@ -5,4 +5,5 @@ export class AttachmentRequest {
|
||||
key: string;
|
||||
fileSize: number;
|
||||
adminRequest: boolean;
|
||||
lastKnownRevisionDate: Date;
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ export class CipherRequest {
|
||||
this.attachments[attachment.id] = fileName;
|
||||
const attachmentRequest = new AttachmentRequest();
|
||||
attachmentRequest.fileName = fileName;
|
||||
attachmentRequest.lastKnownRevisionDate = cipher.revisionDate;
|
||||
if (attachment.key != null) {
|
||||
attachmentRequest.key = attachment.key.encryptedString;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,37 @@ describe("Cipher Service", () => {
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include lastKnownRevisionDate in the upload request", async () => {
|
||||
const fileName = "filename";
|
||||
const fileData = new Uint8Array(10);
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const expectedRevisionDate = "2022-01-31T12:00:00.000Z";
|
||||
|
||||
keyService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
keyService.makeDataEncKey.mockReturnValue(
|
||||
Promise.resolve([
|
||||
new SymmetricCryptoKey(new Uint8Array(32)),
|
||||
new EncString("encrypted-key"),
|
||||
] as any),
|
||||
);
|
||||
|
||||
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(false);
|
||||
|
||||
const uploadSpy = jest.spyOn(cipherFileUploadService, "upload").mockResolvedValue({} as any);
|
||||
|
||||
await cipherService.saveAttachmentRawWithServer(testCipher, fileName, fileData, userId);
|
||||
|
||||
// Verify upload was called with cipher that has revisionDate
|
||||
expect(uploadSpy).toHaveBeenCalled();
|
||||
const cipherArg = uploadSpy.mock.calls[0][0];
|
||||
expect(cipherArg.revisionDate).toEqual(new Date(expectedRevisionDate));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWithServer()", () => {
|
||||
|
||||
@@ -700,8 +700,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
.sort((a, b) => this.sortCiphersByLastUsedThenName(a, b));
|
||||
}
|
||||
|
||||
async getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]> {
|
||||
const response = await this.apiService.getCiphersOrganization(organizationId);
|
||||
async getAllFromApiForOrganization(
|
||||
organizationId: string,
|
||||
includeMemberItems?: boolean,
|
||||
): Promise<CipherView[]> {
|
||||
const response = await this.apiService.getCiphersOrganization(
|
||||
organizationId,
|
||||
includeMemberItems,
|
||||
);
|
||||
return await this.decryptOrganizationCiphersResponse(response, organizationId);
|
||||
}
|
||||
|
||||
@@ -946,7 +952,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
cipher.attachments.forEach((attachment) => {
|
||||
if (attachment.key == null) {
|
||||
attachmentPromises.push(
|
||||
this.shareAttachmentWithServer(attachment, cipher.id, organizationId),
|
||||
this.shareAttachmentWithServer(
|
||||
attachment,
|
||||
cipher.id,
|
||||
organizationId,
|
||||
cipher.revisionDate,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1731,7 +1742,10 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
attachmentView: AttachmentView,
|
||||
cipherId: string,
|
||||
organizationId: string,
|
||||
lastKnownRevisionDate: Date,
|
||||
): Promise<any> {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
const attachmentResponse = await this.apiService.nativeFetch(
|
||||
new Request(attachmentView.url, { cache: "no-store" }),
|
||||
);
|
||||
@@ -1740,7 +1754,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse);
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const userKey = await this.keyService.getUserKey(activeUserId.id);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, userKey);
|
||||
|
||||
@@ -1761,9 +1774,11 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
const blob = new Blob([encData.buffer], { type: "application/octet-stream" });
|
||||
fd.append("key", dataEncKey[1].encryptedString);
|
||||
fd.append("data", blob, encFileName.encryptedString);
|
||||
fd.append("lastKnownRevisionDate", lastKnownRevisionDate.toISOString());
|
||||
} catch (e) {
|
||||
if (Utils.isNode && !Utils.isBrowser) {
|
||||
fd.append("key", dataEncKey[1].encryptedString);
|
||||
fd.append("lastKnownRevisionDate", lastKnownRevisionDate.toISOString());
|
||||
fd.append(
|
||||
"data",
|
||||
Buffer.from(encData.buffer) as any,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { FileUploadService } from "../../../platform/abstractions/file-upload/file-upload.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CipherType } from "../../enums/cipher-type";
|
||||
import { Cipher } from "../../models/domain/cipher";
|
||||
import { AttachmentUploadDataResponse } from "../../models/response/attachment-upload-data.response";
|
||||
import { CipherResponse } from "../../models/response/cipher.response";
|
||||
|
||||
import { CipherFileUploadService } from "./cipher-file-upload.service";
|
||||
|
||||
describe("CipherFileUploadService", () => {
|
||||
const apiService = mock<ApiService>();
|
||||
const fileUploadService = mock<FileUploadService>();
|
||||
|
||||
let service: CipherFileUploadService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
service = new CipherFileUploadService(apiService, fileUploadService);
|
||||
});
|
||||
|
||||
describe("upload", () => {
|
||||
it("should include lastKnownRevisionDate in the attachment request", async () => {
|
||||
const cipherId = Utils.newGuid();
|
||||
const mockCipher = new Cipher({
|
||||
id: cipherId,
|
||||
type: CipherType.Login,
|
||||
name: "Test Cipher",
|
||||
revisionDate: "2024-01-15T10:30:00.000Z",
|
||||
} as any);
|
||||
|
||||
const mockEncFileName = new EncString("encrypted-filename");
|
||||
const mockEncData = {
|
||||
buffer: new ArrayBuffer(100),
|
||||
} as unknown as EncArrayBuffer;
|
||||
|
||||
const mockDataEncKey: [SymmetricCryptoKey, EncString] = [
|
||||
new SymmetricCryptoKey(new Uint8Array(32)),
|
||||
new EncString("encrypted-key"),
|
||||
];
|
||||
|
||||
const mockUploadDataResponse = {
|
||||
attachmentId: "attachment-id",
|
||||
url: "https://upload.example.com",
|
||||
fileUploadType: 0,
|
||||
cipherResponse: {
|
||||
id: cipherId,
|
||||
type: CipherType.Login,
|
||||
revisionDate: "2024-01-15T10:30:00.000Z",
|
||||
} as CipherResponse,
|
||||
cipherMiniResponse: null,
|
||||
} as AttachmentUploadDataResponse;
|
||||
|
||||
apiService.postCipherAttachment.mockResolvedValue(mockUploadDataResponse);
|
||||
fileUploadService.upload.mockResolvedValue(undefined);
|
||||
|
||||
await service.upload(mockCipher, mockEncFileName, mockEncData, false, mockDataEncKey);
|
||||
|
||||
const callArgs = apiService.postCipherAttachment.mock.calls[0][1];
|
||||
|
||||
expect(apiService.postCipherAttachment).toHaveBeenCalledWith(
|
||||
cipherId,
|
||||
expect.objectContaining({
|
||||
key: "encrypted-key",
|
||||
fileName: "encrypted-filename",
|
||||
fileSize: 100,
|
||||
adminRequest: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify lastKnownRevisionDate is set (it's converted to a Date object)
|
||||
expect(callArgs.lastKnownRevisionDate).toBeDefined();
|
||||
expect(callArgs.lastKnownRevisionDate).toEqual(new Date("2024-01-15T10:30:00.000Z"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
|
||||
fileName: encFileName.encryptedString,
|
||||
fileSize: encData.buffer.byteLength,
|
||||
adminRequest: admin,
|
||||
lastKnownRevisionDate: cipher.revisionDate,
|
||||
};
|
||||
|
||||
let response: CipherResponse;
|
||||
|
||||
@@ -25,6 +25,12 @@ export abstract class TaskService {
|
||||
*/
|
||||
abstract pendingTasks$(userId: UserId): Observable<SecurityTask[]>;
|
||||
|
||||
/**
|
||||
* Observable of completed tasks for a given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract completedTasks$(userId: UserId): Observable<SecurityTask[]>;
|
||||
|
||||
/**
|
||||
* Retrieves tasks from the API for a given user and updates the local state.
|
||||
* @param userId
|
||||
|
||||
@@ -80,6 +80,12 @@ export class DefaultTaskService implements TaskService {
|
||||
);
|
||||
});
|
||||
|
||||
completedTasks$ = perUserCache$((userId) => {
|
||||
return this.tasks$(userId).pipe(
|
||||
map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Completed)),
|
||||
);
|
||||
});
|
||||
|
||||
async refreshTasks(userId: UserId): Promise<void> {
|
||||
await this.fetchTasksFromApi(userId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user