mirror of
https://github.com/bitwarden/browser
synced 2026-01-09 12:03:33 +00:00
Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence }}
|
||||
/ {{ cardDetails.price.cadence | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,6 +223,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
@@ -261,6 +262,7 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
|
||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
|
||||
import { DefaultRegisterSdkService } from "@bitwarden/common/platform/services/sdk/register-sdk.service";
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
||||
@@ -1586,6 +1588,19 @@ const safeProviders: SafeProvider[] = [
|
||||
SsoLoginServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RegisterSdkService,
|
||||
useClass: DefaultRegisterSdkService,
|
||||
deps: [
|
||||
SdkClientFactory,
|
||||
EnvironmentService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
AccountServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
StateProvider,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SdkService,
|
||||
useClass: DefaultSdkService,
|
||||
|
||||
@@ -88,14 +88,10 @@ export class VaultFilterComponent implements OnInit {
|
||||
this.folders$ = await this.vaultFilterService.buildNestedFolders();
|
||||
this.collections = await this.initCollections();
|
||||
|
||||
const userCanArchive = await firstValueFrom(
|
||||
this.cipherArchiveService.userCanArchive$(this.activeUserId),
|
||||
);
|
||||
const showArchiveVault = await firstValueFrom(
|
||||
this.cipherArchiveService.showArchiveVault$(this.activeUserId),
|
||||
this.showArchiveVaultFilter = await firstValueFrom(
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||
);
|
||||
|
||||
this.showArchiveVaultFilter = userCanArchive || showArchiveVault;
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { OrganizationKeysRequest } from "./organization-keys.request";
|
||||
|
||||
export class OrganizationUpdateRequest {
|
||||
name: string;
|
||||
businessName: string;
|
||||
billingEmail: string;
|
||||
keys: OrganizationKeysRequest;
|
||||
export interface OrganizationUpdateRequest {
|
||||
name?: string;
|
||||
billingEmail?: string;
|
||||
keys?: OrganizationKeysRequest;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ export enum FeatureFlag {
|
||||
|
||||
/* Innovation */
|
||||
PM19148_InnovationArchive = "pm-19148-innovation-archive",
|
||||
|
||||
/* UIF */
|
||||
RouterFocusManagement = "router-focus-management",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -150,6 +153,9 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Innovation */
|
||||
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
||||
|
||||
/* UIF */
|
||||
[FeatureFlag.RouterFocusManagement]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -55,4 +55,5 @@ export abstract class PlatformUtilsService {
|
||||
abstract readFromClipboard(): Promise<string>;
|
||||
abstract supportsSecureStorage(): boolean;
|
||||
abstract getAutofillKeyboardShortcut(): Promise<string>;
|
||||
abstract packageType(): Promise<string | null>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { BitwardenClient, Uuid } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Rc } from "../../misc/reference-counting/rc";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
export class UserNotLoggedInError extends Error {
|
||||
constructor(userId: UserId) {
|
||||
super(`User (${userId}) is not logged in`);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidUuid extends Error {
|
||||
constructor(uuid: string) {
|
||||
super(`Invalid UUID: ${uuid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to UUID. Will throw an error if the UUID is non valid.
|
||||
*/
|
||||
export function asUuid<T extends Uuid>(uuid: string): T {
|
||||
if (Utils.isGuid(uuid)) {
|
||||
return uuid as T;
|
||||
}
|
||||
|
||||
throw new InvalidUuid(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a UUID to the string representation.
|
||||
*/
|
||||
export function uuidAsString<T extends Uuid>(uuid: T): string {
|
||||
return uuid as unknown as string;
|
||||
}
|
||||
|
||||
export abstract class RegisterSdkService {
|
||||
/**
|
||||
* Retrieve a client with tokens for a specific user.
|
||||
* This client is meant exclusively for registrations that require tokens, such as TDE and key-connector.
|
||||
*
|
||||
* - If the user is not logged when the subscription is created, the observable will complete
|
||||
* immediately with {@link UserNotLoggedInError}.
|
||||
* - If the user is logged in, the observable will emit the client and complete without an error
|
||||
* when the user logs out.
|
||||
*
|
||||
* **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable.
|
||||
* The client will be destroyed when the observable is no longer subscribed to.
|
||||
* Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to.
|
||||
*
|
||||
* @param userId The user id for which to retrieve the client
|
||||
*/
|
||||
abstract registerClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { PasswordManagerClient, Uuid } from "@bitwarden/sdk-internal";
|
||||
import { PasswordManagerClient, Uuid, DeviceType as SdkDeviceType } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { DeviceType } from "../../../enums";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Rc } from "../../misc/reference-counting/rc";
|
||||
import { Utils } from "../../misc/utils";
|
||||
@@ -18,6 +19,63 @@ export class InvalidUuid extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function toSdkDevice(device: DeviceType): SdkDeviceType {
|
||||
switch (device) {
|
||||
case DeviceType.Android:
|
||||
return "Android";
|
||||
case DeviceType.iOS:
|
||||
return "iOS";
|
||||
case DeviceType.ChromeExtension:
|
||||
return "ChromeExtension";
|
||||
case DeviceType.FirefoxExtension:
|
||||
return "FirefoxExtension";
|
||||
case DeviceType.OperaExtension:
|
||||
return "OperaExtension";
|
||||
case DeviceType.EdgeExtension:
|
||||
return "EdgeExtension";
|
||||
case DeviceType.WindowsDesktop:
|
||||
return "WindowsDesktop";
|
||||
case DeviceType.MacOsDesktop:
|
||||
return "MacOsDesktop";
|
||||
case DeviceType.LinuxDesktop:
|
||||
return "LinuxDesktop";
|
||||
case DeviceType.ChromeBrowser:
|
||||
return "ChromeBrowser";
|
||||
case DeviceType.FirefoxBrowser:
|
||||
return "FirefoxBrowser";
|
||||
case DeviceType.OperaBrowser:
|
||||
return "OperaBrowser";
|
||||
case DeviceType.EdgeBrowser:
|
||||
return "EdgeBrowser";
|
||||
case DeviceType.IEBrowser:
|
||||
return "IEBrowser";
|
||||
case DeviceType.UnknownBrowser:
|
||||
return "UnknownBrowser";
|
||||
case DeviceType.AndroidAmazon:
|
||||
return "AndroidAmazon";
|
||||
case DeviceType.UWP:
|
||||
return "UWP";
|
||||
case DeviceType.SafariBrowser:
|
||||
return "SafariBrowser";
|
||||
case DeviceType.VivaldiBrowser:
|
||||
return "VivaldiBrowser";
|
||||
case DeviceType.VivaldiExtension:
|
||||
return "VivaldiExtension";
|
||||
case DeviceType.SafariExtension:
|
||||
return "SafariExtension";
|
||||
case DeviceType.Server:
|
||||
return "Server";
|
||||
case DeviceType.WindowsCLI:
|
||||
return "WindowsCLI";
|
||||
case DeviceType.MacOsCLI:
|
||||
return "MacOsCLI";
|
||||
case DeviceType.LinuxCLI:
|
||||
return "LinuxCLI";
|
||||
default:
|
||||
return "SDK";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to UUID. Will throw an error if the UUID is non valid.
|
||||
*/
|
||||
|
||||
@@ -22,14 +22,12 @@ import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key
|
||||
import {
|
||||
PasswordManagerClient,
|
||||
ClientSettings,
|
||||
DeviceType as SdkDeviceType,
|
||||
TokenProvider,
|
||||
UnsignedSharedKey,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { DeviceType } from "../../../enums/device-type.enum";
|
||||
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
|
||||
import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
|
||||
@@ -39,7 +37,12 @@ import { Environment, EnvironmentService } from "../../abstractions/environment.
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
|
||||
import { asUuid, SdkService, UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
|
||||
import {
|
||||
asUuid,
|
||||
SdkService,
|
||||
toSdkDevice,
|
||||
UserNotLoggedInError,
|
||||
} from "../../abstractions/sdk/sdk.service";
|
||||
import { compareValues } from "../../misc/compare-values";
|
||||
import { Rc } from "../../misc/reference-counting/rc";
|
||||
import { StateProvider } from "../../state";
|
||||
@@ -297,65 +300,8 @@ export class DefaultSdkService implements SdkService {
|
||||
return {
|
||||
apiUrl: env.getApiUrl(),
|
||||
identityUrl: env.getIdentityUrl(),
|
||||
deviceType: this.toDevice(this.platformUtilsService.getDevice()),
|
||||
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
|
||||
userAgent: this.userAgent ?? navigator.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
private toDevice(device: DeviceType): SdkDeviceType {
|
||||
switch (device) {
|
||||
case DeviceType.Android:
|
||||
return "Android";
|
||||
case DeviceType.iOS:
|
||||
return "iOS";
|
||||
case DeviceType.ChromeExtension:
|
||||
return "ChromeExtension";
|
||||
case DeviceType.FirefoxExtension:
|
||||
return "FirefoxExtension";
|
||||
case DeviceType.OperaExtension:
|
||||
return "OperaExtension";
|
||||
case DeviceType.EdgeExtension:
|
||||
return "EdgeExtension";
|
||||
case DeviceType.WindowsDesktop:
|
||||
return "WindowsDesktop";
|
||||
case DeviceType.MacOsDesktop:
|
||||
return "MacOsDesktop";
|
||||
case DeviceType.LinuxDesktop:
|
||||
return "LinuxDesktop";
|
||||
case DeviceType.ChromeBrowser:
|
||||
return "ChromeBrowser";
|
||||
case DeviceType.FirefoxBrowser:
|
||||
return "FirefoxBrowser";
|
||||
case DeviceType.OperaBrowser:
|
||||
return "OperaBrowser";
|
||||
case DeviceType.EdgeBrowser:
|
||||
return "EdgeBrowser";
|
||||
case DeviceType.IEBrowser:
|
||||
return "IEBrowser";
|
||||
case DeviceType.UnknownBrowser:
|
||||
return "UnknownBrowser";
|
||||
case DeviceType.AndroidAmazon:
|
||||
return "AndroidAmazon";
|
||||
case DeviceType.UWP:
|
||||
return "UWP";
|
||||
case DeviceType.SafariBrowser:
|
||||
return "SafariBrowser";
|
||||
case DeviceType.VivaldiBrowser:
|
||||
return "VivaldiBrowser";
|
||||
case DeviceType.VivaldiExtension:
|
||||
return "VivaldiExtension";
|
||||
case DeviceType.SafariExtension:
|
||||
return "SafariExtension";
|
||||
case DeviceType.Server:
|
||||
return "Server";
|
||||
case DeviceType.WindowsCLI:
|
||||
return "WindowsCLI";
|
||||
case DeviceType.MacOsCLI:
|
||||
return "MacOsCLI";
|
||||
case DeviceType.LinuxCLI:
|
||||
return "LinuxCLI";
|
||||
default:
|
||||
return "SDK";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import {
|
||||
ObservableTracker,
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
|
||||
import { UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
|
||||
import { Rc } from "../../misc/reference-counting/rc";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { DefaultRegisterSdkService } from "./register-sdk.service";
|
||||
|
||||
class TestSdkLoadService extends SdkLoadService {
|
||||
protected override load(): Promise<void> {
|
||||
// Simulate successful WASM load
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("DefaultRegisterSdkService", () => {
|
||||
describe("userClient$", () => {
|
||||
let sdkClientFactory!: MockProxy<SdkClientFactory>;
|
||||
let environmentService!: MockProxy<EnvironmentService>;
|
||||
let platformUtilsService!: MockProxy<PlatformUtilsService>;
|
||||
let configService!: MockProxy<ConfigService>;
|
||||
let service!: DefaultRegisterSdkService;
|
||||
let accountService!: FakeAccountService;
|
||||
let fakeStateProvider!: FakeStateProvider;
|
||||
let apiService!: MockProxy<ApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await new TestSdkLoadService().loadAndInit();
|
||||
|
||||
sdkClientFactory = mock<SdkClientFactory>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
apiService = mock<ApiService>();
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
fakeStateProvider = new FakeStateProvider(accountService);
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
configService.serverConfig$ = new BehaviorSubject(null);
|
||||
|
||||
// Can't use `of(mock<Environment>())` for some reason
|
||||
environmentService.environment$ = new BehaviorSubject(mock<Environment>());
|
||||
|
||||
service = new DefaultRegisterSdkService(
|
||||
sdkClientFactory,
|
||||
environmentService,
|
||||
platformUtilsService,
|
||||
accountService,
|
||||
apiService,
|
||||
fakeStateProvider,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("given the user is logged in", () => {
|
||||
const userId = "0da62ebd-98bb-4f42-a846-64e8555087d7" as UserId;
|
||||
beforeEach(() => {
|
||||
environmentService.getEnvironment$
|
||||
.calledWith(userId)
|
||||
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
|
||||
accountService.accounts$ = of({
|
||||
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
|
||||
});
|
||||
});
|
||||
|
||||
let mockClient!: MockProxy<BitwardenClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
sdkClientFactory.createSdkClient.mockResolvedValue(mockClient);
|
||||
});
|
||||
|
||||
it("creates an internal SDK client when called the first time", async () => {
|
||||
await firstValueFrom(service.registerClient$(userId));
|
||||
|
||||
expect(sdkClientFactory.createSdkClient).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create an SDK client when called the second time with same userId", async () => {
|
||||
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
|
||||
// Use subjects to ensure the subscription is kept alive
|
||||
service.registerClient$(userId).subscribe(subject_1);
|
||||
service.registerClient$(userId).subscribe(subject_2);
|
||||
|
||||
// Wait for the next tick to ensure all async operations are done
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(subject_1.value.take().value).toBe(mockClient);
|
||||
expect(subject_2.value.take().value).toBe(mockClient);
|
||||
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("destroys the internal SDK client when all subscriptions are closed", async () => {
|
||||
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subscription_1 = service.registerClient$(userId).subscribe(subject_1);
|
||||
const subscription_2 = service.registerClient$(userId).subscribe(subject_2);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
subscription_1.unsubscribe();
|
||||
subscription_2.unsubscribe();
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
expect(mockClient.free).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("destroys the internal SDK client when the account is removed (logout)", async () => {
|
||||
const accounts$ = new BehaviorSubject({
|
||||
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
|
||||
});
|
||||
accountService.accounts$ = accounts$;
|
||||
|
||||
const userClientTracker = new ObservableTracker(service.registerClient$(userId), false);
|
||||
await userClientTracker.pauseUntilReceived(1);
|
||||
|
||||
accounts$.next({});
|
||||
await userClientTracker.expectCompletion();
|
||||
|
||||
expect(mockClient.free).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the user is not logged in", () => {
|
||||
const userId = "0da62ebd-98bb-4f42-a846-64e8555087d7" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
environmentService.getEnvironment$
|
||||
.calledWith(userId)
|
||||
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
|
||||
accountService.accounts$ = of({});
|
||||
});
|
||||
|
||||
it("throws UserNotLoggedInError when user has no account", async () => {
|
||||
const result = () => firstValueFrom(service.registerClient$(userId));
|
||||
|
||||
await expect(result).rejects.toThrow(UserNotLoggedInError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockClient(): MockProxy<BitwardenClient> {
|
||||
const client = mock<BitwardenClient>();
|
||||
client.platform.mockReturnValue({
|
||||
state: jest.fn().mockReturnValue(mock()),
|
||||
load_flags: jest.fn().mockReturnValue(mock()),
|
||||
free: mock(),
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
196
libs/common/src/platform/services/sdk/register-sdk.service.ts
Normal file
196
libs/common/src/platform/services/sdk/register-sdk.service.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
combineLatest,
|
||||
concatMap,
|
||||
Observable,
|
||||
shareReplay,
|
||||
map,
|
||||
distinctUntilChanged,
|
||||
tap,
|
||||
switchMap,
|
||||
BehaviorSubject,
|
||||
of,
|
||||
takeWhile,
|
||||
throwIfEmpty,
|
||||
firstValueFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { PasswordManagerClient, ClientSettings, TokenProvider } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
import { RegisterSdkService } from "../../abstractions/sdk/register-sdk.service";
|
||||
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
|
||||
import { toSdkDevice, UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
|
||||
import { Rc } from "../../misc/reference-counting/rc";
|
||||
import { StateProvider } from "../../state";
|
||||
|
||||
import { initializeState } from "./client-managed-state";
|
||||
|
||||
// A symbol that represents an overridden client that is explicitly set to undefined,
|
||||
// blocking the creation of an internal client for that user.
|
||||
const UnsetClient = Symbol("UnsetClient");
|
||||
|
||||
/**
|
||||
* A token provider that exposes the access token to the SDK.
|
||||
*/
|
||||
class JsTokenProvider implements TokenProvider {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private userId?: UserId,
|
||||
) {}
|
||||
|
||||
async get_access_token(): Promise<string | undefined> {
|
||||
if (this.userId == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await this.apiService.getActiveBearerToken(this.userId);
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
private sdkClientOverrides = new BehaviorSubject<{
|
||||
[userId: UserId]: Rc<PasswordManagerClient> | typeof UnsetClient;
|
||||
}>({});
|
||||
private sdkClientCache = new Map<UserId, Observable<Rc<PasswordManagerClient>>>();
|
||||
|
||||
client$ = this.environmentService.environment$.pipe(
|
||||
concatMap(async (env) => {
|
||||
await SdkLoadService.Ready;
|
||||
const settings = this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService),
|
||||
settings,
|
||||
);
|
||||
await this.loadFeatureFlags(client);
|
||||
return client;
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private sdkClientFactory: SdkClientFactory,
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private stateProvider: StateProvider,
|
||||
private configService: ConfigService,
|
||||
private userAgent: string | null = null,
|
||||
) {}
|
||||
|
||||
registerClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
|
||||
return this.sdkClientOverrides.pipe(
|
||||
takeWhile((clients) => clients[userId] !== UnsetClient, false),
|
||||
map((clients) => {
|
||||
if (clients[userId] === UnsetClient) {
|
||||
throw new Error("Encountered UnsetClient even though it should have been filtered out");
|
||||
}
|
||||
return clients[userId] as Rc<PasswordManagerClient>;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
switchMap((clientOverride) => {
|
||||
if (clientOverride) {
|
||||
return of(clientOverride);
|
||||
}
|
||||
|
||||
return this.internalClient$(userId);
|
||||
}),
|
||||
takeWhile((client) => client !== undefined, false),
|
||||
throwIfEmpty(() => new UserNotLoggedInError(userId)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to create a client for a specific user by using the existing state of the application.
|
||||
* This client is token-only and does not initialize any encryption keys.
|
||||
* @param userId The user id for which to create the client
|
||||
* @returns An observable that emits the client for the user
|
||||
*/
|
||||
private internalClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
|
||||
const cached = this.sdkClientCache.get(userId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const account$ = this.accountService.accounts$.pipe(
|
||||
map((accounts) => accounts[userId]),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const client$ = combineLatest([
|
||||
this.environmentService.getEnvironment$(userId),
|
||||
account$,
|
||||
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
|
||||
]).pipe(
|
||||
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
|
||||
switchMap(([env, account]) => {
|
||||
// Create our own observable to be able to implement clean-up logic
|
||||
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
|
||||
const createAndInitializeClient = async () => {
|
||||
if (env == null || account == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
);
|
||||
|
||||
// Initialize the SDK managed database and the client managed repositories.
|
||||
await initializeState(userId, client.platform().state(), this.stateProvider);
|
||||
|
||||
await this.loadFeatureFlags(client);
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
let client: Rc<PasswordManagerClient> | undefined;
|
||||
createAndInitializeClient()
|
||||
.then((c) => {
|
||||
client = c === undefined ? undefined : new Rc(c);
|
||||
|
||||
subscriber.next(client);
|
||||
})
|
||||
.catch((e) => {
|
||||
subscriber.error(e);
|
||||
});
|
||||
|
||||
return () => client?.markForDisposal();
|
||||
});
|
||||
}),
|
||||
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
this.sdkClientCache.set(userId, client$);
|
||||
return client$;
|
||||
}
|
||||
|
||||
private async loadFeatureFlags(client: PasswordManagerClient) {
|
||||
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
|
||||
|
||||
const featureFlagMap = new Map(
|
||||
Object.entries(serverConfig?.featureStates ?? {})
|
||||
.filter(([, value]) => typeof value === "boolean") // The SDK only supports boolean feature flags at this time
|
||||
.map(([key, value]) => [key, value] as [string, boolean]),
|
||||
);
|
||||
|
||||
client.platform().load_flags(featureFlagMap);
|
||||
}
|
||||
|
||||
private toSettings(env: Environment): ClientSettings {
|
||||
return {
|
||||
apiUrl: env.getApiUrl(),
|
||||
identityUrl: env.getIdentityUrl(),
|
||||
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
|
||||
userAgent: this.userAgent ?? navigator.userAgent,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1325,6 +1325,11 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
"Bitwarden-Client-Version",
|
||||
await this.platformUtilsService.getApplicationVersionNumber(),
|
||||
);
|
||||
|
||||
const packageType = await this.platformUtilsService.packageType();
|
||||
if (packageType != null) {
|
||||
request.headers.set("Bitwarden-Package-Type", packageType);
|
||||
}
|
||||
return this.nativeFetch(request);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum SendType {
|
||||
Text = 0,
|
||||
File = 1,
|
||||
}
|
||||
/** A type of Send. */
|
||||
export const SendType = Object.freeze({
|
||||
/** Send contains plain text. */
|
||||
Text: 0,
|
||||
/** Send contains a file. */
|
||||
File: 1,
|
||||
} as const);
|
||||
|
||||
/** A type of Send. */
|
||||
export type SendType = (typeof SendType)[keyof typeof SendType];
|
||||
|
||||
@@ -141,6 +141,8 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
* Usage of the {@link CipherViewLike} type is recommended to ensure both `CipherView` and `CipherListView` are supported.
|
||||
*/
|
||||
cipherListViews$ = perUserCache$((userId: UserId) => {
|
||||
let decryptStartTime: number;
|
||||
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe(
|
||||
switchMap((useSdk) => {
|
||||
if (!useSdk) {
|
||||
@@ -158,11 +160,23 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
(cipherData) => new Cipher(cipherData, localData?.[cipherData.id as CipherId]),
|
||||
),
|
||||
),
|
||||
tap(() => {
|
||||
decryptStartTime = performance.now();
|
||||
}),
|
||||
switchMap(async (ciphers) => {
|
||||
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
await this.setFailedDecryptedCiphers(failures, userId);
|
||||
return decrypted;
|
||||
}),
|
||||
tap((decrypted) => {
|
||||
this.logService.measure(
|
||||
decryptStartTime,
|
||||
"Vault",
|
||||
"CipherService",
|
||||
"listView decrypt complete",
|
||||
[["Items", decrypted.length]],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./a11y-title.directive";
|
||||
export * from "./aria-disabled-click-capture.service";
|
||||
export * from "./aria-disable.directive";
|
||||
export * from "./router-focus-manager.service";
|
||||
|
||||
65
libs/components/src/a11y/router-focus-manager.service.ts
Normal file
65
libs/components/src/a11y/router-focus-manager.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { skip, filter, map, combineLatestWith, tap } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class RouterFocusManagerService {
|
||||
private router = inject(Router);
|
||||
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
/**
|
||||
* Handles SPA route focus management. SPA apps don't automatically notify screenreader
|
||||
* users that navigation has occured or move the user's focus to the content they are
|
||||
* navigating to, so we need to do it.
|
||||
*
|
||||
* By default, we focus the `main` after an internal route navigation.
|
||||
*
|
||||
* Consumers can opt out of the passing the following to the `info` input:
|
||||
* `<a [routerLink]="route()" [info]="{ focusMainAfterNav: false }"></a>`
|
||||
*
|
||||
* Or, consumers can use the autofocus directive on an applicable interactive element.
|
||||
* The autofocus directive will take precedence over this route focus pipeline.
|
||||
*
|
||||
* Example of where you might want to manually opt out:
|
||||
* - Tab component causes a route navigation, but the tab content should be focused,
|
||||
* not the whole `main`
|
||||
*
|
||||
* Note that router events that cause a fully new page to load (like switching between
|
||||
* products) will not follow this pipeline. Instead, those will automatically bring
|
||||
* focus to the top of the html document as if it were a full page load. So those links
|
||||
* do not need to manually opt out of this pipeline.
|
||||
*/
|
||||
start$ = this.router.events.pipe(
|
||||
takeUntilDestroyed(),
|
||||
filter((navEvent) => navEvent instanceof NavigationEnd),
|
||||
/**
|
||||
* On first page load, we do not want to skip the user over the navigation content,
|
||||
* so we opt out of the default focus management behavior.
|
||||
*/
|
||||
skip(1),
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)),
|
||||
filter(([_navEvent, flagEnabled]) => flagEnabled),
|
||||
map(() => {
|
||||
const currentNavData = this.router.getCurrentNavigation()?.extras;
|
||||
|
||||
const info = currentNavData?.info as { focusMainAfterNav?: boolean } | undefined;
|
||||
|
||||
return info;
|
||||
}),
|
||||
filter((currentNavInfo) => {
|
||||
return currentNavInfo === undefined ? true : currentNavInfo?.focusMainAfterNav !== false;
|
||||
}),
|
||||
tap(() => {
|
||||
const mainEl = document.querySelector<HTMLElement>("main");
|
||||
|
||||
if (mainEl) {
|
||||
mainEl.focus();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-gap-4 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
[ngClass]="bannerClass"
|
||||
[class]="bannerClass()"
|
||||
[attr.role]="useAlertRole() ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole() ? 'polite' : null"
|
||||
>
|
||||
@if (icon(); as icon) {
|
||||
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" aria-hidden="true"></i>
|
||||
@if (displayIcon(); as icon) {
|
||||
<i class="bwi tw-align-middle tw-text-base" [class]="icon" aria-hidden="true"></i>
|
||||
}
|
||||
<!-- Overriding focus-visible color for link buttons for a11y against colored background -->
|
||||
<span class="tw-grow tw-text-base [&>button[bitlink]:focus-visible:before]:!tw-ring-text-main">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, Output, EventEmitter, input, model } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -13,47 +12,69 @@ const defaultIcon: Record<BannerType, string> = {
|
||||
warning: "bwi-exclamation-triangle",
|
||||
danger: "bwi-error",
|
||||
};
|
||||
/**
|
||||
* Banners are used for important communication with the user that needs to be seen right away, but has
|
||||
* little effect on the experience. Banners appear at the top of the user's screen on page load and
|
||||
* persist across all pages a user navigates to.
|
||||
|
||||
* - They should always be dismissible and never use a timeout. If a user dismisses a banner, it should not reappear during that same active session.
|
||||
* - Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their effectiveness may decrease if too many are used.
|
||||
* - Avoid stacking multiple banners.
|
||||
* - Banners can contain a button or anchor that uses the `bitLink` directive with `linkType="secondary"`.
|
||||
/**
|
||||
* Banners are used for important communication with the user that needs to be seen right away, but has
|
||||
* little effect on the experience. Banners appear at the top of the user's screen on page load and
|
||||
* persist across all pages a user navigates to.
|
||||
*
|
||||
* - They should always be dismissible and never use a timeout. If a user dismisses a banner, it should not reappear during that same active session.
|
||||
* - Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their effectiveness may decrease if too many are used.
|
||||
* - Avoid stacking multiple banners.
|
||||
* - Banners can contain a button or anchor that uses the `bitLink` directive with `linkType="secondary"`.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-banner",
|
||||
templateUrl: "./banner.component.html",
|
||||
imports: [CommonModule, IconButtonModule, I18nPipe],
|
||||
imports: [IconButtonModule, I18nPipe],
|
||||
host: {
|
||||
// Account for bit-layout's padding
|
||||
class:
|
||||
"tw-flex tw-flex-col [bit-layout_&]:-tw-mx-8 [bit-layout_&]:-tw-my-6 [bit-layout_&]:tw-pb-6",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BannerComponent implements OnInit {
|
||||
export class BannerComponent {
|
||||
/**
|
||||
* The type of banner, which determines its color scheme.
|
||||
*/
|
||||
readonly bannerType = input<BannerType>("info");
|
||||
|
||||
// passing `null` will remove the icon from element from the banner
|
||||
readonly icon = model<string | null>();
|
||||
/**
|
||||
* The icon to display. If not provided, a default icon based on bannerType will be used. Explicitly passing null will remove the icon.
|
||||
*/
|
||||
readonly icon = input<string | null>();
|
||||
|
||||
/**
|
||||
* Whether to use ARIA alert role for screen readers.
|
||||
*/
|
||||
readonly useAlertRole = input(true);
|
||||
|
||||
/**
|
||||
* Whether to show the close button.
|
||||
*/
|
||||
readonly showClose = input(true);
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() onClose = new EventEmitter<void>();
|
||||
/**
|
||||
* Emitted when the banner is closed via the close button.
|
||||
*/
|
||||
readonly onClose = output();
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.icon() && this.icon() !== null) {
|
||||
this.icon.set(defaultIcon[this.bannerType()]);
|
||||
/**
|
||||
* The computed icon to display, falling back to the default icon for the banner type.
|
||||
* Returns null if icon is explicitly set to null (to hide the icon).
|
||||
*/
|
||||
protected readonly displayIcon = computed(() => {
|
||||
// If icon is explicitly null, don't show any icon
|
||||
if (this.icon() === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get bannerClass() {
|
||||
// If icon is undefined, fall back to default icon
|
||||
return this.icon() ?? defaultIcon[this.bannerType()];
|
||||
});
|
||||
|
||||
protected readonly bannerClass = computed(() => {
|
||||
switch (this.bannerType()) {
|
||||
case "danger":
|
||||
return "tw-bg-danger-100 tw-border-b-danger-700";
|
||||
@@ -64,5 +85,5 @@ export class BannerComponent implements OnInit {
|
||||
case "warning":
|
||||
return "tw-bg-warning-100 tw-border-b-warning-700";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,13 +75,19 @@
|
||||
@let isScrollable = isScrollable$ | async;
|
||||
@let showFooterBorder =
|
||||
(!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom;
|
||||
<div
|
||||
class="tw-border-0 tw-border-t tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
'tw-border-secondary-100': showFooterBorder,
|
||||
}"
|
||||
data-chromatic="ignore"
|
||||
></div>
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-bg-background tw-py-5 tw-ps-6 tw-pe-4"
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-bg-background tw-py-5 tw-ps-6 tw-pe-4"
|
||||
[ngClass]="{
|
||||
'tw-px-6 tw-py-4': isDrawer,
|
||||
'tw-p-4 has-[[biticonbutton]]:tw-pe-2': !isDrawer,
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
'tw-border-secondary-100': showFooterBorder,
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogFooter]"></ng-content>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[routerLinkActiveOptions]="routerLinkMatchOptions"
|
||||
#rla="routerLinkActive"
|
||||
[active]="rla.isActive"
|
||||
[info]="{ focusMainAfterNav: false }"
|
||||
[disabled]="disabled"
|
||||
[attr.aria-disabled]="disabled"
|
||||
ariaCurrentWhenActive="page"
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as bigInt from "big-integer";
|
||||
import {
|
||||
NEVER,
|
||||
Observable,
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
@@ -948,6 +949,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
|
||||
return result;
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error(
|
||||
`Failed to get encrypted organization keys for user ${userId}`,
|
||||
err,
|
||||
);
|
||||
return of({});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,19 +35,16 @@ export interface SendItemDialogParams {
|
||||
disableForm?: boolean;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum SendItemDialogResult {
|
||||
/**
|
||||
* A Send was saved (created or updated).
|
||||
*/
|
||||
Saved = "saved",
|
||||
/** A result of the Send add/edit dialog. */
|
||||
export const SendItemDialogResult = Object.freeze({
|
||||
/** The send item was created or updated. */
|
||||
Saved: "saved",
|
||||
/** The send item was deleted. */
|
||||
Deleted: "deleted",
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* A Send was deleted.
|
||||
*/
|
||||
Deleted = "deleted",
|
||||
}
|
||||
/** A result of the Send add/edit dialog. */
|
||||
export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
|
||||
|
||||
/**
|
||||
* Component for adding or editing a send item.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component";
|
||||
|
||||
describe("SendDetails DatePreset utilities", () => {
|
||||
it("accepts all defined numeric presets", () => {
|
||||
const presets: Array<any> = [
|
||||
DatePreset.OneHour,
|
||||
DatePreset.OneDay,
|
||||
DatePreset.TwoDays,
|
||||
DatePreset.ThreeDays,
|
||||
DatePreset.SevenDays,
|
||||
DatePreset.FourteenDays,
|
||||
DatePreset.ThirtyDays,
|
||||
];
|
||||
presets.forEach((p) => {
|
||||
expect(isDatePreset(p)).toBe(true);
|
||||
expect(asDatePreset(p)).toBe(p);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid numbers and non-numeric values", () => {
|
||||
const invalid: Array<any> = [5, -1, 0.5, 0, 9999, "never", "foo", null, undefined, {}, []];
|
||||
invalid.forEach((v) => {
|
||||
expect(isDatePreset(v)).toBe(false);
|
||||
expect(asDatePreset(v)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,24 +29,50 @@ import { SendOptionsComponent } from "../options/send-options.component";
|
||||
import { SendFileDetailsComponent } from "./send-file-details.component";
|
||||
import { SendTextDetailsComponent } from "./send-text-details.component";
|
||||
|
||||
// Value = hours
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum DatePreset {
|
||||
OneHour = 1,
|
||||
OneDay = 24,
|
||||
TwoDays = 48,
|
||||
ThreeDays = 72,
|
||||
SevenDays = 168,
|
||||
FourteenDays = 336,
|
||||
ThirtyDays = 720,
|
||||
}
|
||||
/** A preset duration (in hours) for deletion. */
|
||||
export const DatePreset = Object.freeze({
|
||||
/** One-hour duration. */
|
||||
OneHour: 1,
|
||||
/** One-day duration (24 hours). */
|
||||
OneDay: 24,
|
||||
/** Two-day duration (48 hours). */
|
||||
TwoDays: 48,
|
||||
/** Three-day duration (72 hours). */
|
||||
ThreeDays: 72,
|
||||
/** Seven-day duration (168 hours). */
|
||||
SevenDays: 168,
|
||||
/** Fourteen-day duration (336 hours). */
|
||||
FourteenDays: 336,
|
||||
/** Thirty-day duration (720 hours). */
|
||||
ThirtyDays: 720,
|
||||
} as const);
|
||||
|
||||
/** A preset duration (in hours) for deletion. */
|
||||
export type DatePreset = (typeof DatePreset)[keyof typeof DatePreset];
|
||||
|
||||
export interface DatePresetSelectOption {
|
||||
name: string;
|
||||
value: DatePreset | string;
|
||||
}
|
||||
|
||||
const namesByDatePreset = new Map<DatePreset, keyof typeof DatePreset>(
|
||||
Object.entries(DatePreset).map(([k, v]) => [v as DatePreset, k as keyof typeof DatePreset]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Runtime type guard to verify a value is a valid DatePreset.
|
||||
*/
|
||||
export function isDatePreset(value: unknown): value is DatePreset {
|
||||
return namesByDatePreset.has(value as DatePreset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe converter to DatePreset (numeric preset), returns undefined for invalid inputs.
|
||||
*/
|
||||
export function asDatePreset(value: unknown): DatePreset | undefined {
|
||||
return isDatePreset(value) ? (value as DatePreset) : undefined;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -153,11 +179,18 @@ export class SendDetailsComponent implements OnInit {
|
||||
const now = new Date();
|
||||
const selectedValue = this.sendDetailsForm.controls.selectedDeletionDatePreset.value;
|
||||
|
||||
// The form allows for custom date strings, if such is used, return it without worrying about DatePreset validation
|
||||
if (typeof selectedValue === "string") {
|
||||
return selectedValue;
|
||||
}
|
||||
|
||||
const milliseconds = now.setTime(now.getTime() + (selectedValue as number) * 60 * 60 * 1000);
|
||||
// Otherwise, treat it as a preset and validate at runtime
|
||||
const preset = asDatePreset(selectedValue);
|
||||
if (!isDatePreset(preset)) {
|
||||
return new Date(now).toString();
|
||||
}
|
||||
|
||||
const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000);
|
||||
return new Date(milliseconds).toString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user