mirror of
https://github.com/bitwarden/browser
synced 2026-02-19 10:54:00 +00:00
Merge branch 'main' into ps/extension-refresh
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, Subject } from "rxjs";
|
||||
import { concatMap, take, takeUntil } from "rxjs/operators";
|
||||
import { concatMap, map, take, takeUntil } from "rxjs/operators";
|
||||
|
||||
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -11,10 +11,12 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
||||
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
|
||||
@@ -30,6 +32,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
|
||||
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@@ -46,6 +49,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
supportsBiometric: boolean;
|
||||
biometricLock: boolean;
|
||||
|
||||
private activeUserId: UserId;
|
||||
protected successRoute = "vault";
|
||||
protected forcePasswordResetRoute = "update-temp-password";
|
||||
protected onSuccessfulSubmit: () => Promise<void>;
|
||||
@@ -80,14 +84,16 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
protected pinCryptoService: PinCryptoServiceAbstraction,
|
||||
protected biometricStateService: BiometricStateService,
|
||||
protected accountService: AccountService,
|
||||
protected authService: AuthService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.activeAccount$
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
concatMap(async () => {
|
||||
await this.load();
|
||||
concatMap(async (account) => {
|
||||
this.activeUserId = account?.id;
|
||||
await this.load(account?.id);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
@@ -116,7 +122,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout");
|
||||
this.messagingService.send("logout", { userId: this.activeUserId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,23 +327,35 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async load() {
|
||||
private async load(userId: UserId) {
|
||||
// TODO: Investigate PM-3515
|
||||
|
||||
// The loading of the lock component works as follows:
|
||||
// 1. First, is locking a valid timeout action? If not, we will log the user out.
|
||||
// 2. If locking IS a valid timeout action, we proceed to show the user the lock screen.
|
||||
// 1. If the user is unlocked, we're here in error so we navigate to the home page
|
||||
// 2. First, is locking a valid timeout action? If not, we will log the user out.
|
||||
// 3. If locking IS a valid timeout action, we proceed to show the user the lock screen.
|
||||
// The user will be able to unlock as follows:
|
||||
// - If they have a PIN set, they will be presented with the PIN input
|
||||
// - If they have a master password and no PIN, they will be presented with the master password input
|
||||
// - If they have biometrics enabled, they will be presented with the biometric prompt
|
||||
|
||||
const isUnlocked = await firstValueFrom(
|
||||
this.authService
|
||||
.authStatusFor$(userId)
|
||||
.pipe(map((status) => status === AuthenticationStatus.Unlocked)),
|
||||
);
|
||||
if (isUnlocked) {
|
||||
// navigate to home
|
||||
await this.router.navigate(["/"]);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableVaultTimeoutActions = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
||||
);
|
||||
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
|
||||
if (!supportsLock) {
|
||||
return await this.vaultTimeoutService.logOut();
|
||||
return await this.vaultTimeoutService.logOut(userId);
|
||||
}
|
||||
|
||||
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
interface User {
|
||||
export interface User {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InjectionToken } from "@angular/core";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
AbstractStorageService,
|
||||
@@ -52,3 +53,4 @@ export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<ThemeTy
|
||||
export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken<Subject<Message<object>>>(
|
||||
"INTRAPROCESS_MESSAGING_SUBJECT",
|
||||
);
|
||||
export const CLIENT_TYPE = new SafeInjectionToken<ClientType>("CLIENT_TYPE");
|
||||
|
||||
@@ -53,7 +53,6 @@ import { ProviderApiService } from "@bitwarden/common/admin-console/services/pro
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
|
||||
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import {
|
||||
AccountService,
|
||||
AccountService as AccountServiceAbstraction,
|
||||
InternalAccountService,
|
||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -162,7 +161,7 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r
|
||||
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
|
||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
import {
|
||||
@@ -276,6 +275,7 @@ import {
|
||||
SYSTEM_THEME_OBSERVABLE,
|
||||
WINDOW,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
CLIENT_TYPE,
|
||||
} from "./injection-tokens";
|
||||
import { ModalService } from "./modal.service";
|
||||
|
||||
@@ -1047,7 +1047,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: DerivedStateProvider,
|
||||
useClass: DefaultDerivedStateProvider,
|
||||
deps: [StorageServiceProvider],
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: StateProvider,
|
||||
@@ -1099,7 +1099,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: MigrationRunner,
|
||||
useClass: MigrationRunner,
|
||||
deps: [AbstractStorageService, LogService, MigrationBuilderService],
|
||||
deps: [AbstractStorageService, LogService, MigrationBuilderService, CLIENT_TYPE],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MigrationBuilderService,
|
||||
@@ -1127,9 +1127,9 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: UserKeyInitService,
|
||||
useClass: UserKeyInitService,
|
||||
deps: [AccountService, CryptoServiceAbstraction, LogService],
|
||||
provide: UserAutoUnlockKeyService,
|
||||
useClass: UserAutoUnlockKeyService,
|
||||
deps: [CryptoServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ErrorHandler,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Subject, firstValueFrom, takeUntil, map, BehaviorSubject, concatMap } f
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -118,6 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected dialogService: DialogService,
|
||||
protected formBuilder: FormBuilder,
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected accountService: AccountService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||
@@ -215,7 +217,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.emailVerified = await this.stateService.getEmailVerified();
|
||||
this.emailVerified = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.emailVerified ?? false)),
|
||||
);
|
||||
|
||||
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
|
||||
if (this.send == null) {
|
||||
|
||||
@@ -128,6 +128,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({ sub: mockUserId });
|
||||
|
||||
await authRequestLoginStrategy.logIn(credentials);
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("LoginStrategy", () => {
|
||||
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
|
||||
});
|
||||
|
||||
it("throws if active account isn't found after being initialized", async () => {
|
||||
it("throws if new account isn't active after being initialized", async () => {
|
||||
const idTokenResponse = identityTokenResponseFactory();
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
@@ -228,7 +228,8 @@ describe("LoginStrategy", () => {
|
||||
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
|
||||
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
|
||||
|
||||
accountService.activeAccountSubject.next(null);
|
||||
accountService.switchAccount = jest.fn(); // block internal switch to new account
|
||||
accountService.activeAccountSubject.next(null); // simulate no active account
|
||||
|
||||
await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -169,6 +169,12 @@ export abstract class LoginStrategy {
|
||||
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
|
||||
const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
|
||||
|
||||
await this.accountService.addAccount(userId, {
|
||||
name: accountInformation.name,
|
||||
email: accountInformation.email,
|
||||
emailVerified: accountInformation.email_verified,
|
||||
});
|
||||
|
||||
// set access token and refresh token before account initialization so authN status can be accurate
|
||||
// User id will be derived from the access token.
|
||||
await this.tokenService.setTokens(
|
||||
@@ -178,6 +184,8 @@ export abstract class LoginStrategy {
|
||||
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
|
||||
);
|
||||
|
||||
await this.accountService.switchAccount(userId);
|
||||
|
||||
await this.stateService.addAccount(
|
||||
new Account({
|
||||
profile: {
|
||||
|
||||
@@ -164,6 +164,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
|
||||
|
||||
await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
@@ -199,6 +200,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
|
||||
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
|
||||
policyService.evaluateMasterPassword.mockReturnValue(false);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
|
||||
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
@@ -213,6 +215,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
|
||||
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
|
||||
policyService.evaluateMasterPassword.mockReturnValue(false);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
|
||||
|
||||
const token2FAResponse = new IdentityTwoFactorResponse({
|
||||
TwoFactorProviders: ["0"],
|
||||
|
||||
@@ -244,7 +244,7 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
|
||||
// Only try to set user key with device key if admin approval request was not successful
|
||||
if (!hasUserKey) {
|
||||
await this.trySetUserKeyWithDeviceKey(tokenResponse);
|
||||
await this.trySetUserKeyWithDeviceKey(tokenResponse, userId);
|
||||
}
|
||||
} else if (
|
||||
masterKeyEncryptedUserKey != null &&
|
||||
@@ -312,11 +312,12 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise<void> {
|
||||
private async trySetUserKeyWithDeviceKey(
|
||||
tokenResponse: IdentityTokenResponse,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption;
|
||||
|
||||
const userId = (await this.stateService.getUserId()) as UserId;
|
||||
|
||||
const deviceKey = await this.deviceTrustService.getDeviceKey(userId);
|
||||
const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey;
|
||||
const encUserKey = trustedDeviceOption?.encryptedUserKey;
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("UserDecryptionOptionsService", () => {
|
||||
await fakeAccountService.addAccount(givenUser, {
|
||||
name: "Test User 1",
|
||||
email: "test1@email.com",
|
||||
emailVerified: false,
|
||||
});
|
||||
await fakeStateProvider.setUserState(
|
||||
USER_DECRYPTION_OPTIONS,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { ReplaySubject } from "rxjs";
|
||||
import { ReplaySubject, combineLatest, map } from "rxjs";
|
||||
|
||||
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
||||
import { UserId } from "../src/types/guid";
|
||||
@@ -7,15 +7,20 @@ import { UserId } from "../src/types/guid";
|
||||
export function mockAccountServiceWith(
|
||||
userId: UserId,
|
||||
info: Partial<AccountInfo> = {},
|
||||
activity: Record<UserId, Date> = {},
|
||||
): FakeAccountService {
|
||||
const fullInfo: AccountInfo = {
|
||||
...info,
|
||||
...{
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: true,
|
||||
},
|
||||
};
|
||||
const service = new FakeAccountService({ [userId]: fullInfo });
|
||||
|
||||
const fullActivity = { [userId]: new Date(), ...activity };
|
||||
|
||||
const service = new FakeAccountService({ [userId]: fullInfo }, fullActivity);
|
||||
service.activeAccountSubject.next({ id: userId, ...fullInfo });
|
||||
return service;
|
||||
}
|
||||
@@ -26,17 +31,46 @@ export class FakeAccountService implements AccountService {
|
||||
accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
|
||||
private _activeUserId: UserId;
|
||||
get activeUserId() {
|
||||
return this._activeUserId;
|
||||
}
|
||||
accounts$ = this.accountsSubject.asObservable();
|
||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
||||
accountActivity$ = this.accountActivitySubject.asObservable();
|
||||
get sortedUserIds$() {
|
||||
return this.accountActivity$.pipe(
|
||||
map((activity) => {
|
||||
return Object.entries(activity)
|
||||
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
|
||||
.sort((a, b) => a.lastActive.getTime() - b.lastActive.getTime())
|
||||
.map((a) => a.userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
get nextUpAccount$() {
|
||||
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
|
||||
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
|
||||
return nextId ? { id: nextId, ...accounts[nextId] } : null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(initialData: Record<UserId, AccountInfo>) {
|
||||
constructor(initialData: Record<UserId, AccountInfo>, accountActivity?: Record<UserId, Date>) {
|
||||
this.accountsSubject.next(initialData);
|
||||
this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id));
|
||||
this.activeAccountSubject.next(null);
|
||||
this.accountActivitySubject.next(accountActivity);
|
||||
}
|
||||
setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
|
||||
this.accountActivitySubject.next({
|
||||
...this.accountActivitySubject["_buffer"][0],
|
||||
[userId]: lastActivity,
|
||||
});
|
||||
return this.mock.setAccountActivity(userId, lastActivity);
|
||||
}
|
||||
|
||||
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
||||
@@ -53,10 +87,27 @@ export class FakeAccountService implements AccountService {
|
||||
await this.mock.setAccountEmail(userId, email);
|
||||
}
|
||||
|
||||
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
|
||||
await this.mock.setAccountEmailVerified(userId, emailVerified);
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
const next =
|
||||
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||
this.activeAccountSubject.next(next);
|
||||
await this.mock.switchAccount(userId);
|
||||
}
|
||||
|
||||
async clean(userId: UserId): Promise<void> {
|
||||
const current = this.accountsSubject["_buffer"][0] ?? {};
|
||||
const updated = { ...current, [userId]: loggedOutInfo };
|
||||
this.accountsSubject.next(updated);
|
||||
await this.mock.clean(userId);
|
||||
}
|
||||
}
|
||||
|
||||
const loggedOutInfo: AccountInfo = {
|
||||
name: undefined,
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
};
|
||||
|
||||
@@ -249,11 +249,11 @@ export class FakeDerivedStateProvider implements DerivedStateProvider {
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey("memory")) as DerivedState<TTo>;
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.states.set(deriveDefinition.buildCacheKey("memory"), result);
|
||||
this.states.set(deriveDefinition.buildCacheKey(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./fake-state-provider";
|
||||
export * from "./fake-state";
|
||||
export * from "./fake-account-service";
|
||||
export * from "./fake-storage.service";
|
||||
export * from "./observable-tracker";
|
||||
|
||||
@@ -16,9 +16,11 @@ export class ObservableTracker<T> {
|
||||
/**
|
||||
* Awaits the next emission from the observable, or throws if the timeout is exceeded
|
||||
* @param msTimeout The maximum time to wait for another emission before throwing
|
||||
* @returns The next emission from the observable
|
||||
* @throws If the timeout is exceeded
|
||||
*/
|
||||
async expectEmission(msTimeout = 50) {
|
||||
await firstValueFrom(
|
||||
async expectEmission(msTimeout = 50): Promise<T> {
|
||||
return await firstValueFrom(
|
||||
this.observable.pipe(
|
||||
timeout({
|
||||
first: msTimeout,
|
||||
@@ -28,7 +30,7 @@ export class ObservableTracker<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Awaits until the the total number of emissions observed by this tracker equals or exceeds {@link count}
|
||||
/** Awaits until the total number of emissions observed by this tracker equals or exceeds {@link count}
|
||||
* @param count The number of emissions to wait for
|
||||
*/
|
||||
async pauseUntilReceived(count: number, msTimeout = 50): Promise<T[]> {
|
||||
|
||||
@@ -8,18 +8,44 @@ import { UserId } from "../../types/guid";
|
||||
*/
|
||||
export type AccountInfo = {
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string | undefined;
|
||||
};
|
||||
|
||||
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||
return a?.email === b?.email && a?.name === b?.name;
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
|
||||
for (const key of keys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export abstract class AccountService {
|
||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
|
||||
|
||||
/**
|
||||
* Observable of the last activity time for each account.
|
||||
*/
|
||||
accountActivity$: Observable<Record<UserId, Date>>;
|
||||
/** Account list in order of descending recency */
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
/** Next account that is not the current active account */
|
||||
nextUpAccount$: Observable<{ id: UserId } & AccountInfo>;
|
||||
/**
|
||||
* Updates the `accounts$` observable with the new account data.
|
||||
*
|
||||
* @note Also sets the last active date of the account to `now`.
|
||||
* @param userId
|
||||
* @param accountData
|
||||
*/
|
||||
@@ -36,11 +62,30 @@ export abstract class AccountService {
|
||||
* @param email
|
||||
*/
|
||||
abstract setAccountEmail(userId: UserId, email: string): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the new email verification status for the account.
|
||||
* @param userId
|
||||
* @param emailVerified
|
||||
*/
|
||||
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
|
||||
/**
|
||||
* Updates the `activeAccount$` observable with the new active account.
|
||||
* @param userId
|
||||
*/
|
||||
abstract switchAccount(userId: UserId): Promise<void>;
|
||||
/**
|
||||
* Cleans personal information for the given account from the `accounts$` observable. Does not remove the userId from the observable.
|
||||
*
|
||||
* @note Also sets the last active date of the account to `null`.
|
||||
* @param userId
|
||||
*/
|
||||
abstract clean(userId: UserId): Promise<void>;
|
||||
/**
|
||||
* Updates the given user's last activity time.
|
||||
* @param userId
|
||||
* @param lastActivity
|
||||
*/
|
||||
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class InternalAccountService extends AccountService {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* need to update test environment so structuredClone works appropriately
|
||||
* @jest-environment ../../libs/shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
@@ -6,15 +11,57 @@ import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { trackEmissions } from "../../../spec/utils";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AccountInfo } from "../abstractions/account.service";
|
||||
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
|
||||
|
||||
import {
|
||||
ACCOUNT_ACCOUNTS,
|
||||
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||
ACCOUNT_ACTIVITY,
|
||||
AccountServiceImplementation,
|
||||
} from "./account.service";
|
||||
|
||||
describe("accountInfoEqual", () => {
|
||||
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
|
||||
|
||||
it("compares nulls", () => {
|
||||
expect(accountInfoEqual(null, null)).toBe(true);
|
||||
expect(accountInfoEqual(null, accountInfo)).toBe(false);
|
||||
expect(accountInfoEqual(accountInfo, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares all keys, not just those defined in AccountInfo", () => {
|
||||
const different = { ...accountInfo, extra: "extra" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares name", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, name: "name2" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares email", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, email: "email2" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares emailVerified", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, emailVerified: false };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountService", () => {
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
@@ -22,8 +69,8 @@ describe("accountService", () => {
|
||||
let sut: AccountServiceImplementation;
|
||||
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
|
||||
let activeAccountIdState: FakeGlobalState<UserId>;
|
||||
const userId = "userId" as UserId;
|
||||
const userInfo = { email: "email", name: "name" };
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userInfo = { email: "email", name: "name", emailVerified: true };
|
||||
|
||||
beforeEach(() => {
|
||||
messagingService = mock();
|
||||
@@ -86,6 +133,25 @@ describe("accountService", () => {
|
||||
|
||||
expect(currentValue).toEqual({ [userId]: userInfo });
|
||||
});
|
||||
|
||||
it("sets the last active date of the account to now", async () => {
|
||||
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||
state.stateSubject.next({});
|
||||
await sut.addAccount(userId, userInfo);
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: expect.any(Date) });
|
||||
});
|
||||
|
||||
it.each([null, undefined, 123, "not a guid"])(
|
||||
"does not set last active if the userId is not a valid guid",
|
||||
async (userId) => {
|
||||
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||
state.stateSubject.next({});
|
||||
await expect(sut.addAccount(userId as UserId, userInfo)).rejects.toThrow(
|
||||
"userId is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setAccountName", () => {
|
||||
@@ -134,6 +200,58 @@ describe("accountService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountEmailVerified", () => {
|
||||
const initialState = { [userId]: userInfo };
|
||||
initialState[userId].emailVerified = false;
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next(initialState);
|
||||
});
|
||||
|
||||
it("should update the account", async () => {
|
||||
await sut.setAccountEmailVerified(userId, true);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, emailVerified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update if the email is the same", async () => {
|
||||
await sut.setAccountEmailVerified(userId, false);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clean", () => {
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next({ [userId]: userInfo });
|
||||
});
|
||||
|
||||
it("removes account info of the given user", async () => {
|
||||
await sut.clean(userId);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes account activity of the given user", async () => {
|
||||
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||
state.stateSubject.next({ [userId]: new Date() });
|
||||
|
||||
await sut.clean(userId);
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchAccount", () => {
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next({ [userId]: userInfo });
|
||||
@@ -152,4 +270,83 @@ describe("accountService", () => {
|
||||
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("account activity", () => {
|
||||
let state: FakeGlobalState<Record<UserId, Date>>;
|
||||
|
||||
beforeEach(() => {
|
||||
state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||
});
|
||||
describe("accountActivity$", () => {
|
||||
it("returns the account activity state", async () => {
|
||||
state.stateSubject.next({
|
||||
[toId("user1")]: new Date(1),
|
||||
[toId("user2")]: new Date(2),
|
||||
});
|
||||
|
||||
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
|
||||
[toId("user1")]: new Date(1),
|
||||
[toId("user2")]: new Date(2),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty object when account activity is null", async () => {
|
||||
state.stateSubject.next(null);
|
||||
|
||||
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortedUserIds$", () => {
|
||||
it("returns the sorted user ids by date with most recent first", async () => {
|
||||
state.stateSubject.next({
|
||||
[toId("user1")]: new Date(3),
|
||||
[toId("user2")]: new Date(2),
|
||||
[toId("user3")]: new Date(1),
|
||||
});
|
||||
|
||||
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([
|
||||
"user1" as UserId,
|
||||
"user2" as UserId,
|
||||
"user3" as UserId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when account activity is null", async () => {
|
||||
state.stateSubject.next(null);
|
||||
|
||||
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountActivity", () => {
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
it("sets the account activity", async () => {
|
||||
await sut.setAccountActivity(userId, new Date(1));
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: new Date(1) });
|
||||
});
|
||||
|
||||
it("does not update if the activity is the same", async () => {
|
||||
state.stateSubject.next({ [userId]: new Date(1) });
|
||||
|
||||
await sut.setAccountActivity(userId, new Date(1));
|
||||
|
||||
expect(state.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, undefined, 123, "not a guid"])(
|
||||
"does not set last active if the userId is not a valid guid",
|
||||
async (userId) => {
|
||||
await sut.setAccountActivity(userId as UserId, new Date(1));
|
||||
|
||||
expect(state.nextMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function toId(userId: string) {
|
||||
return userId as UserId;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
|
||||
import { combineLatestWith, map, distinctUntilChanged, shareReplay, combineLatest } from "rxjs";
|
||||
|
||||
import {
|
||||
AccountInfo,
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
} from "../../auth/abstractions/account.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import {
|
||||
ACCOUNT_MEMORY,
|
||||
ACCOUNT_DISK,
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
@@ -16,25 +17,36 @@ import {
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
|
||||
ACCOUNT_MEMORY,
|
||||
ACCOUNT_DISK,
|
||||
"accounts",
|
||||
{
|
||||
deserializer: (accountInfo) => accountInfo,
|
||||
},
|
||||
);
|
||||
|
||||
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", {
|
||||
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_DISK, "activeAccountId", {
|
||||
deserializer: (id: UserId) => id,
|
||||
});
|
||||
|
||||
export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK, "activity", {
|
||||
deserializer: (activity) => new Date(activity),
|
||||
});
|
||||
|
||||
const LOGGED_OUT_INFO: AccountInfo = {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
export class AccountServiceImplementation implements InternalAccountService {
|
||||
private lock = new Subject<UserId>();
|
||||
private logout = new Subject<UserId>();
|
||||
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
||||
private activeAccountIdState: GlobalState<UserId | undefined>;
|
||||
|
||||
accounts$;
|
||||
activeAccount$;
|
||||
accountActivity$;
|
||||
sortedUserIds$;
|
||||
nextUpAccount$;
|
||||
|
||||
constructor(
|
||||
private messagingService: MessagingService,
|
||||
@@ -53,14 +65,40 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
this.accountActivity$ = this.globalStateProvider
|
||||
.get(ACCOUNT_ACTIVITY)
|
||||
.state$.pipe(map((activity) => activity ?? {}));
|
||||
this.sortedUserIds$ = this.accountActivity$.pipe(
|
||||
map((activity) => {
|
||||
return Object.entries(activity)
|
||||
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
|
||||
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()) // later dates first
|
||||
.map((a) => a.userId);
|
||||
}),
|
||||
);
|
||||
this.nextUpAccount$ = combineLatest([
|
||||
this.accounts$,
|
||||
this.activeAccount$,
|
||||
this.sortedUserIds$,
|
||||
]).pipe(
|
||||
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
|
||||
return nextId ? { id: nextId, ...accounts[nextId] } : null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
throw new Error("userId is required");
|
||||
}
|
||||
|
||||
await this.accountsState.update((accounts) => {
|
||||
accounts ||= {};
|
||||
accounts[userId] = accountData;
|
||||
return accounts;
|
||||
});
|
||||
await this.setAccountActivity(userId, new Date());
|
||||
}
|
||||
|
||||
async setAccountName(userId: UserId, name: string): Promise<void> {
|
||||
@@ -71,6 +109,15 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
await this.setAccountInfo(userId, { email });
|
||||
}
|
||||
|
||||
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
|
||||
await this.setAccountInfo(userId, { emailVerified });
|
||||
}
|
||||
|
||||
async clean(userId: UserId) {
|
||||
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
|
||||
await this.removeAccountActivity(userId);
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
await this.activeAccountIdState.update(
|
||||
(_, accounts) => {
|
||||
@@ -94,6 +141,37 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
);
|
||||
}
|
||||
|
||||
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
// only store for valid userIds
|
||||
return;
|
||||
}
|
||||
|
||||
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
|
||||
(activity) => {
|
||||
activity ||= {};
|
||||
activity[userId] = lastActivity;
|
||||
return activity;
|
||||
},
|
||||
{
|
||||
shouldUpdate: (oldActivity) => oldActivity?.[userId]?.getTime() !== lastActivity?.getTime(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async removeAccountActivity(userId: UserId): Promise<void> {
|
||||
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
|
||||
(activity) => {
|
||||
if (activity == null) {
|
||||
return activity;
|
||||
}
|
||||
delete activity[userId];
|
||||
return activity;
|
||||
},
|
||||
{ shouldUpdate: (oldActivity) => oldActivity?.[userId] != null },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
|
||||
async delete(): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -56,6 +56,7 @@ describe("AuthService", () => {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: userId,
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
};
|
||||
|
||||
@@ -109,6 +110,7 @@ describe("AuthService", () => {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: Utils.newGuid() as UserId,
|
||||
email: "email2",
|
||||
emailVerified: false,
|
||||
name: "name2",
|
||||
};
|
||||
|
||||
@@ -126,7 +128,11 @@ describe("AuthService", () => {
|
||||
it("requests auth status for all known users", async () => {
|
||||
const userId2 = Utils.newGuid() as UserId;
|
||||
|
||||
await accountService.addAccount(userId2, { email: "email2", name: "name2" });
|
||||
await accountService.addAccount(userId2, {
|
||||
email: "email2",
|
||||
emailVerified: false,
|
||||
name: "name2",
|
||||
});
|
||||
|
||||
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
|
||||
sut.authStatusFor$ = mockFn;
|
||||
@@ -147,11 +153,14 @@ describe("AuthService", () => {
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
});
|
||||
|
||||
it("emits LoggedOut when userId is null", async () => {
|
||||
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
it.each([null, undefined, "not a userId"])(
|
||||
"emits LoggedOut when userId is invalid (%s)",
|
||||
async () => {
|
||||
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("emits LoggedOut when there is no access token", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Observable,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
@@ -12,6 +13,7 @@ import { ApiService } from "../../abstractions/api.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
||||
@@ -39,13 +41,16 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
|
||||
this.authStatuses$ = this.accountService.accounts$.pipe(
|
||||
map((accounts) => Object.keys(accounts) as UserId[]),
|
||||
switchMap((entries) =>
|
||||
combineLatest(
|
||||
switchMap((entries) => {
|
||||
if (entries.length === 0) {
|
||||
return of([] as { userId: UserId; status: AuthenticationStatus }[]);
|
||||
}
|
||||
return combineLatest(
|
||||
entries.map((userId) =>
|
||||
this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
map((statuses) => {
|
||||
return statuses.reduce(
|
||||
(acc, { userId, status }) => {
|
||||
@@ -59,7 +64,7 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
}
|
||||
|
||||
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
|
||||
if (userId == null) {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
return of(AuthenticationStatus.LoggedOut);
|
||||
}
|
||||
|
||||
@@ -84,17 +89,8 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
}
|
||||
|
||||
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
|
||||
// If we don't have an access token or userId, we're logged out
|
||||
const isAuthenticated = await this.stateService.getIsAuthenticated({ userId: userId });
|
||||
if (!isAuthenticated) {
|
||||
return AuthenticationStatus.LoggedOut;
|
||||
}
|
||||
|
||||
// Note: since we aggresively set the auto user key to memory if it exists on app init (see InitService)
|
||||
// we only need to check if the user key is in memory.
|
||||
const hasUserKey = await this.cryptoService.hasUserKeyInMemory(userId as UserId);
|
||||
|
||||
return hasUserKey ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked;
|
||||
userId ??= await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
|
||||
return await firstValueFrom(this.authStatusFor$(userId as UserId));
|
||||
}
|
||||
|
||||
logOut(callback: () => void) {
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
const user1AccountInfo: AccountInfo = {
|
||||
name: "Test User 1",
|
||||
email: "test1@email.com",
|
||||
emailVerified: true,
|
||||
};
|
||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
|
||||
if (!accessTokenKey) {
|
||||
// If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet
|
||||
// and we have to return null here to properly indicate the the user isn't logged in.
|
||||
// and we have to return null here to properly indicate the user isn't logged in.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,4 +58,16 @@ export class SelfHostedOrganizationSubscriptionView implements View {
|
||||
get isExpiredAndOutsideGracePeriod() {
|
||||
return this.hasExpiration && this.expirationWithGracePeriod < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* In the case of a trial, where there is no grace period, the expirationWithGracePeriod and expirationWithoutGracePeriod will
|
||||
* be exactly the same. This can be used to hide the grace period note.
|
||||
*/
|
||||
get isInTrial() {
|
||||
return (
|
||||
this.expirationWithGracePeriod &&
|
||||
this.expirationWithoutGracePeriod &&
|
||||
this.expirationWithGracePeriod.getTime() === this.expirationWithoutGracePeriod.getTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export abstract class CryptoService {
|
||||
*/
|
||||
abstract makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]>;
|
||||
/**
|
||||
* Sets the the user's encrypted private key in storage and
|
||||
* Sets the user's encrypted private key in storage and
|
||||
* clears the decrypted private key from memory
|
||||
* Note: does not clear the private key if null is provided
|
||||
* @param encPrivateKey An encrypted private key
|
||||
|
||||
@@ -25,11 +25,10 @@ export type InitOptions = {
|
||||
|
||||
export abstract class StateService<T extends Account = Account> {
|
||||
accounts$: Observable<{ [userId: string]: T }>;
|
||||
activeAccount$: Observable<string>;
|
||||
|
||||
addAccount: (account: T) => Promise<void>;
|
||||
setActiveUser: (userId: string) => Promise<void>;
|
||||
clean: (options?: StorageOptions) => Promise<UserId>;
|
||||
clearDecryptedData: (userId: UserId) => Promise<void>;
|
||||
clean: (options?: StorageOptions) => Promise<void>;
|
||||
init: (initOptions?: InitOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -122,8 +121,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEmail: (options?: StorageOptions) => Promise<string>;
|
||||
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||
@@ -147,8 +144,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
*/
|
||||
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
|
||||
getLastActive: (options?: StorageOptions) => Promise<number>;
|
||||
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getLastSync: (options?: StorageOptions) => Promise<string>;
|
||||
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
||||
@@ -180,5 +175,4 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
nextUpActiveUser: () => Promise<UserId>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,33 @@ import * as path from "path";
|
||||
import { Utils } from "./utils";
|
||||
|
||||
describe("Utils Service", () => {
|
||||
describe("isGuid", () => {
|
||||
it("is false when null", () => {
|
||||
expect(Utils.isGuid(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when undefined", () => {
|
||||
expect(Utils.isGuid(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when empty", () => {
|
||||
expect(Utils.isGuid("")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when not a string", () => {
|
||||
expect(Utils.isGuid(123 as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when not a guid", () => {
|
||||
expect(Utils.isGuid("not a guid")).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a guid", () => {
|
||||
// we use a limited guid scope in which all zeroes is invalid
|
||||
expect(Utils.isGuid("00000000-0000-1000-8000-000000000000")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDomain", () => {
|
||||
it("should fail for invalid urls", () => {
|
||||
expect(Utils.getDomain(null)).toBeNull();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
|
||||
const nodeURL = typeof window === "undefined" ? require("url") : null;
|
||||
const nodeURL = typeof self === "undefined" ? require("url") : null;
|
||||
|
||||
declare global {
|
||||
/* eslint-disable-next-line no-var */
|
||||
|
||||
@@ -9,9 +9,6 @@ export class State<
|
||||
> {
|
||||
accounts: { [userId: string]: TAccount } = {};
|
||||
globals: TGlobalState;
|
||||
activeUserId: string;
|
||||
authenticatedAccounts: string[] = [];
|
||||
accountActivity: { [userId: string]: number } = {};
|
||||
|
||||
constructor(globals: TGlobalState) {
|
||||
this.globals = globals;
|
||||
|
||||
@@ -100,7 +100,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
USER_PRIVATE_KEY,
|
||||
{
|
||||
encryptService: this.encryptService,
|
||||
cryptoService: this,
|
||||
getUserKey: (userId) => this.getUserKey(userId),
|
||||
},
|
||||
);
|
||||
this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null
|
||||
@@ -738,13 +738,23 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
// Can decrypt private key
|
||||
const privateKey = await USER_PRIVATE_KEY.derive([userId, encPrivateKey], {
|
||||
encryptService: this.encryptService,
|
||||
cryptoService: this,
|
||||
getUserKey: () => Promise.resolve(key),
|
||||
});
|
||||
|
||||
if (privateKey == null) {
|
||||
// failed to decrypt
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can successfully derive public key
|
||||
await USER_PUBLIC_KEY.derive(privateKey, {
|
||||
const publicKey = await USER_PUBLIC_KEY.derive(privateKey, {
|
||||
cryptoFunctionService: this.cryptoFunctionService,
|
||||
});
|
||||
|
||||
if (publicKey == null) {
|
||||
// failed to decrypt
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -31,10 +31,12 @@ describe("EnvironmentService", () => {
|
||||
[testUser]: {
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
},
|
||||
[alternateTestUser]: {
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
},
|
||||
});
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
@@ -47,6 +49,7 @@ describe("EnvironmentService", () => {
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: `Test Name ${userId}`,
|
||||
emailVerified: false,
|
||||
});
|
||||
await awaitAsync();
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { EncString } from "../../models/domain/enc-string";
|
||||
import { CryptoService } from "../crypto.service";
|
||||
|
||||
import {
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
@@ -89,40 +88,37 @@ describe("Derived decrypted private key", () => {
|
||||
});
|
||||
|
||||
it("should derive decrypted private key", async () => {
|
||||
const cryptoService = mock<CryptoService>();
|
||||
cryptoService.getUserKey.mockResolvedValue(userKey);
|
||||
const getUserKey = jest.fn(async () => userKey);
|
||||
const encryptService = mock<EncryptService>();
|
||||
encryptService.decryptToBytes.mockResolvedValue(decryptedPrivateKey);
|
||||
|
||||
const result = await sut.derive([userId, encryptedPrivateKey], {
|
||||
encryptService,
|
||||
cryptoService,
|
||||
getUserKey,
|
||||
});
|
||||
|
||||
expect(result).toEqual(decryptedPrivateKey);
|
||||
});
|
||||
|
||||
it("should handle null input values", async () => {
|
||||
const cryptoService = mock<CryptoService>();
|
||||
cryptoService.getUserKey.mockResolvedValue(userKey);
|
||||
const getUserKey = jest.fn(async () => userKey);
|
||||
const encryptService = mock<EncryptService>();
|
||||
|
||||
const result = await sut.derive([userId, null], {
|
||||
encryptService,
|
||||
cryptoService,
|
||||
getUserKey,
|
||||
});
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it("should handle null user key", async () => {
|
||||
const cryptoService = mock<CryptoService>();
|
||||
cryptoService.getUserKey.mockResolvedValue(null);
|
||||
const getUserKey = jest.fn(async () => null);
|
||||
const encryptService = mock<EncryptService>();
|
||||
|
||||
const result = await sut.derive([userId, encryptedPrivateKey], {
|
||||
encryptService,
|
||||
cryptoService,
|
||||
getUserKey,
|
||||
});
|
||||
|
||||
expect(result).toEqual(null);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserPrivateKey, UserPublicKey, UserKey } from "../../../types/key";
|
||||
import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { EncString, EncryptedString } from "../../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY, UserKeyDefinition } from "../../state";
|
||||
import { CryptoService } from "../crypto.service";
|
||||
|
||||
export const USER_EVER_HAD_USER_KEY = new UserKeyDefinition<boolean>(
|
||||
CRYPTO_DISK,
|
||||
@@ -28,15 +28,15 @@ export const USER_PRIVATE_KEY = DeriveDefinition.fromWithUserId<
|
||||
EncryptedString,
|
||||
UserPrivateKey,
|
||||
// TODO: update cryptoService to user key directly
|
||||
{ encryptService: EncryptService; cryptoService: CryptoService }
|
||||
{ encryptService: EncryptService; getUserKey: (userId: UserId) => Promise<UserKey> }
|
||||
>(USER_ENCRYPTED_PRIVATE_KEY, {
|
||||
deserializer: (obj) => new Uint8Array(Object.values(obj)) as UserPrivateKey,
|
||||
derive: async ([userId, encPrivateKeyString], { encryptService, cryptoService }) => {
|
||||
derive: async ([userId, encPrivateKeyString], { encryptService, getUserKey }) => {
|
||||
if (encPrivateKeyString == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userKey = await cryptoService.getUserKey(userId);
|
||||
const userKey = await getUserKey(userId);
|
||||
if (userKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeStorageService } from "../../../spec/fake-storage.service";
|
||||
import { ClientType } from "../../enums";
|
||||
import { MigrationHelper } from "../../state-migrations/migration-helper";
|
||||
|
||||
import { MigrationBuilderService } from "./migration-builder.service";
|
||||
@@ -66,25 +67,38 @@ describe("MigrationBuilderService", () => {
|
||||
global: {},
|
||||
};
|
||||
|
||||
it.each([
|
||||
noAccounts,
|
||||
nullAndUndefinedAccounts,
|
||||
emptyAccountObject,
|
||||
nullCommonAccountProperties,
|
||||
emptyCommonAccountProperties,
|
||||
nullGlobal,
|
||||
undefinedGlobal,
|
||||
emptyGlobalObject,
|
||||
])("should not produce migrations that throw when given data: %s", async (startingState) => {
|
||||
const sut = new MigrationBuilderService();
|
||||
const startingStates = [
|
||||
{ data: noAccounts, description: "No Accounts" },
|
||||
{ data: nullAndUndefinedAccounts, description: "Null and Undefined Accounts" },
|
||||
{ data: emptyAccountObject, description: "Empty Account Object" },
|
||||
{ data: nullCommonAccountProperties, description: "Null Common Account Properties" },
|
||||
{ data: emptyCommonAccountProperties, description: "Empty Common Account Properties" },
|
||||
{ data: nullGlobal, description: "Null Global" },
|
||||
{ data: undefinedGlobal, description: "Undefined Global" },
|
||||
{ data: emptyGlobalObject, description: "Empty Global Object" },
|
||||
];
|
||||
|
||||
const helper = new MigrationHelper(
|
||||
startingStateVersion,
|
||||
new FakeStorageService(startingState),
|
||||
mock(),
|
||||
"general",
|
||||
);
|
||||
const clientTypes = Object.values(ClientType);
|
||||
|
||||
await sut.build().migrate(helper);
|
||||
});
|
||||
// Generate all possible test cases
|
||||
const testCases = startingStates.flatMap((startingState) =>
|
||||
clientTypes.map((clientType) => ({ startingState, clientType })),
|
||||
);
|
||||
|
||||
it.each(testCases)(
|
||||
"should not produce migrations that throw when given $startingState.description for client $clientType",
|
||||
async ({ startingState, clientType }) => {
|
||||
const sut = new MigrationBuilderService();
|
||||
|
||||
const helper = new MigrationHelper(
|
||||
startingStateVersion,
|
||||
new FakeStorageService(startingState),
|
||||
mock(),
|
||||
"general",
|
||||
clientType,
|
||||
);
|
||||
|
||||
await sut.build().migrate(helper);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { awaitAsync } from "../../../spec";
|
||||
import { ClientType } from "../../enums";
|
||||
import { CURRENT_VERSION } from "../../state-migrations";
|
||||
import { MigrationBuilder } from "../../state-migrations/migration-builder";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
@@ -17,7 +18,7 @@ describe("MigrationRunner", () => {
|
||||
|
||||
migrationBuilderService.build.mockReturnValue(mockMigrationBuilder);
|
||||
|
||||
const sut = new MigrationRunner(storage, logService, migrationBuilderService);
|
||||
const sut = new MigrationRunner(storage, logService, migrationBuilderService, ClientType.Web);
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should not run migrations if state is empty", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ClientType } from "../../enums";
|
||||
import { waitForMigrations } from "../../state-migrations";
|
||||
import { CURRENT_VERSION, currentVersion } from "../../state-migrations/migrate";
|
||||
import { MigrationHelper } from "../../state-migrations/migration-helper";
|
||||
@@ -11,6 +12,7 @@ export class MigrationRunner {
|
||||
protected diskStorage: AbstractStorageService,
|
||||
protected logService: LogService,
|
||||
protected migrationBuilderService: MigrationBuilderService,
|
||||
private clientType: ClientType,
|
||||
) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
@@ -19,6 +21,7 @@ export class MigrationRunner {
|
||||
this.diskStorage,
|
||||
this.logService,
|
||||
"general",
|
||||
this.clientType,
|
||||
);
|
||||
|
||||
if (migrationHelper.currentVersion < 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, map } from "rxjs";
|
||||
import { Jsonify, JsonValue } from "type-fest";
|
||||
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
@@ -33,10 +33,7 @@ const keys = {
|
||||
state: "state",
|
||||
stateVersion: "stateVersion",
|
||||
global: "global",
|
||||
authenticatedAccounts: "authenticatedAccounts",
|
||||
activeUserId: "activeUserId",
|
||||
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
|
||||
accountActivity: "accountActivity",
|
||||
};
|
||||
|
||||
const partialKeys = {
|
||||
@@ -58,9 +55,6 @@ export class StateService<
|
||||
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
|
||||
accounts$ = this.accountsSubject.asObservable();
|
||||
|
||||
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
|
||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
||||
|
||||
private hasBeenInited = false;
|
||||
protected isRecoveredSession = false;
|
||||
|
||||
@@ -112,36 +106,16 @@ export class StateService<
|
||||
}
|
||||
|
||||
// Get all likely authenticated accounts
|
||||
const authenticatedAccounts = (
|
||||
(await this.storageService.get<string[]>(keys.authenticatedAccounts)) ?? []
|
||||
).filter((account) => account != null);
|
||||
const authenticatedAccounts = await firstValueFrom(
|
||||
this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))),
|
||||
);
|
||||
|
||||
await this.updateState(async (state) => {
|
||||
for (const i in authenticatedAccounts) {
|
||||
state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
|
||||
}
|
||||
|
||||
// After all individual accounts have been added
|
||||
state.authenticatedAccounts = authenticatedAccounts;
|
||||
|
||||
const storedActiveUser = await this.storageService.get<string>(keys.activeUserId);
|
||||
if (storedActiveUser != null) {
|
||||
state.activeUserId = storedActiveUser;
|
||||
}
|
||||
await this.pushAccounts();
|
||||
this.activeAccountSubject.next(state.activeUserId);
|
||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
||||
// account service tracks logged out accounts, but State service does not, so we need to add the active account
|
||||
// if it's not in the accounts list.
|
||||
if (state.activeUserId != null && this.accountsSubject.value[state.activeUserId] == null) {
|
||||
const activeDiskAccount = await this.getAccountFromDisk({ userId: state.activeUserId });
|
||||
await this.accountService.addAccount(state.activeUserId as UserId, {
|
||||
name: activeDiskAccount.profile.name,
|
||||
email: activeDiskAccount.profile.email,
|
||||
});
|
||||
}
|
||||
await this.accountService.switchAccount(state.activeUserId as UserId);
|
||||
// End TODO
|
||||
|
||||
return state;
|
||||
});
|
||||
@@ -161,61 +135,25 @@ export class StateService<
|
||||
return state;
|
||||
});
|
||||
|
||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
||||
// The determination of state should be handled by the various services that control those values.
|
||||
await this.accountService.addAccount(userId as UserId, {
|
||||
name: diskAccount.profile.name,
|
||||
email: diskAccount.profile.email,
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async addAccount(account: TAccount) {
|
||||
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
|
||||
await this.updateState(async (state) => {
|
||||
state.authenticatedAccounts.push(account.profile.userId);
|
||||
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
|
||||
state.accounts[account.profile.userId] = account;
|
||||
return state;
|
||||
});
|
||||
await this.scaffoldNewAccountStorage(account);
|
||||
await this.setLastActive(new Date().getTime(), { userId: account.profile.userId });
|
||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
||||
await this.accountService.addAccount(account.profile.userId as UserId, {
|
||||
name: account.profile.name,
|
||||
email: account.profile.email,
|
||||
});
|
||||
await this.setActiveUser(account.profile.userId);
|
||||
}
|
||||
|
||||
async setActiveUser(userId: string): Promise<void> {
|
||||
await this.clearDecryptedDataForActiveUser();
|
||||
await this.updateState(async (state) => {
|
||||
state.activeUserId = userId;
|
||||
await this.storageService.save(keys.activeUserId, userId);
|
||||
this.activeAccountSubject.next(state.activeUserId);
|
||||
// TODO: temporary update to avoid routing all account status changes through account service for now.
|
||||
await this.accountService.switchAccount(userId as UserId);
|
||||
|
||||
return state;
|
||||
});
|
||||
|
||||
await this.pushAccounts();
|
||||
}
|
||||
|
||||
async clean(options?: StorageOptions): Promise<UserId> {
|
||||
async clean(options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
|
||||
await this.deAuthenticateAccount(options.userId);
|
||||
let currentUser = (await this.state())?.activeUserId;
|
||||
if (options.userId === currentUser) {
|
||||
currentUser = await this.dynamicallySetActiveUser();
|
||||
}
|
||||
|
||||
await this.removeAccountFromDisk(options?.userId);
|
||||
await this.removeAccountFromMemory(options?.userId);
|
||||
await this.pushAccounts();
|
||||
return currentUser as UserId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,24 +453,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEmailVerified(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.profile.emailVerified ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEmailVerified(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.profile.emailVerified = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
@@ -642,35 +562,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getLastActive(options?: StorageOptions): Promise<number> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
|
||||
|
||||
const accountActivity = await this.storageService.get<{ [userId: string]: number }>(
|
||||
keys.accountActivity,
|
||||
options,
|
||||
);
|
||||
|
||||
if (accountActivity == null || Object.keys(accountActivity).length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accountActivity[options.userId];
|
||||
}
|
||||
|
||||
async setLastActive(value: number, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
|
||||
if (options.userId == null) {
|
||||
return;
|
||||
}
|
||||
const accountActivity =
|
||||
(await this.storageService.get<{ [userId: string]: number }>(
|
||||
keys.accountActivity,
|
||||
options,
|
||||
)) ?? {};
|
||||
accountActivity[options.userId] = value;
|
||||
await this.storageService.save(keys.accountActivity, accountActivity, options);
|
||||
}
|
||||
|
||||
async getLastSync(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
|
||||
@@ -910,24 +801,28 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
|
||||
const userId =
|
||||
options.userId ??
|
||||
(await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
));
|
||||
|
||||
return await this.state().then(async (state) => {
|
||||
if (state.accounts == null) {
|
||||
return null;
|
||||
}
|
||||
return state.accounts[await this.getUserIdFromMemory(options)];
|
||||
});
|
||||
}
|
||||
|
||||
protected async getUserIdFromMemory(options: StorageOptions): Promise<string> {
|
||||
return await this.state().then((state) => {
|
||||
return options?.userId != null
|
||||
? state.accounts[options.userId]?.profile?.userId
|
||||
: state.activeUserId;
|
||||
return state.accounts[userId];
|
||||
});
|
||||
}
|
||||
|
||||
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
|
||||
if (options?.userId == null && (await this.state())?.activeUserId == null) {
|
||||
const userId =
|
||||
options.userId ??
|
||||
(await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
));
|
||||
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1086,53 +981,76 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async defaultInMemoryOptions(): Promise<StorageOptions> {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
storageLocation: StorageLocation.Memory,
|
||||
userId: (await this.state()).activeUserId,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
protected async defaultOnDiskOptions(): Promise<StorageOptions> {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
htmlStorageLocation: HtmlStorageLocation.Session,
|
||||
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
|
||||
userId,
|
||||
useSecureStorage: false,
|
||||
};
|
||||
}
|
||||
|
||||
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
|
||||
userId,
|
||||
useSecureStorage: false,
|
||||
};
|
||||
}
|
||||
|
||||
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
htmlStorageLocation: HtmlStorageLocation.Memory,
|
||||
userId: (await this.state())?.activeUserId ?? (await this.getUserId()),
|
||||
userId,
|
||||
useSecureStorage: false,
|
||||
};
|
||||
}
|
||||
|
||||
protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
useSecureStorage: true,
|
||||
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
protected async getActiveUserIdFromStorage(): Promise<string> {
|
||||
return await this.storageService.get<string>(keys.activeUserId);
|
||||
return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
|
||||
}
|
||||
|
||||
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
|
||||
userId = userId ?? (await this.state())?.activeUserId;
|
||||
userId ??= await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
const storedAccount = await this.getAccount(
|
||||
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
@@ -1143,7 +1061,10 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
|
||||
userId = userId ?? (await this.state())?.activeUserId;
|
||||
userId ??= await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
const storedAccount = await this.getAccount(
|
||||
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
@@ -1154,7 +1075,10 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
|
||||
userId = userId ?? (await this.state())?.activeUserId;
|
||||
userId ??= await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
await this.setUserKeyAutoUnlock(null, { userId: userId });
|
||||
await this.setUserKeyBiometric(null, { userId: userId });
|
||||
await this.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
@@ -1163,8 +1087,11 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
|
||||
userId ??= await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
await this.updateState(async (state) => {
|
||||
userId = userId ?? state.activeUserId;
|
||||
delete state.accounts[userId];
|
||||
return state;
|
||||
});
|
||||
@@ -1178,15 +1105,16 @@ export class StateService<
|
||||
return Object.assign(this.createAccount(), persistentAccountInformation);
|
||||
}
|
||||
|
||||
protected async clearDecryptedDataForActiveUser(): Promise<void> {
|
||||
async clearDecryptedData(userId: UserId): Promise<void> {
|
||||
await this.updateState(async (state) => {
|
||||
const userId = state?.activeUserId;
|
||||
if (userId != null && state?.accounts[userId]?.data != null) {
|
||||
state.accounts[userId].data = new AccountData();
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
|
||||
await this.pushAccounts();
|
||||
}
|
||||
|
||||
protected createAccount(init: Partial<TAccount> = null): TAccount {
|
||||
@@ -1201,14 +1129,6 @@ export class StateService<
|
||||
// We must have a manual call to clear tokens as we can't leverage state provider to clean
|
||||
// up our data as we have secure storage in the mix.
|
||||
await this.tokenService.clearTokens(userId as UserId);
|
||||
await this.setLastActive(null, { userId: userId });
|
||||
await this.updateState(async (state) => {
|
||||
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
|
||||
|
||||
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
protected async removeAccountFromDisk(userId: string) {
|
||||
@@ -1217,32 +1137,6 @@ export class StateService<
|
||||
await this.removeAccountFromSecureStorage(userId);
|
||||
}
|
||||
|
||||
async nextUpActiveUser() {
|
||||
const accounts = (await this.state())?.accounts;
|
||||
if (accounts == null || Object.keys(accounts).length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newActiveUser;
|
||||
for (const userId in accounts) {
|
||||
if (userId == null) {
|
||||
continue;
|
||||
}
|
||||
if (await this.getIsAuthenticated({ userId: userId })) {
|
||||
newActiveUser = userId;
|
||||
break;
|
||||
}
|
||||
newActiveUser = null;
|
||||
}
|
||||
return newActiveUser as UserId;
|
||||
}
|
||||
|
||||
protected async dynamicallySetActiveUser() {
|
||||
const newActiveUser = await this.nextUpActiveUser();
|
||||
await this.setActiveUser(newActiveUser);
|
||||
return newActiveUser;
|
||||
}
|
||||
|
||||
protected async saveSecureStorageKey<T extends JsonValue>(
|
||||
key: string,
|
||||
value: T,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { firstValueFrom, timeout } from "rxjs";
|
||||
import { firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
@@ -25,15 +27,18 @@ export class SystemService implements SystemServiceAbstraction {
|
||||
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async startProcessReload(authService: AuthService): Promise<void> {
|
||||
const accounts = await firstValueFrom(this.stateService.accounts$);
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
if (accounts != null) {
|
||||
const keys = Object.keys(accounts);
|
||||
if (keys.length > 0) {
|
||||
for (const userId of keys) {
|
||||
if ((await authService.getAuthStatus(userId)) === AuthenticationStatus.Unlocked) {
|
||||
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
|
||||
status = await authService.getAuthStatus(userId);
|
||||
if (status === AuthenticationStatus.Unlocked) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -63,15 +68,24 @@ export class SystemService implements SystemServiceAbstraction {
|
||||
clearInterval(this.reloadInterval);
|
||||
this.reloadInterval = null;
|
||||
|
||||
const currentUser = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500)));
|
||||
const currentUser = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((a) => a?.id),
|
||||
timeout(500),
|
||||
),
|
||||
);
|
||||
// Replace current active user if they will be logged out on reload
|
||||
if (currentUser != null) {
|
||||
const timeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
|
||||
);
|
||||
if (timeoutAction === VaultTimeoutAction.LogOut) {
|
||||
const nextUser = await this.stateService.nextUpActiveUser();
|
||||
await this.stateService.setActiveUser(nextUser);
|
||||
const nextUser = await firstValueFrom(
|
||||
this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)),
|
||||
);
|
||||
// Can be removed once we migrate password generation history to state providers
|
||||
await this.stateService.clearDecryptedData(currentUser);
|
||||
await this.accountService.switchAccount(nextUser);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { KeySuffixOptions } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { CryptoService } from "./crypto.service";
|
||||
import { UserAutoUnlockKeyService } from "./user-auto-unlock-key.service";
|
||||
|
||||
describe("UserAutoUnlockKeyService", () => {
|
||||
let userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
|
||||
const cryptoService = mock<CryptoService>();
|
||||
|
||||
beforeEach(() => {
|
||||
userAutoUnlockKeyService = new UserAutoUnlockKeyService(cryptoService);
|
||||
});
|
||||
|
||||
describe("setUserKeyInMemoryIfAutoUserKeySet", () => {
|
||||
it("does nothing if the userId is null", async () => {
|
||||
// Act
|
||||
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(null);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.getUserKeyFromStorage).not.toHaveBeenCalled();
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if the autoUserKey is null", async () => {
|
||||
// Arrange
|
||||
const userId = mockUserId;
|
||||
|
||||
cryptoService.getUserKeyFromStorage.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
||||
KeySuffixOptions.Auto,
|
||||
userId,
|
||||
);
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the user key in memory if the autoUserKey is not null", async () => {
|
||||
// Arrange
|
||||
const userId = mockUserId;
|
||||
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockAutoUserKey: UserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
|
||||
cryptoService.getUserKeyFromStorage.mockResolvedValue(mockAutoUserKey);
|
||||
|
||||
// Act
|
||||
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
||||
KeySuffixOptions.Auto,
|
||||
userId,
|
||||
);
|
||||
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockAutoUserKey, userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { KeySuffixOptions } from "../enums";
|
||||
|
||||
// TODO: this is a half measure improvement which allows us to reduce some side effects today (cryptoService.getUserKey setting user key in memory if auto key exists)
|
||||
// but ideally, in the future, we would be able to put this logic into the cryptoService
|
||||
// after the vault timeout settings service is transitioned to state provider so that
|
||||
// the getUserKey logic can simply go to the correct location based on the vault timeout settings
|
||||
// similar to the TokenService (it would either go to secure storage for the auto user key or memory for the user key)
|
||||
|
||||
export class UserAutoUnlockKeyService {
|
||||
constructor(private cryptoService: CryptoService) {}
|
||||
|
||||
/**
|
||||
* The presence of the user key in memory dictates whether the user's vault is locked or unlocked.
|
||||
* However, for users that have the auto unlock user key set, we need to set the user key in memory
|
||||
* on application bootstrap and on active account changes so that the user's vault loads unlocked.
|
||||
* @param userId - The user id to check for an auto user key.
|
||||
*/
|
||||
async setUserKeyInMemoryIfAutoUserKeySet(userId: UserId): Promise<void> {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoUserKey = await this.cryptoService.getUserKeyFromStorage(
|
||||
KeySuffixOptions.Auto,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (autoUserKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cryptoService.setUserKey(autoUserKey, userId);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { KeySuffixOptions } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { CryptoService } from "./crypto.service";
|
||||
import { UserKeyInitService } from "./user-key-init.service";
|
||||
|
||||
describe("UserKeyInitService", () => {
|
||||
let userKeyInitService: UserKeyInitService;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
beforeEach(() => {
|
||||
userKeyInitService = new UserKeyInitService(accountService, cryptoService, logService);
|
||||
});
|
||||
|
||||
describe("listenForActiveUserChangesToSetUserKey()", () => {
|
||||
it("calls setUserKeyInMemoryIfAutoUserKeySet if there is an active user", () => {
|
||||
// Arrange
|
||||
accountService.activeAccountSubject.next({
|
||||
id: mockUserId,
|
||||
name: "name",
|
||||
email: "email",
|
||||
});
|
||||
|
||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet = jest.fn();
|
||||
|
||||
// Act
|
||||
|
||||
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
||||
|
||||
// Assert
|
||||
|
||||
expect(subscription).not.toBeFalsy();
|
||||
|
||||
expect((userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls setUserKeyInMemoryIfAutoUserKeySet if there is an active user and tracks subsequent emissions", () => {
|
||||
// Arrange
|
||||
accountService.activeAccountSubject.next({
|
||||
id: mockUserId,
|
||||
name: "name",
|
||||
email: "email",
|
||||
});
|
||||
|
||||
const mockUser2Id = Utils.newGuid() as UserId;
|
||||
|
||||
jest
|
||||
.spyOn(userKeyInitService as any, "setUserKeyInMemoryIfAutoUserKeySet")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
// Act
|
||||
|
||||
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
||||
|
||||
accountService.activeAccountSubject.next({
|
||||
id: mockUser2Id,
|
||||
name: "name",
|
||||
email: "email",
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
expect(subscription).not.toBeFalsy();
|
||||
|
||||
expect((userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
|
||||
expect(
|
||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
|
||||
).toHaveBeenNthCalledWith(1, mockUserId);
|
||||
expect(
|
||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
|
||||
).toHaveBeenNthCalledWith(2, mockUser2Id);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("does not call setUserKeyInMemoryIfAutoUserKeySet if there is not an active user", () => {
|
||||
// Arrange
|
||||
accountService.activeAccountSubject.next(null);
|
||||
|
||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet = jest.fn();
|
||||
|
||||
// Act
|
||||
|
||||
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
||||
|
||||
// Assert
|
||||
|
||||
expect(subscription).not.toBeFalsy();
|
||||
|
||||
expect(
|
||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
|
||||
).not.toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserKeyInMemoryIfAutoUserKeySet", () => {
|
||||
it("does nothing if the userId is null", async () => {
|
||||
// Act
|
||||
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(null);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.getUserKeyFromStorage).not.toHaveBeenCalled();
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if the autoUserKey is null", async () => {
|
||||
// Arrange
|
||||
const userId = mockUserId;
|
||||
|
||||
cryptoService.getUserKeyFromStorage.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
||||
KeySuffixOptions.Auto,
|
||||
userId,
|
||||
);
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the user key in memory if the autoUserKey is not null", async () => {
|
||||
// Arrange
|
||||
const userId = mockUserId;
|
||||
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockAutoUserKey: UserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
|
||||
cryptoService.getUserKeyFromStorage.mockResolvedValue(mockAutoUserKey);
|
||||
|
||||
// Act
|
||||
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
||||
|
||||
// Assert
|
||||
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
||||
KeySuffixOptions.Auto,
|
||||
userId,
|
||||
);
|
||||
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockAutoUserKey, userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { EMPTY, Subscription, catchError, filter, from, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { KeySuffixOptions } from "../enums";
|
||||
|
||||
// TODO: this is a half measure improvement which allows us to reduce some side effects today (cryptoService.getUserKey setting user key in memory if auto key exists)
|
||||
// but ideally, in the future, we would be able to put this logic into the cryptoService
|
||||
// after the vault timeout settings service is transitioned to state provider so that
|
||||
// the getUserKey logic can simply go to the correct location based on the vault timeout settings
|
||||
// similar to the TokenService (it would either go to secure storage for the auto user key or memory for the user key)
|
||||
|
||||
export class UserKeyInitService {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private cryptoService: CryptoService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
// Note: must listen for changes to support account switching
|
||||
listenForActiveUserChangesToSetUserKey(): Subscription {
|
||||
return this.accountService.activeAccount$
|
||||
.pipe(
|
||||
filter((activeAccount) => activeAccount != null),
|
||||
switchMap((activeAccount) =>
|
||||
from(this.setUserKeyInMemoryIfAutoUserKeySet(activeAccount?.id)).pipe(
|
||||
catchError((err: unknown) => {
|
||||
this.logService.warning(
|
||||
`setUserKeyInMemoryIfAutoUserKeySet failed with error: ${err}`,
|
||||
);
|
||||
// Returning EMPTY to protect observable chain from cancellation in case of error
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private async setUserKeyInMemoryIfAutoUserKeySet(userId: UserId) {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoUserKey = await this.cryptoService.getUserKeyFromStorage(
|
||||
KeySuffixOptions.Auto,
|
||||
userId,
|
||||
);
|
||||
if (autoUserKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cryptoService.setUserKey(autoUserKey, userId);
|
||||
}
|
||||
}
|
||||
@@ -171,8 +171,8 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
|
||||
return this.options.clearOnCleanup ?? true;
|
||||
}
|
||||
|
||||
buildCacheKey(location: string): string {
|
||||
return `derived_${location}_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
|
||||
buildCacheKey(): string {
|
||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { SingleUserStateProvider } from "../user-state.provider";
|
||||
|
||||
@@ -14,7 +13,7 @@ describe("DefaultActiveUserStateProvider", () => {
|
||||
id: userId,
|
||||
name: "name",
|
||||
email: "email",
|
||||
status: AuthenticationStatus.Locked,
|
||||
emailVerified: false,
|
||||
};
|
||||
const accountService = mockAccountServiceWith(userId, accountInfo);
|
||||
let sut: DefaultActiveUserStateProvider;
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("DefaultActiveUserState", () => {
|
||||
activeAccountSubject.next({
|
||||
id: userId,
|
||||
email: `test${id}@example.com`,
|
||||
emailVerified: false,
|
||||
name: `Test User ${id}`,
|
||||
});
|
||||
await awaitAsync();
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
import { DerivedStateProvider } from "../derived-state.provider";
|
||||
@@ -15,18 +10,14 @@ import { DefaultDerivedState } from "./default-derived-state";
|
||||
export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
||||
private cache: Record<string, DerivedState<unknown>> = {};
|
||||
|
||||
constructor(protected storageServiceProvider: StorageServiceProvider) {}
|
||||
constructor() {}
|
||||
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
// TODO: we probably want to support optional normal memory storage for browser
|
||||
const [location, storageService] = this.storageServiceProvider.get("memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
const cacheKey = deriveDefinition.buildCacheKey(location);
|
||||
const cacheKey = deriveDefinition.buildCacheKey();
|
||||
const existingDerivedState = this.cache[cacheKey];
|
||||
if (existingDerivedState != null) {
|
||||
// I have to cast out of the unknown generic but this should be safe if rules
|
||||
@@ -34,10 +25,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
||||
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
|
||||
}
|
||||
|
||||
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies, [
|
||||
location,
|
||||
storageService,
|
||||
]);
|
||||
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.cache[cacheKey] = newDerivedState;
|
||||
return newDerivedState;
|
||||
}
|
||||
@@ -46,13 +34,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
storageLocation: [string, AbstractStorageService & ObservableStorageService],
|
||||
): DerivedState<TTo> {
|
||||
return new DefaultDerivedState<TFrom, TTo, TDeps>(
|
||||
parentState$,
|
||||
deriveDefinition,
|
||||
storageLocation[1],
|
||||
dependencies,
|
||||
);
|
||||
return new DefaultDerivedState<TFrom, TTo, TDeps>(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
@@ -29,7 +28,6 @@ const deriveDefinition = new DeriveDefinition<string, Date, { date: Date }>(
|
||||
|
||||
describe("DefaultDerivedState", () => {
|
||||
let parentState$: Subject<string>;
|
||||
let memoryStorage: FakeStorageService;
|
||||
let sut: DefaultDerivedState<string, Date, { date: Date }>;
|
||||
const deps = {
|
||||
date: new Date(),
|
||||
@@ -38,8 +36,7 @@ describe("DefaultDerivedState", () => {
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
parentState$ = new Subject();
|
||||
memoryStorage = new FakeStorageService();
|
||||
sut = new DefaultDerivedState(parentState$, deriveDefinition, memoryStorage, deps);
|
||||
sut = new DefaultDerivedState(parentState$, deriveDefinition, deps);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -66,71 +63,33 @@ describe("DefaultDerivedState", () => {
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should store the derived state in memory", async () => {
|
||||
const dateString = "2020-01-01";
|
||||
trackEmissions(sut.state$);
|
||||
parentState$.next(dateString);
|
||||
await awaitAsync();
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(dateString)),
|
||||
);
|
||||
const calls = memoryStorage.mock.save.mock.calls;
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0][0]).toBe(deriveDefinition.storageKey);
|
||||
expect(calls[0][1]).toEqual(derivedValue(new Date(dateString)));
|
||||
});
|
||||
|
||||
describe("forceValue", () => {
|
||||
const initialParentValue = "2020-01-01";
|
||||
const forced = new Date("2020-02-02");
|
||||
let emissions: Date[];
|
||||
|
||||
describe("without observers", () => {
|
||||
beforeEach(async () => {
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
});
|
||||
|
||||
it("should store the forced value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(forced),
|
||||
);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
emissions = trackEmissions(sut.state$);
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
});
|
||||
|
||||
describe("with observers", () => {
|
||||
beforeEach(async () => {
|
||||
emissions = trackEmissions(sut.state$);
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
});
|
||||
it("should force the value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(emissions).toEqual([new Date(initialParentValue), forced]);
|
||||
});
|
||||
|
||||
it("should store the forced value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(forced),
|
||||
);
|
||||
});
|
||||
it("should only force the value once", async () => {
|
||||
await sut.forceValue(forced);
|
||||
|
||||
it("should force the value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(emissions).toEqual([new Date(initialParentValue), forced]);
|
||||
});
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
|
||||
it("should only force the value once", async () => {
|
||||
await sut.forceValue(forced);
|
||||
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
|
||||
expect(emissions).toEqual([
|
||||
new Date(initialParentValue),
|
||||
forced,
|
||||
new Date(initialParentValue),
|
||||
]);
|
||||
});
|
||||
expect(emissions).toEqual([
|
||||
new Date(initialParentValue),
|
||||
forced,
|
||||
new Date(initialParentValue),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,42 +107,6 @@ describe("DefaultDerivedState", () => {
|
||||
expect(parentState$.observed).toBe(false);
|
||||
});
|
||||
|
||||
it("should clear state after cleanup", async () => {
|
||||
const subscription = sut.state$.subscribe();
|
||||
parentState$.next(newDate);
|
||||
await awaitAsync();
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(newDate)),
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
// Wait for cleanup
|
||||
await awaitAsync(cleanupDelayMs * 2);
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not clear state after cleanup if clearOnCleanup is false", async () => {
|
||||
deriveDefinition.options.clearOnCleanup = false;
|
||||
|
||||
const subscription = sut.state$.subscribe();
|
||||
parentState$.next(newDate);
|
||||
await awaitAsync();
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(newDate)),
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
// Wait for cleanup
|
||||
await awaitAsync(cleanupDelayMs * 2);
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(newDate)),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not cleanup if there are still subscribers", async () => {
|
||||
const subscription1 = sut.state$.subscribe();
|
||||
const sub2Emissions: Date[] = [];
|
||||
@@ -260,7 +183,3 @@ describe("DefaultDerivedState", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function derivedValue<T>(value: T) {
|
||||
return { derived: true, value };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
|
||||
@@ -22,7 +18,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
|
||||
constructor(
|
||||
private parentState$: Observable<TFrom>,
|
||||
protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
private memoryStorage: AbstractStorageService & ObservableStorageService,
|
||||
private dependencies: TDeps,
|
||||
) {
|
||||
this.storageKey = deriveDefinition.storageKey;
|
||||
@@ -34,7 +29,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
|
||||
derivedStateOrPromise = await derivedStateOrPromise;
|
||||
}
|
||||
const derivedState = derivedStateOrPromise;
|
||||
await this.storeValue(derivedState);
|
||||
return derivedState;
|
||||
}),
|
||||
);
|
||||
@@ -44,26 +38,13 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
|
||||
connector: () => {
|
||||
return new ReplaySubject<TTo>(1);
|
||||
},
|
||||
resetOnRefCountZero: () =>
|
||||
timer(this.deriveDefinition.cleanupDelayMs).pipe(
|
||||
concatMap(async () => {
|
||||
if (this.deriveDefinition.clearOnCleanup) {
|
||||
await this.memoryStorage.remove(this.storageKey);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async forceValue(value: TTo) {
|
||||
await this.storeValue(value);
|
||||
this.forcedValueSubject.next(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private storeValue(value: TTo) {
|
||||
return this.memoryStorage.save(this.storageKey, { derived: true, value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,12 @@ describe("DefaultStateProvider", () => {
|
||||
userId?: UserId,
|
||||
) => Observable<string>,
|
||||
) => {
|
||||
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut };
|
||||
const accountInfo = {
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
status: AuthenticationStatus.LoggedOut,
|
||||
};
|
||||
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
|
||||
deserializer: (s) => s,
|
||||
});
|
||||
@@ -114,7 +119,12 @@ describe("DefaultStateProvider", () => {
|
||||
);
|
||||
|
||||
describe("getUserState$", () => {
|
||||
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut };
|
||||
const accountInfo = {
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
status: AuthenticationStatus.LoggedOut,
|
||||
};
|
||||
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
|
||||
deserializer: (s) => s,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ export const BILLING_DISK = new StateDefinition("billing", "disk");
|
||||
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
|
||||
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const ACCOUNT_DISK = new StateDefinition("account", "disk");
|
||||
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
|
||||
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
|
||||
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { MockProxy, any, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { AccountInfo } from "../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
|
||||
@@ -13,7 +14,6 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { Account } from "../../platform/models/domain/account";
|
||||
import { StateEventRunnerService } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherService } from "../../vault/abstractions/cipher.service";
|
||||
@@ -39,7 +39,6 @@ describe("VaultTimeoutService", () => {
|
||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
|
||||
|
||||
let accountsSubject: BehaviorSubject<Record<string, Account>>;
|
||||
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
|
||||
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
|
||||
|
||||
@@ -65,10 +64,6 @@ describe("VaultTimeoutService", () => {
|
||||
lockedCallback = jest.fn();
|
||||
loggedOutCallback = jest.fn();
|
||||
|
||||
accountsSubject = new BehaviorSubject(null);
|
||||
|
||||
stateService.accounts$ = accountsSubject;
|
||||
|
||||
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
|
||||
|
||||
vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject);
|
||||
@@ -127,21 +122,39 @@ describe("VaultTimeoutService", () => {
|
||||
return Promise.resolve(accounts[userId]?.vaultTimeout);
|
||||
});
|
||||
|
||||
stateService.getLastActive.mockImplementation((options) => {
|
||||
return Promise.resolve(accounts[options.userId]?.lastActive);
|
||||
});
|
||||
|
||||
stateService.getUserId.mockResolvedValue(globalSetups?.userId);
|
||||
|
||||
stateService.activeAccount$ = new BehaviorSubject<string>(globalSetups?.userId);
|
||||
|
||||
// Set desired user active and known users on accounts service : note the only thing that matters here is that the ID are set
|
||||
if (globalSetups?.userId) {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: globalSetups.userId as UserId,
|
||||
email: null,
|
||||
emailVerified: false,
|
||||
name: null,
|
||||
});
|
||||
}
|
||||
accountService.accounts$ = of(
|
||||
Object.entries(accounts).reduce(
|
||||
(agg, [id]) => {
|
||||
agg[id] = {
|
||||
email: "",
|
||||
emailVerified: true,
|
||||
name: "",
|
||||
};
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, AccountInfo>,
|
||||
),
|
||||
);
|
||||
accountService.accountActivity$ = of(
|
||||
Object.entries(accounts).reduce(
|
||||
(agg, [id, info]) => {
|
||||
agg[id] = info.lastActive ? new Date(info.lastActive) : null;
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, Date>,
|
||||
),
|
||||
);
|
||||
|
||||
platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false);
|
||||
|
||||
@@ -158,16 +171,6 @@ describe("VaultTimeoutService", () => {
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
const accountsSubjectValue: Record<string, Account> = Object.keys(accounts).reduce(
|
||||
(agg, key) => {
|
||||
const newPartial: Record<string, unknown> = {};
|
||||
newPartial[key] = null; // No values actually matter on this other than the key
|
||||
return Object.assign(agg, newPartial);
|
||||
},
|
||||
{} as Record<string, Account>,
|
||||
);
|
||||
accountsSubject.next(accountsSubjectValue);
|
||||
};
|
||||
|
||||
const expectUserToHaveLocked = (userId: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom, timeout } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
@@ -64,14 +64,25 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
// Get whether or not the view is open a single time so it can be compared for each user
|
||||
const isViewOpen = await this.platformUtilsService.isViewOpen();
|
||||
|
||||
const activeUserId = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500)));
|
||||
|
||||
const accounts = await firstValueFrom(this.stateService.accounts$);
|
||||
for (const userId in accounts) {
|
||||
if (userId != null && (await this.shouldLock(userId, activeUserId, isViewOpen))) {
|
||||
await this.executeTimeoutAction(userId);
|
||||
}
|
||||
}
|
||||
await firstValueFrom(
|
||||
combineLatest([
|
||||
this.accountService.activeAccount$,
|
||||
this.accountService.accountActivity$,
|
||||
]).pipe(
|
||||
switchMap(async ([activeAccount, accountActivity]) => {
|
||||
const activeUserId = activeAccount?.id;
|
||||
for (const userIdString in accountActivity) {
|
||||
const userId = userIdString as UserId;
|
||||
if (
|
||||
userId != null &&
|
||||
(await this.shouldLock(userId, accountActivity[userId], activeUserId, isViewOpen))
|
||||
) {
|
||||
await this.executeTimeoutAction(userId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async lock(userId?: string): Promise<void> {
|
||||
@@ -123,6 +134,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
|
||||
private async shouldLock(
|
||||
userId: string,
|
||||
lastActive: Date,
|
||||
activeUserId: string,
|
||||
isViewOpen: boolean,
|
||||
): Promise<boolean> {
|
||||
@@ -146,13 +158,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastActive = await this.stateService.getLastActive({ userId: userId });
|
||||
if (lastActive == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vaultTimeoutSeconds = vaultTimeout * 60;
|
||||
const diffSeconds = (new Date().getTime() - lastActive) / 1000;
|
||||
const diffSeconds = (new Date().getTime() - lastActive.getTime()) / 1000;
|
||||
return diffSeconds >= vaultTimeoutSeconds;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,13 +57,14 @@ import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-st
|
||||
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
|
||||
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
|
||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
||||
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 3;
|
||||
export const CURRENT_VERSION = 59;
|
||||
export const CURRENT_VERSION = 60;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
@@ -124,7 +125,8 @@ export function createMigrationBuilder() {
|
||||
.with(AuthRequestMigrator, 55, 56)
|
||||
.with(CipherServiceMigrator, 56, 57)
|
||||
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
|
||||
.with(KdfConfigMigrator, 58, CURRENT_VERSION);
|
||||
.with(KdfConfigMigrator, 58, 59)
|
||||
.with(KnownAccountsMigrator, 59, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
import { ClientType } from "../enums";
|
||||
|
||||
import { MigrationBuilder } from "./migration-builder";
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Migrator } from "./migrator";
|
||||
@@ -72,65 +75,69 @@ describe("MigrationBuilder", () => {
|
||||
expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" });
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
let migrator: TestMigrator;
|
||||
let rollback_migrator: TestMigrator;
|
||||
const clientTypes = Object.values(ClientType);
|
||||
|
||||
beforeEach(() => {
|
||||
sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
|
||||
migrator = (sut as any).migrations[0].migrator;
|
||||
rollback_migrator = (sut as any).migrations[1].migrator;
|
||||
describe.each(clientTypes)("for client %s", (clientType) => {
|
||||
describe("migrate", () => {
|
||||
let migrator: TestMigrator;
|
||||
let rollback_migrator: TestMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
|
||||
migrator = (sut as any).migrations[0].migrator;
|
||||
rollback_migrator = (sut as any).migrations[1].migrator;
|
||||
});
|
||||
|
||||
it("should migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(migrator, "migrate");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should update version on migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "up");
|
||||
});
|
||||
|
||||
it("should update version on rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general", clientType);
|
||||
const spy = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "down");
|
||||
});
|
||||
|
||||
it("should not run the migrator if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general", clientType);
|
||||
const migrate = jest.spyOn(migrator, "migrate");
|
||||
const rollback = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not update version if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general", clientType);
|
||||
const migrate = jest.spyOn(migrator, "updateVersion");
|
||||
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(migrator, "migrate");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
it("should be able to call instance methods", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general", clientType);
|
||||
await sut.with(TestMigratorWithInstanceMethod, 0, 1).migrate(helper);
|
||||
});
|
||||
|
||||
it("should rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should update version on migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "up");
|
||||
});
|
||||
|
||||
it("should update version on rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock(), "general");
|
||||
const spy = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "down");
|
||||
});
|
||||
|
||||
it("should not run the migrator if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general");
|
||||
const migrate = jest.spyOn(migrator, "migrate");
|
||||
const rollback = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not update version if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock(), "general");
|
||||
const migrate = jest.spyOn(migrator, "updateVersion");
|
||||
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to call instance methods", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock(), "general");
|
||||
await sut.with(TestMigratorWithInstanceMethod, 0, 1).migrate(helper);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { FakeStorageService } from "../../spec/fake-storage.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum
|
||||
import { ClientType } from "../enums";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
@@ -25,6 +27,14 @@ const exampleJSON = {
|
||||
},
|
||||
global_serviceName_key: "global_serviceName_key",
|
||||
user_userId_serviceName_key: "user_userId_serviceName_key",
|
||||
global_account_accounts: {
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
@@ -32,116 +42,164 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let sut: MigrationHelper;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock();
|
||||
storage = mock();
|
||||
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
|
||||
const clientTypes = Object.values(ClientType);
|
||||
|
||||
sut = new MigrationHelper(0, storage, logService, "general");
|
||||
});
|
||||
describe.each(clientTypes)("for client %s", (clientType) => {
|
||||
beforeEach(() => {
|
||||
logService = mock();
|
||||
storage = mock();
|
||||
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
|
||||
|
||||
describe("get", () => {
|
||||
it("should delegate to storage.get", async () => {
|
||||
await sut.get("key");
|
||||
expect(storage.get).toHaveBeenCalledWith("key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("should delegate to storage.save", async () => {
|
||||
await sut.set("key", "value");
|
||||
expect(storage.save).toHaveBeenCalledWith("key", "value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAccounts", () => {
|
||||
it("should return all accounts", async () => {
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([
|
||||
{ userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } },
|
||||
{ userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } },
|
||||
]);
|
||||
sut = new MigrationHelper(0, storage, logService, "general", clientType);
|
||||
});
|
||||
|
||||
it("should handle missing authenticatedAccounts", async () => {
|
||||
storage.get.mockImplementation((key) =>
|
||||
key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key],
|
||||
);
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFromGlobal", () => {
|
||||
it("should return the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
const value = await sut.getFromGlobal({
|
||||
stateDefinition: { name: "serviceName" },
|
||||
key: "key",
|
||||
describe("get", () => {
|
||||
it("should delegate to storage.get", async () => {
|
||||
await sut.get("key");
|
||||
expect(storage.get).toHaveBeenCalledWith("key");
|
||||
});
|
||||
expect(value).toEqual("global_serviceName_key");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.getFromGlobal({ stateDefinition: { name: "serviceName" }, key: "key" }),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setToGlobal", () => {
|
||||
it("should set the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
await sut.setToGlobal({ stateDefinition: { name: "serviceName" }, key: "key" }, "new_value");
|
||||
expect(storage.save).toHaveBeenCalledWith("global_serviceName_key", "new_value");
|
||||
describe("set", () => {
|
||||
it("should delegate to storage.save", async () => {
|
||||
await sut.set("key", "value");
|
||||
expect(storage.save).toHaveBeenCalledWith("key", "value");
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.setToGlobal(
|
||||
describe("getAccounts", () => {
|
||||
it("should return all accounts", async () => {
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
account: { otherStuff: "otherStuff1" },
|
||||
},
|
||||
{
|
||||
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
account: { otherStuff: "otherStuff2" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle missing authenticatedAccounts", async () => {
|
||||
storage.get.mockImplementation((key) =>
|
||||
key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key],
|
||||
);
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles global scoped known accounts for version 60 and after", async () => {
|
||||
sut.currentVersion = 60;
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([
|
||||
// Note, still gets values stored in state service objects, just grabs user ids from global
|
||||
{
|
||||
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
account: { otherStuff: "otherStuff1" },
|
||||
},
|
||||
{
|
||||
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
account: { otherStuff: "otherStuff2" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnownUserIds", () => {
|
||||
it("returns all user ids", async () => {
|
||||
const userIds = await sut.getKnownUserIds();
|
||||
expect(userIds).toEqual([
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns all user ids when version is 60 or greater", async () => {
|
||||
sut.currentVersion = 60;
|
||||
const userIds = await sut.getKnownUserIds();
|
||||
expect(userIds).toEqual([
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFromGlobal", () => {
|
||||
it("should return the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
const value = await sut.getFromGlobal({
|
||||
stateDefinition: { name: "serviceName" },
|
||||
key: "key",
|
||||
});
|
||||
expect(value).toEqual("global_serviceName_key");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.getFromGlobal({ stateDefinition: { name: "serviceName" }, key: "key" }),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setToGlobal", () => {
|
||||
it("should set the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
await sut.setToGlobal(
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"global_serviceName_key",
|
||||
),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFromUser", () => {
|
||||
it("should return the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
const value = await sut.getFromUser("userId", {
|
||||
stateDefinition: { name: "serviceName" },
|
||||
key: "key",
|
||||
"new_value",
|
||||
);
|
||||
expect(storage.save).toHaveBeenCalledWith("global_serviceName_key", "new_value");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.setToGlobal(
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"global_serviceName_key",
|
||||
),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
expect(value).toEqual("user_userId_serviceName_key");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.getFromUser("userId", { stateDefinition: { name: "serviceName" }, key: "key" }),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
describe("getFromUser", () => {
|
||||
it("should return the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
const value = await sut.getFromUser("userId", {
|
||||
stateDefinition: { name: "serviceName" },
|
||||
key: "key",
|
||||
});
|
||||
expect(value).toEqual("user_userId_serviceName_key");
|
||||
});
|
||||
|
||||
describe("setToUser", () => {
|
||||
it("should set the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
await sut.setToUser(
|
||||
"userId",
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"new_value",
|
||||
);
|
||||
expect(storage.save).toHaveBeenCalledWith("user_userId_serviceName_key", "new_value");
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.getFromUser("userId", { stateDefinition: { name: "serviceName" }, key: "key" }),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.setToUser(
|
||||
describe("setToUser", () => {
|
||||
it("should set the correct value", async () => {
|
||||
sut.currentVersion = 9;
|
||||
await sut.setToUser(
|
||||
"userId",
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"new_value",
|
||||
),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
);
|
||||
expect(storage.save).toHaveBeenCalledWith("user_userId_serviceName_key", "new_value");
|
||||
});
|
||||
|
||||
it("should throw if the current version is less than 9", () => {
|
||||
expect(() =>
|
||||
sut.setToUser(
|
||||
"userId",
|
||||
{ stateDefinition: { name: "serviceName" }, key: "key" },
|
||||
"new_value",
|
||||
),
|
||||
).toThrowError("No key builder should be used for versions prior to 9.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -151,6 +209,7 @@ export function mockMigrationHelper(
|
||||
storageJson: any,
|
||||
stateVersion = 0,
|
||||
type: MigrationHelperType = "general",
|
||||
clientType: ClientType = ClientType.Web,
|
||||
): MockProxy<MigrationHelper> {
|
||||
const logService: MockProxy<LogService> = mock();
|
||||
const storage: MockProxy<AbstractStorageService> = mock();
|
||||
@@ -158,7 +217,7 @@ export function mockMigrationHelper(
|
||||
storage.save.mockImplementation(async (key, value) => {
|
||||
(storageJson as any)[key] = value;
|
||||
});
|
||||
const helper = new MigrationHelper(stateVersion, storage, logService, type);
|
||||
const helper = new MigrationHelper(stateVersion, storage, logService, type, clientType);
|
||||
|
||||
const mockHelper = mock<MigrationHelper>();
|
||||
mockHelper.get.mockImplementation((key) => helper.get(key));
|
||||
@@ -295,7 +354,13 @@ export async function runMigrator<
|
||||
const allInjectedData = injectData(initalData, []);
|
||||
|
||||
const fakeStorageService = new FakeStorageService(initalData);
|
||||
const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock(), "general");
|
||||
const helper = new MigrationHelper(
|
||||
migrator.fromVersion,
|
||||
fakeStorageService,
|
||||
mock(),
|
||||
"general",
|
||||
ClientType.Web,
|
||||
);
|
||||
|
||||
// Run their migrations
|
||||
if (direction === "rollback") {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to provide client type to migrations
|
||||
import { ClientType } from "../enums";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
@@ -17,6 +19,7 @@ export class MigrationHelper {
|
||||
private storageService: AbstractStorageService,
|
||||
public logService: LogService,
|
||||
type: MigrationHelperType,
|
||||
public clientType: ClientType,
|
||||
) {
|
||||
this.type = type;
|
||||
}
|
||||
@@ -154,12 +157,12 @@ export class MigrationHelper {
|
||||
*
|
||||
* This is useful from creating migrations off of this paradigm, but should not be used once a value is migrated to a state provider.
|
||||
*
|
||||
* @returns a list of all accounts that have been authenticated with state service, cast the the expected type.
|
||||
* @returns a list of all accounts that have been authenticated with state service, cast the expected type.
|
||||
*/
|
||||
async getAccounts<ExpectedAccountType>(): Promise<
|
||||
{ userId: string; account: ExpectedAccountType }[]
|
||||
> {
|
||||
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? [];
|
||||
const userIds = await this.getKnownUserIds();
|
||||
return Promise.all(
|
||||
userIds.map(async (userId) => ({
|
||||
userId,
|
||||
@@ -168,6 +171,17 @@ export class MigrationHelper {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to read known users ids.
|
||||
*/
|
||||
async getKnownUserIds(): Promise<string[]> {
|
||||
if (this.currentVersion < 61) {
|
||||
return knownAccountUserIdsBuilderPre61(this.storageService);
|
||||
} else {
|
||||
return knownAccountUserIdsBuilder(this.storageService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a user storage key appropriate for the current version.
|
||||
*
|
||||
@@ -230,3 +244,18 @@ function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
|
||||
function globalKeyBuilderPre9(): string {
|
||||
throw Error("No key builder should be used for versions prior to 9.");
|
||||
}
|
||||
|
||||
async function knownAccountUserIdsBuilderPre61(
|
||||
storageService: AbstractStorageService,
|
||||
): Promise<string[]> {
|
||||
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
|
||||
}
|
||||
|
||||
async function knownAccountUserIdsBuilder(
|
||||
storageService: AbstractStorageService,
|
||||
): Promise<string[]> {
|
||||
const accounts = await storageService.get<Record<string, unknown>>(
|
||||
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
|
||||
);
|
||||
return Object.keys(accounts ?? {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
ACCOUNT_ACCOUNTS,
|
||||
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||
ACCOUNT_ACTIVITY,
|
||||
KnownAccountsMigrator,
|
||||
} from "./60-known-accounts";
|
||||
|
||||
const migrateJson = () => {
|
||||
return {
|
||||
authenticatedAccounts: ["user1", "user2"],
|
||||
activeUserId: "user1",
|
||||
user1: {
|
||||
profile: {
|
||||
email: "user1",
|
||||
name: "User 1",
|
||||
emailVerified: true,
|
||||
},
|
||||
},
|
||||
user2: {
|
||||
profile: {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
},
|
||||
},
|
||||
accountActivity: {
|
||||
user1: 1609459200000, // 2021-01-01
|
||||
user2: 1609545600000, // 2021-01-02
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const rollbackJson = () => {
|
||||
return {
|
||||
user1: {
|
||||
profile: {
|
||||
email: "user1",
|
||||
name: "User 1",
|
||||
emailVerified: true,
|
||||
},
|
||||
},
|
||||
user2: {
|
||||
profile: {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
},
|
||||
},
|
||||
global_account_accounts: {
|
||||
user1: {
|
||||
profile: {
|
||||
email: "user1",
|
||||
name: "User 1",
|
||||
emailVerified: true,
|
||||
},
|
||||
},
|
||||
user2: {
|
||||
profile: {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
global_account_activeAccountId: "user1",
|
||||
global_account_activity: {
|
||||
user1: "2021-01-01T00:00:00.000Z",
|
||||
user2: "2021-01-02T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("ReplicateKnownAccounts", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: KnownAccountsMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateJson(), 59);
|
||||
sut = new KnownAccountsMigrator(59, 60);
|
||||
});
|
||||
|
||||
it("migrates accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS, {
|
||||
user1: {
|
||||
email: "user1",
|
||||
name: "User 1",
|
||||
emailVerified: true,
|
||||
},
|
||||
user2: {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
},
|
||||
});
|
||||
expect(helper.remove).toHaveBeenCalledWith("authenticatedAccounts");
|
||||
});
|
||||
|
||||
it("migrates active account it", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID, "user1");
|
||||
expect(helper.remove).toHaveBeenCalledWith("activeUserId");
|
||||
});
|
||||
|
||||
it("migrates account activity", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY, {
|
||||
user1: '"2021-01-01T00:00:00.000Z"',
|
||||
user2: '"2021-01-02T00:00:00.000Z"',
|
||||
});
|
||||
expect(helper.remove).toHaveBeenCalledWith("accountActivity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJson(), 60);
|
||||
sut = new KnownAccountsMigrator(59, 60);
|
||||
});
|
||||
|
||||
it("rolls back authenticated accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("authenticatedAccounts", ["user1", "user2"]);
|
||||
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS);
|
||||
});
|
||||
|
||||
it("rolls back active account id", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("activeUserId", "user1");
|
||||
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||
});
|
||||
|
||||
it("rolls back account activity", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("accountActivity", {
|
||||
user1: 1609459200000,
|
||||
user2: 1609545600000,
|
||||
});
|
||||
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
libs/common/src/state-migrations/migrations/60-known-accounts.ts
Normal file
111
libs/common/src/state-migrations/migrations/60-known-accounts.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
export const ACCOUNT_ACCOUNTS: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "account",
|
||||
},
|
||||
key: "accounts",
|
||||
};
|
||||
|
||||
export const ACCOUNT_ACTIVE_ACCOUNT_ID: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "account",
|
||||
},
|
||||
key: "activeAccountId",
|
||||
};
|
||||
|
||||
export const ACCOUNT_ACTIVITY: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "account",
|
||||
},
|
||||
key: "activity",
|
||||
};
|
||||
|
||||
type ExpectedAccountType = {
|
||||
profile?: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
emailVerified?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export class KnownAccountsMigrator extends Migrator<59, 60> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await this.migrateAuthenticatedAccounts(helper);
|
||||
await this.migrateActiveAccountId(helper);
|
||||
await this.migrateAccountActivity(helper);
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
// authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back
|
||||
const accounts = (await helper.getFromGlobal<Record<string, unknown>>(ACCOUNT_ACCOUNTS)) ?? {};
|
||||
await helper.set("authenticatedAccounts", Object.keys(accounts));
|
||||
await helper.removeFromGlobal(ACCOUNT_ACCOUNTS);
|
||||
|
||||
// Active Account Id
|
||||
const activeAccountId = await helper.getFromGlobal<string>(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||
if (activeAccountId) {
|
||||
await helper.set("activeUserId", activeAccountId);
|
||||
}
|
||||
await helper.removeFromGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||
|
||||
// Account Activity
|
||||
const accountActivity = await helper.getFromGlobal<Record<string, string>>(ACCOUNT_ACTIVITY);
|
||||
if (accountActivity) {
|
||||
const toStore = Object.entries(accountActivity).reduce(
|
||||
(agg, [userId, dateString]) => {
|
||||
agg[userId] = new Date(dateString).getTime();
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
await helper.set("accountActivity", toStore);
|
||||
}
|
||||
await helper.removeFromGlobal(ACCOUNT_ACTIVITY);
|
||||
}
|
||||
|
||||
private async migrateAuthenticatedAccounts(helper: MigrationHelper) {
|
||||
const authenticatedAccounts = (await helper.get<string[]>("authenticatedAccounts")) ?? [];
|
||||
const accounts = await Promise.all(
|
||||
authenticatedAccounts.map(async (userId) => {
|
||||
const account = await helper.get<ExpectedAccountType>(userId);
|
||||
return { userId, account };
|
||||
}),
|
||||
);
|
||||
const accountsToStore = accounts.reduce(
|
||||
(agg, { userId, account }) => {
|
||||
if (account?.profile) {
|
||||
agg[userId] = {
|
||||
email: account.profile.email ?? "",
|
||||
emailVerified: account.profile.emailVerified ?? false,
|
||||
name: account.profile.name,
|
||||
};
|
||||
}
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, { email: string; emailVerified: boolean; name: string | undefined }>,
|
||||
);
|
||||
|
||||
await helper.setToGlobal(ACCOUNT_ACCOUNTS, accountsToStore);
|
||||
await helper.remove("authenticatedAccounts");
|
||||
}
|
||||
|
||||
private async migrateAccountActivity(helper: MigrationHelper) {
|
||||
const stored = await helper.get<Record<string, Date>>("accountActivity");
|
||||
const accountActivity = Object.entries(stored ?? {}).reduce(
|
||||
(agg, [userId, dateMs]) => {
|
||||
agg[userId] = JSON.stringify(new Date(dateMs));
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
await helper.setToGlobal(ACCOUNT_ACTIVITY, accountActivity);
|
||||
await helper.remove("accountActivity");
|
||||
}
|
||||
|
||||
private async migrateActiveAccountId(helper: MigrationHelper) {
|
||||
const activeAccountId = await helper.get<string>("activeUserId");
|
||||
await helper.setToGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID, activeAccountId);
|
||||
await helper.remove("activeUserId");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum
|
||||
import { ClientType } from "../enums";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
@@ -23,52 +25,56 @@ describe("migrator default methods", () => {
|
||||
let helper: MigrationHelper;
|
||||
let sut: TestMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = mock();
|
||||
logService = mock();
|
||||
helper = new MigrationHelper(0, storage, logService, "general");
|
||||
sut = new TestMigrator(0, 1);
|
||||
});
|
||||
const clientTypes = Object.values(ClientType);
|
||||
|
||||
describe("shouldMigrate", () => {
|
||||
describe("up", () => {
|
||||
it("should return true if the current version equals the from version", async () => {
|
||||
expect(await sut.shouldMigrate(helper, "up")).toBe(true);
|
||||
describe.each(clientTypes)("for client %s", (clientType) => {
|
||||
beforeEach(() => {
|
||||
storage = mock();
|
||||
logService = mock();
|
||||
helper = new MigrationHelper(0, storage, logService, "general", clientType);
|
||||
sut = new TestMigrator(0, 1);
|
||||
});
|
||||
|
||||
describe("shouldMigrate", () => {
|
||||
describe("up", () => {
|
||||
it("should return true if the current version equals the from version", async () => {
|
||||
expect(await sut.shouldMigrate(helper, "up")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the current version does not equal the from version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
expect(await sut.shouldMigrate(helper, "up")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if the current version does not equal the from version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
expect(await sut.shouldMigrate(helper, "up")).toBe(false);
|
||||
describe("down", () => {
|
||||
it("should return true if the current version equals the to version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
expect(await sut.shouldMigrate(helper, "down")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the current version does not equal the to version", async () => {
|
||||
expect(await sut.shouldMigrate(helper, "down")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("down", () => {
|
||||
it("should return true if the current version equals the to version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
expect(await sut.shouldMigrate(helper, "down")).toBe(true);
|
||||
describe("updateVersion", () => {
|
||||
describe("up", () => {
|
||||
it("should update the version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
expect(storage.save).toBeCalledWith("stateVersion", 1);
|
||||
expect(helper.currentVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if the current version does not equal the to version", async () => {
|
||||
expect(await sut.shouldMigrate(helper, "down")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
describe("up", () => {
|
||||
it("should update the version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
expect(storage.save).toBeCalledWith("stateVersion", 1);
|
||||
expect(helper.currentVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("down", () => {
|
||||
it("should update the version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
await sut.updateVersion(helper, "down");
|
||||
expect(storage.save).toBeCalledWith("stateVersion", 0);
|
||||
expect(helper.currentVersion).toBe(0);
|
||||
describe("down", () => {
|
||||
it("should update the version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
await sut.updateVersion(helper, "down");
|
||||
expect(storage.save).toBeCalledWith("stateVersion", 0);
|
||||
expect(helper.currentVersion).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("SendService", () => {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: mockUserId,
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
});
|
||||
|
||||
|
||||
@@ -326,7 +326,10 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
|
||||
await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor);
|
||||
await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId);
|
||||
await this.stateService.setEmailVerified(response.emailVerified);
|
||||
await this.accountService.setAccountEmailVerified(
|
||||
response.id as UserId,
|
||||
response.emailVerified,
|
||||
);
|
||||
|
||||
await this.billingAccountProfileStateService.setHasPremium(
|
||||
response.premiumPersonally,
|
||||
|
||||
33
libs/components/src/a11y/a11y-cell.directive.ts
Normal file
33
libs/components/src/a11y/a11y-cell.directive.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
@Directive({
|
||||
selector: "bitA11yCell",
|
||||
standalone: true,
|
||||
providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }],
|
||||
})
|
||||
export class A11yCellDirective implements FocusableElement {
|
||||
@HostBinding("attr.role")
|
||||
role: "gridcell" | null;
|
||||
|
||||
@ContentChild(FocusableElement)
|
||||
private focusableChild: FocusableElement;
|
||||
|
||||
getFocusTarget() {
|
||||
let focusTarget: HTMLElement;
|
||||
if (this.focusableChild) {
|
||||
focusTarget = this.focusableChild.getFocusTarget();
|
||||
} else {
|
||||
focusTarget = this.elementRef.nativeElement.querySelector("button, a");
|
||||
}
|
||||
|
||||
if (!focusTarget) {
|
||||
return this.elementRef.nativeElement;
|
||||
}
|
||||
|
||||
return focusTarget;
|
||||
}
|
||||
|
||||
constructor(private elementRef: ElementRef<HTMLElement>) {}
|
||||
}
|
||||
145
libs/components/src/a11y/a11y-grid.directive.ts
Normal file
145
libs/components/src/a11y/a11y-grid.directive.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ContentChildren,
|
||||
Directive,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
QueryList,
|
||||
} from "@angular/core";
|
||||
|
||||
import type { A11yCellDirective } from "./a11y-cell.directive";
|
||||
import { A11yRowDirective } from "./a11y-row.directive";
|
||||
|
||||
@Directive({
|
||||
selector: "bitA11yGrid",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yGridDirective implements AfterViewInit {
|
||||
@HostBinding("attr.role")
|
||||
role = "grid";
|
||||
|
||||
@ContentChildren(A11yRowDirective)
|
||||
rows: QueryList<A11yRowDirective>;
|
||||
|
||||
/** The number of pages to navigate on `PageUp` and `PageDown` */
|
||||
@Input() pageSize = 5;
|
||||
|
||||
private grid: A11yCellDirective[][];
|
||||
|
||||
/** The row that currently has focus */
|
||||
private activeRow = 0;
|
||||
|
||||
/** The cell that currently has focus */
|
||||
private activeCol = 0;
|
||||
|
||||
@HostListener("keydown", ["$event"])
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
switch (event.code) {
|
||||
case "ArrowUp":
|
||||
this.updateCellFocusByDelta(-1, 0);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
this.updateCellFocusByDelta(0, 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
this.updateCellFocusByDelta(1, 0);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
this.updateCellFocusByDelta(0, -1);
|
||||
break;
|
||||
case "Home":
|
||||
this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
|
||||
break;
|
||||
case "End":
|
||||
this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
|
||||
break;
|
||||
case "PageUp":
|
||||
this.updateCellFocusByDelta(-this.pageSize, 0);
|
||||
break;
|
||||
case "PageDown":
|
||||
this.updateCellFocusByDelta(this.pageSize, 0);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
/** Prevent default scrolling behavior */
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initializeGrid();
|
||||
}
|
||||
|
||||
private initializeGrid(): void {
|
||||
try {
|
||||
this.grid = this.rows.map((listItem) => {
|
||||
listItem.role = "row";
|
||||
return [...listItem.cells];
|
||||
});
|
||||
this.grid.flat().forEach((cell) => {
|
||||
cell.role = "gridcell";
|
||||
cell.getFocusTarget().tabIndex = -1;
|
||||
});
|
||||
|
||||
this.getActiveCellContent().tabIndex = 0;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Unable to initialize grid");
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the focusable content of the active cell */
|
||||
private getActiveCellContent(): HTMLElement {
|
||||
return this.grid[this.activeRow][this.activeCol].getFocusTarget();
|
||||
}
|
||||
|
||||
/** Move focus via a delta against the currently active gridcell */
|
||||
private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
|
||||
const prevActive = this.getActiveCellContent();
|
||||
|
||||
this.activeCol += colDelta;
|
||||
this.activeRow += rowDelta;
|
||||
|
||||
// Row upper bound
|
||||
if (this.activeRow >= this.grid.length) {
|
||||
this.activeRow = this.grid.length - 1;
|
||||
}
|
||||
|
||||
// Row lower bound
|
||||
if (this.activeRow < 0) {
|
||||
this.activeRow = 0;
|
||||
}
|
||||
|
||||
// Column upper bound
|
||||
if (this.activeCol >= this.grid[this.activeRow].length) {
|
||||
if (this.activeRow < this.grid.length - 1) {
|
||||
// Wrap to next row on right arrow
|
||||
this.activeCol = 0;
|
||||
this.activeRow += 1;
|
||||
} else {
|
||||
this.activeCol = this.grid[this.activeRow].length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Column lower bound
|
||||
if (this.activeCol < 0) {
|
||||
if (this.activeRow > 0) {
|
||||
// Wrap to prev row on left arrow
|
||||
this.activeRow -= 1;
|
||||
this.activeCol = this.grid[this.activeRow].length - 1;
|
||||
} else {
|
||||
this.activeCol = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const nextActive = this.getActiveCellContent();
|
||||
nextActive.tabIndex = 0;
|
||||
nextActive.focus();
|
||||
|
||||
if (nextActive !== prevActive) {
|
||||
prevActive.tabIndex = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
libs/components/src/a11y/a11y-row.directive.ts
Normal file
31
libs/components/src/a11y/a11y-row.directive.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ContentChildren,
|
||||
Directive,
|
||||
HostBinding,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from "@angular/core";
|
||||
|
||||
import { A11yCellDirective } from "./a11y-cell.directive";
|
||||
|
||||
@Directive({
|
||||
selector: "bitA11yRow",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yRowDirective implements AfterViewInit {
|
||||
@HostBinding("attr.role")
|
||||
role: "row" | null;
|
||||
|
||||
cells: A11yCellDirective[];
|
||||
|
||||
@ViewChildren(A11yCellDirective)
|
||||
private viewCells: QueryList<A11yCellDirective>;
|
||||
|
||||
@ContentChildren(A11yCellDirective)
|
||||
private contentCells: QueryList<A11yCellDirective>;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.cells = [...this.viewCells, ...this.contentCells];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
|
||||
const styles: Record<BadgeVariant, string[]> = {
|
||||
@@ -22,8 +24,9 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
|
||||
|
||||
@Directive({
|
||||
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
|
||||
providers: [{ provide: FocusableElement, useExisting: BadgeDirective }],
|
||||
})
|
||||
export class BadgeDirective {
|
||||
export class BadgeDirective implements FocusableElement {
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-inline-block",
|
||||
@@ -62,6 +65,10 @@ export class BadgeDirective {
|
||||
*/
|
||||
@Input() truncate = true;
|
||||
|
||||
getFocusTarget() {
|
||||
return this.el.nativeElement;
|
||||
}
|
||||
|
||||
private hasHoverEffects = false;
|
||||
|
||||
constructor(private el: ElementRef<HTMLElement>) {
|
||||
|
||||
@@ -50,7 +50,7 @@ element after close since a user may not want to close the dialog immediately if
|
||||
additional interactive elements. See
|
||||
[WCAG Focus Order success criteria](https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html)
|
||||
|
||||
Once closed, focus should remain on the the element which triggered the Dialog.
|
||||
Once closed, focus should remain on the element which triggered the Dialog.
|
||||
|
||||
**Note:** If a Simple Dialog is triggered from a main Dialog, be sure to make sure focus is moved to
|
||||
the Simple Dialog.
|
||||
|
||||
@@ -16,8 +16,8 @@ always use the native `form` element and bind a `formGroup`.
|
||||
|
||||
Forms consists of 1 or more inputs, and ends with 1 or 2 buttons.
|
||||
|
||||
If there are many inputs in a form, they should should be organized into sections as content
|
||||
relates. **Example:** Item type form
|
||||
If there are many inputs in a form, they should be organized into sections as content relates.
|
||||
**Example:** Item type form
|
||||
|
||||
Each input within a section should follow the following spacing guidelines (see
|
||||
[Tailwind CSS spacing documentation](https://tailwindcss.com/docs/customizing-spacing)):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
||||
|
||||
@@ -123,9 +124,12 @@ const sizes: Record<IconButtonSize, string[]> = {
|
||||
@Component({
|
||||
selector: "button[bitIconButton]:not(button[bitButton])",
|
||||
templateUrl: "icon-button.component.html",
|
||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
|
||||
providers: [
|
||||
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
|
||||
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
|
||||
],
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction {
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
@Input("bitIconButton") icon: string;
|
||||
|
||||
@Input() buttonType: IconButtonType;
|
||||
@@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
|
||||
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
|
||||
this.buttonType = value;
|
||||
}
|
||||
|
||||
getFocusTarget() {
|
||||
return this.elementRef.nativeElement;
|
||||
}
|
||||
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./form-field";
|
||||
export * from "./icon-button";
|
||||
export * from "./icon";
|
||||
export * from "./input";
|
||||
export * from "./item";
|
||||
export * from "./layout";
|
||||
export * from "./link";
|
||||
export * from "./menu";
|
||||
|
||||
@@ -3,12 +3,7 @@ import { take } from "rxjs/operators";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
/**
|
||||
* Interface for implementing focusable components. Used by the AutofocusDirective.
|
||||
*/
|
||||
export abstract class FocusableElement {
|
||||
focus: () => void;
|
||||
}
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
/**
|
||||
* Directive to focus an element.
|
||||
@@ -46,7 +41,7 @@ export class AutofocusDirective {
|
||||
|
||||
private focus() {
|
||||
if (this.focusableElement) {
|
||||
this.focusableElement.focus();
|
||||
this.focusableElement.getFocusTarget().focus();
|
||||
} else {
|
||||
this.el.nativeElement.focus();
|
||||
}
|
||||
|
||||
1
libs/components/src/item/index.ts
Normal file
1
libs/components/src/item/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./item.module";
|
||||
12
libs/components/src/item/item-action.component.ts
Normal file
12
libs/components/src/item/item-action.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { A11yCellDirective } from "../a11y/a11y-cell.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-item-action",
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<ng-content></ng-content>`,
|
||||
providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }],
|
||||
})
|
||||
export class ItemActionComponent extends A11yCellDirective {}
|
||||
16
libs/components/src/item/item-content.component.html
Normal file
16
libs/components/src/item/item-content.component.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="tw-flex tw-gap-2 tw-items-center">
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-items-start tw-text-start tw-w-full [&_p]:tw-mb-0">
|
||||
<div class="tw-text-main tw-text-base">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
<div class="tw-text-muted tw-text-sm">
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-gap-2 tw-items-center">
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
</div>
|
||||
15
libs/components/src/item/item-content.component.ts
Normal file
15
libs/components/src/item/item-content.component.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-item-content, [bit-item-content]",
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: `item-content.component.html`,
|
||||
host: {
|
||||
class:
|
||||
"fvw-target tw-outline-none tw-text-main hover:tw-text-main hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ItemContentComponent {}
|
||||
13
libs/components/src/item/item-group.component.ts
Normal file
13
libs/components/src/item/item-group.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-item-group",
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<ng-content></ng-content>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: "tw-block",
|
||||
},
|
||||
})
|
||||
export class ItemGroupComponent {}
|
||||
21
libs/components/src/item/item.component.html
Normal file
21
libs/components/src/item/item.component.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!-- TODO: Colors will be finalized in the extension refresh feature branch -->
|
||||
<div
|
||||
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-bg-primary-300/20 tw-text-main tw-border-solid tw-border-b tw-border-0 tw-rounded-lg tw-mb-1.5"
|
||||
[ngClass]="
|
||||
focusVisibleWithin()
|
||||
? 'tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-primary-600 tw-border-transparent'
|
||||
: 'tw-border-b-secondary-300 [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-border-b-transparent'
|
||||
"
|
||||
>
|
||||
<bit-item-action class="item-main-content tw-block tw-w-full">
|
||||
<ng-content></ng-content>
|
||||
</bit-item-action>
|
||||
|
||||
<div
|
||||
#endSlot
|
||||
class="tw-p-2 tw-flex tw-gap-1 tw-items-center"
|
||||
[hidden]="endSlot.childElementCount === 0"
|
||||
>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
29
libs/components/src/item/item.component.ts
Normal file
29
libs/components/src/item/item.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, HostListener, signal } from "@angular/core";
|
||||
|
||||
import { A11yRowDirective } from "../a11y/a11y-row.directive";
|
||||
|
||||
import { ItemActionComponent } from "./item-action.component";
|
||||
|
||||
@Component({
|
||||
selector: "bit-item",
|
||||
standalone: true,
|
||||
imports: [CommonModule, ItemActionComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "item.component.html",
|
||||
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
|
||||
})
|
||||
export class ItemComponent extends A11yRowDirective {
|
||||
/**
|
||||
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
|
||||
*/
|
||||
protected focusVisibleWithin = signal(false);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.focusVisibleWithin.set(false);
|
||||
}
|
||||
}
|
||||
141
libs/components/src/item/item.mdx
Normal file
141
libs/components/src/item/item.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./item.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { ItemModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Item
|
||||
|
||||
`<bit-item>` is a horizontal card that contains one or more interactive actions.
|
||||
|
||||
It is a generic container that can be used for either standalone content, an alternative to tables,
|
||||
or to list nav links.
|
||||
|
||||
<Canvas>
|
||||
<Story of={stories.Default} />
|
||||
</Canvas>
|
||||
|
||||
## Primary Content
|
||||
|
||||
The primary content of an item is supplied by `bit-item-content`.
|
||||
|
||||
### Content Types
|
||||
|
||||
The content can be a button, anchor, or static container.
|
||||
|
||||
```html
|
||||
<bit-item>
|
||||
<a bit-item-content routerLink="..."> Hi, I am a link. </a>
|
||||
</bit-item>
|
||||
|
||||
<bit-item>
|
||||
<button bit-item-content (click)="...">And I am a button.</button>
|
||||
</bit-item>
|
||||
|
||||
<bit-item>
|
||||
<bit-item-content> I'm just static :( </bit-item-content>
|
||||
</bit-item>
|
||||
```
|
||||
|
||||
<Canvas>
|
||||
<Story of={stories.ContentTypes} />
|
||||
</Canvas>
|
||||
|
||||
### Content Slots
|
||||
|
||||
`bit-item-content` contains the following slots to help position the content:
|
||||
|
||||
| Slot | Description |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| default | primary text or arbitrary content; fan favorite |
|
||||
| `slot="secondary"` | supporting text; under the default slot |
|
||||
| `slot="start"` | commonly an icon or avatar; before the default slot |
|
||||
| `slot="end"` | commonly an icon; after the default slot |
|
||||
|
||||
- Note: There is also an `end` slot within `bit-item` itself. Place
|
||||
[interactive secondary actions](#secondary-actions) there, and place non-interactive content (such
|
||||
as icons) in `bit-item-content`
|
||||
|
||||
```html
|
||||
<bit-item>
|
||||
<button bit-item-content type="button">
|
||||
<bit-avatar slot="start" text="Foo"></bit-avatar>
|
||||
foo@bitwarden.com
|
||||
<ng-container slot="secondary">
|
||||
<div>Bitwarden.com</div>
|
||||
<div><em>locked</em></div>
|
||||
</ng-container>
|
||||
<i slot="end" class="bwi bwi-lock" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
```
|
||||
|
||||
<Canvas>
|
||||
<Story of={stories.ContentSlots} />
|
||||
</Canvas>
|
||||
|
||||
## Secondary Actions
|
||||
|
||||
Secondary interactive actions can be placed in the item through the `"end"` slot, outside of
|
||||
`bit-item-content`.
|
||||
|
||||
Each action must be wrapped by `<bit-item-action>`.
|
||||
|
||||
Actions are commonly icon buttons or badge buttons.
|
||||
|
||||
```html
|
||||
<bit-item>
|
||||
<button bit-item-content>...</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone" aria-label="Copy"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v" aria-label="Options"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
```
|
||||
|
||||
## Item Groups
|
||||
|
||||
Groups of items can be associated by wrapping them in the `<bit-item-group>`.
|
||||
|
||||
<Canvas>
|
||||
<Story of={stories.MultipleActionList} />
|
||||
</Canvas>
|
||||
|
||||
<Canvas>
|
||||
<Story of={stories.SingleActionList} />
|
||||
</Canvas>
|
||||
|
||||
### A11y
|
||||
|
||||
Keyboard nav is currently disabled due to a bug when used within a virtual scroll viewport.
|
||||
|
||||
Item groups utilize arrow-based keyboard navigation
|
||||
([further reading here](https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/#kbd_label)).
|
||||
|
||||
Use `aria-label` or `aria-labelledby` to give groups an accessible name.
|
||||
|
||||
```html
|
||||
<bit-item-group aria-label="My Items">
|
||||
<bit-item>...</bit-item>
|
||||
<bit-item>...</bit-item>
|
||||
<bit-item>...</bit-item>
|
||||
</bit-item-group>
|
||||
```
|
||||
|
||||
### Virtual Scrolling
|
||||
|
||||
<Canvas>
|
||||
<Story of={stories.VirtualScrolling} />
|
||||
</Canvas>
|
||||
12
libs/components/src/item/item.module.ts
Normal file
12
libs/components/src/item/item.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { ItemActionComponent } from "./item-action.component";
|
||||
import { ItemContentComponent } from "./item-content.component";
|
||||
import { ItemGroupComponent } from "./item-group.component";
|
||||
import { ItemComponent } from "./item.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [ItemComponent, ItemContentComponent, ItemActionComponent, ItemGroupComponent],
|
||||
exports: [ItemComponent, ItemContentComponent, ItemActionComponent, ItemGroupComponent],
|
||||
})
|
||||
export class ItemModule {}
|
||||
326
libs/components/src/item/item.stories.ts
Normal file
326
libs/components/src/item/item.stories.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { A11yGridDirective } from "../a11y/a11y-grid.directive";
|
||||
import { AvatarModule } from "../avatar";
|
||||
import { BadgeModule } from "../badge";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
import { ItemActionComponent } from "./item-action.component";
|
||||
import { ItemContentComponent } from "./item-content.component";
|
||||
import { ItemGroupComponent } from "./item-group.component";
|
||||
import { ItemComponent } from "./item.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Item",
|
||||
component: ItemComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ItemGroupComponent,
|
||||
AvatarModule,
|
||||
IconButtonModule,
|
||||
BadgeModule,
|
||||
TypographyModule,
|
||||
ItemActionComponent,
|
||||
ItemContentComponent,
|
||||
A11yGridDirective,
|
||||
ScrollingModule,
|
||||
],
|
||||
}),
|
||||
componentWrapperDecorator((story) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<ItemGroupComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ContentSlots: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item>
|
||||
<button bit-item-content type="button">
|
||||
<bit-avatar
|
||||
slot="start"
|
||||
[text]="'Foo'"
|
||||
></bit-avatar>
|
||||
foo@bitwarden.com
|
||||
<ng-container slot="secondary">
|
||||
<div>Bitwarden.com</div>
|
||||
<div><em>locked</em></div>
|
||||
</ng-container>
|
||||
<i slot="end" class="bwi bwi-lock" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ContentTypes: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Hi, I am a link.
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content href="#">
|
||||
And I am a button.
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<bit-item-content>
|
||||
I'm just static :(
|
||||
</bit-item-content>
|
||||
</bit-item>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const TextOverflow: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-text-main tw-mb-4">TODO: Fix truncation</div>
|
||||
<bit-item>
|
||||
<bit-item-content>
|
||||
Helloooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
|
||||
</bit-item-content>
|
||||
</bit-item>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const MultipleActionList: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item-group aria-label="Multiple Action List">
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
Foo
|
||||
<span slot="secondary">Bar</span>
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const SingleActionList: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item-group aria-label="Single Action List">
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Foobar
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Foobar
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Foobar
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Foobar
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Foobar
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<a bit-item-content href="#">
|
||||
Foobar
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const VirtualScrolling: Story = {
|
||||
render: (_args) => ({
|
||||
props: {
|
||||
data: Array.from(Array(100000).keys()),
|
||||
},
|
||||
template: /*html*/ `
|
||||
<cdk-virtual-scroll-viewport [itemSize]="46" class="tw-h-[500px]">
|
||||
<bit-item-group aria-label="Single Action List">
|
||||
<bit-item *cdkVirtualFor="let item of data">
|
||||
<button bit-item-content>
|
||||
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||
{{ item }}
|
||||
</button>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
|
||||
import { FocusableElement } from "../input/autofocus.directive";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@@ -32,8 +32,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
|
||||
@Input() disabled: boolean;
|
||||
@Input() placeholder: string;
|
||||
|
||||
focus() {
|
||||
this.input.nativeElement.focus();
|
||||
getFocusTarget() {
|
||||
return this.input.nativeElement;
|
||||
}
|
||||
|
||||
onChange(searchText: string) {
|
||||
|
||||
8
libs/components/src/shared/focusable-element.ts
Normal file
8
libs/components/src/shared/focusable-element.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Interface for implementing focusable components.
|
||||
*
|
||||
* Used by the `AutofocusDirective` and `A11yGridDirective`.
|
||||
*/
|
||||
export abstract class FocusableElement {
|
||||
getFocusTarget: () => HTMLElement;
|
||||
}
|
||||
@@ -49,6 +49,6 @@ $card-icons-base: "../../src/billing/images/cards/";
|
||||
@import "multi-select/scss/bw.theme.scss";
|
||||
|
||||
// Workaround for https://bitwarden.atlassian.net/browse/CL-110
|
||||
#storybook-docs pre.prismjs {
|
||||
.sbdocs-preview pre.prismjs {
|
||||
color: white;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user