Fold account activity into account service

This commit is contained in:
Matt Gibson 2024-04-23 18:53:59 -04:00
parent 06c24acc5c
commit b12c9eeab6
No known key found for this signature in database
GPG Key ID: 963EE038B0581878
23 changed files with 210 additions and 428 deletions

View File

@ -1,29 +0,0 @@
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { DefaultAccountActivityService } from "@bitwarden/common/auth/services/account-activity.service";
import {
FactoryOptions,
CachedServices,
factory,
} from "../../../platform/background/service-factories/factory-options";
import {
GlobalStateProviderInitOptions,
globalStateProviderFactory,
} from "../../../platform/background/service-factories/global-state-provider.factory";
type AccountActivityServiceFactoryOptions = FactoryOptions;
export type AccountActivityServiceInitOptions = AccountActivityServiceFactoryOptions &
GlobalStateProviderInitOptions;
export function accountActivityServiceFactory(
cache: { accountActivityService?: AccountActivityService } & CachedServices,
opts: AccountActivityServiceInitOptions,
): Promise<AccountActivityService> {
return factory(
cache,
"accountActivityService",
opts,
async () => new DefaultAccountActivityService(await globalStateProviderFactory(cache, opts)),
);
}

View File

@ -19,18 +19,12 @@ import {
messagingServiceFactory,
} from "../../../platform/background/service-factories/messaging-service.factory";
import {
AccountActivityServiceInitOptions,
accountActivityServiceFactory,
} from "./account-activity-service.factory";
type AccountServiceFactoryOptions = FactoryOptions;
export type AccountServiceInitOptions = AccountServiceFactoryOptions &
MessagingServiceInitOptions &
LogServiceInitOptions &
GlobalStateProviderInitOptions &
AccountActivityServiceInitOptions;
GlobalStateProviderInitOptions;
export function accountServiceFactory(
cache: { accountService?: AccountService } & CachedServices,
@ -45,7 +39,6 @@ export function accountServiceFactory(
await messagingServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
await globalStateProviderFactory(cache, opts),
await accountActivityServiceFactory(cache, opts),
),
);
}

View File

@ -1,7 +1,6 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import {
@ -68,7 +67,6 @@ describe("ContextMenuClickedHandler", () => {
let authService: MockProxy<AuthService>;
let cipherService: MockProxy<CipherService>;
let accountService: FakeAccountService;
let accountActivityService: MockProxy<AccountActivityService>;
let totpService: MockProxy<TotpService>;
let eventCollectionService: MockProxy<EventCollectionService>;
let userVerificationService: MockProxy<UserVerificationService>;
@ -84,7 +82,6 @@ describe("ContextMenuClickedHandler", () => {
accountService = mockAccountServiceWith("userId" as UserId);
totpService = mock();
eventCollectionService = mock();
accountActivityService = mock();
sut = new ContextMenuClickedHandler(
copyToClipboard,
@ -96,7 +93,6 @@ describe("ContextMenuClickedHandler", () => {
eventCollectionService,
userVerificationService,
accountService,
accountActivityService,
);
});

View File

@ -1,7 +1,6 @@
import { firstValueFrom, map } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@ -29,7 +28,6 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { accountActivityServiceFactory } from "../../auth/background/service-factories/account-activity-service.factory";
import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory";
import {
authServiceFactory,
@ -79,7 +77,6 @@ export class ContextMenuClickedHandler {
private eventCollectionService: EventCollectionService,
private userVerificationService: UserVerificationService,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {}
static async mv3Create(cachedServices: CachedServices) {
@ -137,7 +134,6 @@ export class ContextMenuClickedHandler {
await eventCollectionServiceFactory(cachedServices, serviceOptions),
await userVerificationServiceFactory(cachedServices, serviceOptions),
await accountServiceFactory(cachedServices, serviceOptions),
await accountActivityServiceFactory(cachedServices, serviceOptions),
);
}
@ -248,7 +244,7 @@ export class ContextMenuClickedHandler {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.accountActivityService.setAccountActivity(activeUserId, new Date());
await this.accountService.setAccountActivity(activeUserId, new Date());
switch (info.parentMenuItemId) {
case AUTOFILL_ID:
case AUTOFILL_IDENTITY_ID:

View File

@ -27,7 +27,6 @@ import { OrganizationService } from "@bitwarden/common/admin-console/services/or
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
@ -43,7 +42,6 @@ import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/ab
import { UserVerificationService as UserVerificationServiceAbstraction } 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 { DefaultAccountActivityService } from "@bitwarden/common/auth/services/account-activity.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
@ -323,7 +321,6 @@ export default class MainBackground {
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
authRequestService: AuthRequestServiceAbstraction;
accountService: AccountServiceAbstraction;
accountActivityService: AccountActivityService;
globalStateProvider: GlobalStateProvider;
pinCryptoService: PinCryptoServiceAbstraction;
singleUserStateProvider: SingleUserStateProvider;
@ -487,12 +484,10 @@ export default class MainBackground {
storageServiceProvider,
stateEventRegistrarService,
);
this.accountActivityService = new DefaultAccountActivityService(this.globalStateProvider);
this.accountService = new AccountServiceImplementation(
this.messagingService,
this.logService,
this.globalStateProvider,
this.accountActivityService,
);
this.activeUserStateProvider = new DefaultActiveUserStateProvider(
this.accountService,
@ -772,7 +767,6 @@ export default class MainBackground {
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.accountActivityService,
lockedCallback,
logoutCallback,
);
@ -957,7 +951,6 @@ export default class MainBackground {
this.fido2Background,
messageListener,
this.accountService,
this.accountActivityService,
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.accountService,
@ -1051,7 +1044,6 @@ export default class MainBackground {
this.eventCollectionService,
this.userVerificationService,
this.accountService,
this.accountActivityService,
);
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
@ -1258,7 +1250,7 @@ export default class MainBackground {
const needStorageReseed = await this.needsStorageReseed();
const newActiveUser = await firstValueFrom(
this.accountService.nextUpActiveUsers$.pipe(map((users) => users[0]?.id ?? null)),
this.accountService.nextUpAccount$.pipe(map((a) => a?.id)),
);
await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);

View File

@ -1,7 +1,6 @@
import { firstValueFrom, map, mergeMap } from "rxjs";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
@ -47,7 +46,6 @@ export default class RuntimeBackground {
private fido2Background: Fido2Background,
private messageListener: MessageListener,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@ -112,7 +110,7 @@ export default class RuntimeBackground {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.accountActivityService.setAccountActivity(activeUserId, new Date());
await this.accountService.setAccountActivity(activeUserId, new Date());
const totpCode = await this.autofillService.doAutoFillActiveTab(
[
{

View File

@ -1,9 +1,5 @@
import { VaultTimeoutService as AbstractVaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import {
accountActivityServiceFactory,
AccountActivityServiceInitOptions,
} from "../../auth/background/service-factories/account-activity-service.factory";
import {
accountServiceFactory,
AccountServiceInitOptions,
@ -81,8 +77,7 @@ export type VaultTimeoutServiceInitOptions = VaultTimeoutServiceFactoryOptions &
StateServiceInitOptions &
AuthServiceInitOptions &
VaultTimeoutSettingsServiceInitOptions &
StateEventRunnerServiceInitOptions &
AccountActivityServiceInitOptions;
StateEventRunnerServiceInitOptions;
export function vaultTimeoutServiceFactory(
cache: { vaultTimeoutService?: AbstractVaultTimeoutService } & CachedServices,
@ -107,7 +102,6 @@ export function vaultTimeoutServiceFactory(
await authServiceFactory(cache, opts),
await vaultTimeoutSettingsServiceFactory(cache, opts),
await stateEventRunnerServiceFactory(cache, opts),
await accountActivityServiceFactory(cache, opts),
opts.vaultTimeoutServiceOptions.lockedCallback,
opts.vaultTimeoutServiceOptions.loggedOutCallback,
),

View File

@ -1,10 +1,8 @@
import { Component, Input } from "@angular/core";
import { Observable, combineLatest, map, of, switchMap } from "rxjs";
import { Observable, map, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserId } from "@bitwarden/common/types/guid";
import { enableAccountSwitching } from "../flags";
@ -16,18 +14,15 @@ export class HeaderComponent {
@Input() noTheme = false;
@Input() hideAccountSwitcher = false;
authedAccounts$: Observable<boolean>;
constructor(accountService: AccountService, authService: AuthService) {
this.authedAccounts$ = accountService.accounts$.pipe(
switchMap((accounts) => {
constructor(authService: AuthService) {
this.authedAccounts$ = authService.authStatuses$.pipe(
map((record) => Object.values(record)),
switchMap((statuses) => {
if (!enableAccountSwitching()) {
return of(false);
}
return combineLatest(
Object.keys(accounts).map((id) => authService.authStatusFor$(id as UserId)),
).pipe(
map((statuses) => statuses.some((status) => status !== AuthenticationStatus.LoggedOut)),
);
return of(statuses.some((status) => status !== AuthenticationStatus.LoggedOut));
}),
);
}

View File

@ -193,26 +193,33 @@ export class LocalBackedSessionStorageService
}
private compareValues<T>(value1: T, value2: T): boolean {
if (value1 == null && value2 == null) {
try {
if (value1 == null && value2 == null) {
return true;
}
if (value1 && value2 == null) {
return false;
}
if (value1 == null && value2) {
return false;
}
if (typeof value1 !== "object" || typeof value2 !== "object") {
return value1 === value2;
}
if (JSON.stringify(value1) === JSON.stringify(value2)) {
return true;
}
return Object.entries(value1).sort().toString() === Object.entries(value2).sort().toString();
} catch (e) {
this.logService.error(
`error comparing values\n${JSON.stringify(value1)}\n${JSON.stringify(value2)}`,
);
return true;
}
if (value1 && value2 == null) {
return false;
}
if (value1 == null && value2) {
return false;
}
if (typeof value1 !== "object" || typeof value2 !== "object") {
return value1 === value2;
}
if (JSON.stringify(value1) === JSON.stringify(value2)) {
return true;
}
return Object.entries(value1).sort().toString() === Object.entries(value2).sort().toString();
}
}

View File

@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angula
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -51,7 +50,6 @@ export class AppComponent implements OnInit, OnDestroy {
private messageListener: MessageListener,
private toastService: ToastService,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {}
async ngOnInit() {
@ -211,7 +209,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
this.lastActivity = now;
await this.accountActivityService.setAccountActivity(this.activeUserId, now);
await this.accountService.setAccountActivity(this.activeUserId, now);
}
private showToast(msg: any) {

View File

@ -20,7 +20,6 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
@ -152,7 +151,6 @@ export class AppComponent implements OnInit, OnDestroy {
private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {}
ngOnInit() {
@ -634,7 +632,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
this.lastActivity = now;
await this.accountActivityService.setAccountActivity(this.activeUserId, now);
await this.accountService.setAccountActivity(this.activeUserId, now);
// Idle states
if (this.isIdle) {

View File

@ -10,7 +10,6 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
@ -89,7 +88,6 @@ export class AppComponent implements OnDestroy, OnInit {
private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {}
ngOnInit() {
@ -311,7 +309,7 @@ export class AppComponent implements OnDestroy, OnInit {
}
this.lastActivity = now;
await this.accountActivityService.setAccountActivity(activeUserId, now);
await this.accountService.setAccountActivity(activeUserId, now);
// Idle states
if (this.isIdle) {
this.isIdle = false;

View File

@ -51,7 +51,6 @@ import { PolicyApiService } from "@bitwarden/common/admin-console/services/polic
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountActivityService } from "@bitwarden/common/auth/abstractions/account-activity.service";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import {
AccountService,
@ -78,7 +77,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { DefaultAccountActivityService } from "@bitwarden/common/auth/services/account-activity.service";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
@ -483,7 +481,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: InternalAccountService,
useClass: AccountServiceImplementation,
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider, AccountActivityService],
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider],
}),
safeProvider({
provide: AccountServiceAbstraction,
@ -666,7 +664,6 @@ const safeProviders: SafeProvider[] = [
AuthServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
StateEventRunnerService,
AccountActivityService,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
],
@ -1159,11 +1156,6 @@ const safeProviders: SafeProvider[] = [
useClass: ProviderApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: AccountActivityService,
useClass: DefaultAccountActivityService,
deps: [GlobalStateProvider],
}),
];
function encryptServiceFactory(

View File

@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { Observable, ReplaySubject } from "rxjs";
import { ReplaySubject, combineLatest, map } from "rxjs";
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid";
@ -33,14 +33,31 @@ export class FakeAccountService implements AccountService {
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
nextUpActiveUsers$: Observable<({ id: UserId } & AccountInfo)[]>;
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>, accountActivity?: Record<UserId, Date>) {
this.accountsSubject.next(initialData);
@ -48,6 +65,13 @@ export class FakeAccountService implements AccountService {
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> {
const current = this.accountsSubject["_buffer"][0] ?? {};

View File

@ -1,32 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
export abstract class AccountActivityService {
/**
* Observable of the last activity time for each account.
*/
accountActivity$: Observable<Record<UserId, Date>>;
/**
* Observable of the next-most-recent active account.
*/
sortedUserIds$: Observable<UserId[]>;
/**
* Updates the given user's last activity time.
* @param userId
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
/**
* Removes a user from the account activity list.
* @param userId
*/
abstract removeAccountActivity(userId: UserId): Promise<void>;
/**
* Returns the most recent account that is not the current active account, as expressed by the `currentUser` observable.
* @param currentUser Observable of the current active account's UserId.
*/
abstract nextUpActiveAccount(currentUser: Observable<UserId>): Observable<UserId | null>;
}

View File

@ -30,8 +30,15 @@ export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
/** Ordered list of next accounts, sorted in descending order by most recent account activity */
nextUpActiveUsers$: Observable<({ id: UserId } & 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.
*
@ -70,6 +77,12 @@ export abstract class AccountService {
* @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 {

View File

@ -1,179 +0,0 @@
import { firstValueFrom, of } from "rxjs";
import { FakeGlobalState, FakeGlobalStateProvider } from "../../../spec";
import { UserId } from "../../types/guid";
import { AccountActivityService } from "../abstractions/account-activity.service";
import { ACCOUNT_ACTIVITY, DefaultAccountActivityService } from "./account-activity.service";
describe("AccountActivityService", () => {
let globalStateProvider: FakeGlobalStateProvider;
let state: FakeGlobalState<Record<UserId, Date>>;
let sut: AccountActivityService;
beforeEach(() => {
globalStateProvider = new FakeGlobalStateProvider();
state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
sut = new DefaultAccountActivityService(globalStateProvider);
});
afterEach(() => {
jest.resetAllMocks();
});
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", 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([
"user3" as UserId,
"user2" as UserId,
"user1" 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("nextUpActiveAccount", () => {
beforeEach(() => {
state.stateSubject.next({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
[toId("user3")]: new Date(3),
});
});
it("returns returns the next most recent account when active is most recent", async () => {
const currentUser = of("user1" as UserId);
await expect(firstValueFrom(sut.nextUpActiveAccount(currentUser))).resolves.toBe(
"user2" as UserId,
);
});
it("returns most recent account when active is not most recent", async () => {
const currentUser = of("user2" as UserId);
await expect(firstValueFrom(sut.nextUpActiveAccount(currentUser))).resolves.toBe(
"user1" as UserId,
);
});
it("returns the most recent account when active is null", async () => {
const currentUser = of(null);
await expect(firstValueFrom(sut.nextUpActiveAccount(currentUser))).resolves.toBe(
"user1" as UserId,
);
});
it("returns null when there are no accounts", async () => {
state.stateSubject.next({});
const currentUser = of(null);
await expect(firstValueFrom(sut.nextUpActiveAccount(currentUser))).resolves.toBe(null);
});
it("returns null when there are no accounts but there is an active account", async () => {
state.stateSubject.next({});
const currentUser = of("user1" as UserId);
await expect(firstValueFrom(sut.nextUpActiveAccount(currentUser))).resolves.toBe(null);
});
it("returns null when account activity is null", async () => {
state.stateSubject.next(null);
const currentUser = of(null);
await expect(firstValueFrom(sut.nextUpActiveAccount(currentUser))).resolves.toBe(null);
});
});
describe("setAccountActivity", () => {
it("sets the account activity", async () => {
await sut.setAccountActivity("user1" as UserId, new Date(1));
expect(state.nextMock).toHaveBeenCalledWith({ user1: new Date(1) });
});
it("does not update if the activity is the same", async () => {
state.stateSubject.next({ [toId("user1")]: new Date(1) });
await sut.setAccountActivity("user1" as UserId, new Date(1));
expect(state.nextMock).not.toHaveBeenCalled();
});
});
describe("removeAccountActivity", () => {
it("removes the account activity", async () => {
state.stateSubject.next({ [toId("user1")]: new Date(1) });
await sut.removeAccountActivity("user1" as UserId);
expect(state.nextMock).toHaveBeenCalledWith({});
});
it("does not update if the account activity is null", async () => {
state.stateSubject.next(null);
await sut.removeAccountActivity("user1" as UserId);
expect(state.nextMock).not.toHaveBeenCalled();
});
it("does not update if the account activity is empty", async () => {
state.stateSubject.next({});
await sut.removeAccountActivity("user1" as UserId);
expect(state.nextMock).not.toHaveBeenCalled();
});
it("does not update if the account activity does not contain the user", async () => {
state.stateSubject.next({ [toId("user2")]: new Date(1) });
await sut.removeAccountActivity("user1" as UserId);
expect(state.nextMock).not.toHaveBeenCalled();
});
});
});
function toId(userId: string) {
return userId as UserId;
}

View File

@ -1,66 +0,0 @@
import { Observable, combineLatest, map } from "rxjs";
import { ACCOUNT_DISK, GlobalStateProvider, KeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { AccountActivityService } from "../abstractions/account-activity.service";
export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK, "activity", {
deserializer: (activity) => new Date(activity),
});
export class DefaultAccountActivityService implements AccountActivityService {
accountActivity$: Observable<Record<UserId, Date>>;
sortedUserIds$: Observable<UserId[]>;
constructor(private globalStateProvider: GlobalStateProvider) {
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) => a.lastActive.getTime() - b.lastActive.getTime())
.map((a) => a.userId);
}),
);
}
nextUpActiveAccount(currentUser: Observable<UserId>): Observable<UserId | null> {
return combineLatest([this.sortedUserIds$, currentUser]).pipe(
map(([sortedUserIds, currentUserId]) => {
const filtered = sortedUserIds.filter((userId) => userId !== currentUserId);
if (filtered.length > 0) {
return filtered[0];
}
return null;
}),
);
}
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
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 },
);
}
}

View File

@ -12,12 +12,12 @@ import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { UserId } from "../../types/guid";
import { AccountActivityService } from "../abstractions/account-activity.service";
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
AccountServiceImplementation,
} from "./account.service";
@ -64,7 +64,6 @@ describe("accountInfoEqual", () => {
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let accountActivityService: MockProxy<AccountActivityService>;
let globalStateProvider: FakeGlobalStateProvider;
let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
@ -75,15 +74,9 @@ describe("accountService", () => {
beforeEach(() => {
messagingService = mock();
logService = mock();
accountActivityService = mock();
globalStateProvider = new FakeGlobalStateProvider();
sut = new AccountServiceImplementation(
messagingService,
logService,
globalStateProvider,
accountActivityService,
);
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID);
@ -141,12 +134,11 @@ describe("accountService", () => {
});
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(accountActivityService.setAccountActivity).toHaveBeenCalledWith(
userId,
expect.any(Date),
);
expect(state.nextMock).toHaveBeenCalledWith({ userId: expect.any(Date) });
});
});
@ -239,9 +231,12 @@ describe("accountService", () => {
});
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(accountActivityService.removeAccountActivity).toHaveBeenCalledWith(userId);
expect(state.nextMock).toHaveBeenCalledWith({});
});
});
@ -263,4 +258,73 @@ 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", 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([
"user3" as UserId,
"user2" as UserId,
"user1" 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", () => {
it("sets the account activity", async () => {
await sut.setAccountActivity("user1" as UserId, new Date(1));
expect(state.nextMock).toHaveBeenCalledWith({ user1: new Date(1) });
});
it("does not update if the activity is the same", async () => {
state.stateSubject.next({ [toId("user1")]: new Date(1) });
await sut.setAccountActivity("user1" as UserId, new Date(1));
expect(state.nextMock).not.toHaveBeenCalled();
});
});
});
});
function toId(userId: string) {
return userId as UserId;
}

View File

@ -14,7 +14,6 @@ import {
KeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { AccountActivityService } from "../abstractions/account-activity.service";
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_DISK,
@ -44,13 +43,14 @@ export class AccountServiceImplementation implements InternalAccountService {
accounts$;
activeAccount$;
nextUpActiveUsers$;
accountActivity$;
sortedUserIds$;
nextUpAccount$;
constructor(
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider,
private accountActivityService: AccountActivityService,
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
@ -64,15 +64,25 @@ export class AccountServiceImplementation implements InternalAccountService {
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.nextUpActiveUsers$ = combineLatest([
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.accountActivityService.sortedUserIds$,
this.sortedUserIds$,
]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
return sortedUserIds
.filter((id) => id !== activeAccount?.id && accounts[id] != null)
.map((id) => ({ id, ...accounts[id] }));
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
}
@ -83,7 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accounts[userId] = accountData;
return accounts;
});
await this.accountActivityService.setAccountActivity(userId, new Date());
await this.setAccountActivity(userId, new Date());
}
async setAccountName(userId: UserId, name: string): Promise<void> {
@ -100,7 +110,7 @@ export class AccountServiceImplementation implements InternalAccountService {
async clean(userId: UserId) {
await this.setAccountInfo(userId, loggedOutInfo);
await this.accountActivityService.removeAccountActivity(userId);
await this.removeAccountActivity(userId);
}
async switchAccount(userId: UserId): Promise<void> {
@ -126,6 +136,32 @@ export class AccountServiceImplementation implements InternalAccountService {
);
}
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
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 {

View File

@ -78,7 +78,7 @@ export class SystemService implements SystemServiceAbstraction {
);
if (timeoutAction === VaultTimeoutAction.LogOut) {
const nextUser = await firstValueFrom(
this.accountService.nextUpActiveUsers$.pipe(map((accounts) => accounts[0]?.id ?? null)),
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);

View File

@ -4,7 +4,6 @@ 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 { AccountActivityService } from "../../auth/abstractions/account-activity.service";
import { AccountInfo } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
@ -37,7 +36,6 @@ describe("VaultTimeoutService", () => {
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let accountActivityService: MockProxy<AccountActivityService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
@ -62,7 +60,6 @@ describe("VaultTimeoutService", () => {
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
accountActivityService = mock();
lockedCallback = jest.fn();
loggedOutCallback = jest.fn();
@ -87,7 +84,6 @@ describe("VaultTimeoutService", () => {
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
accountActivityService,
lockedCallback,
loggedOutCallback,
);
@ -150,7 +146,7 @@ describe("VaultTimeoutService", () => {
{} as Record<string, AccountInfo>,
),
);
accountActivityService.accountActivity$ = of(
accountService.accountActivity$ = of(
Object.entries(accounts).reduce(
(agg, [id, info]) => {
agg[id] = info.lastActive ? new Date(info.lastActive) : null;

View File

@ -3,7 +3,6 @@ import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service";
import { AccountActivityService } from "../../auth/abstractions/account-activity.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction";
@ -37,7 +36,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
private accountActivityService: AccountActivityService,
private lockedCallback: (userId?: string) => Promise<void> = null,
private loggedOutCallback: (expired: boolean, userId?: string) => Promise<void> = null,
) {}
@ -69,7 +67,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await firstValueFrom(
combineLatest([
this.accountService.activeAccount$,
this.accountActivityService.accountActivity$,
this.accountService.accountActivity$,
]).pipe(
switchMap(async ([activeAccount, accountActivity]) => {
const activeUserId = activeAccount?.id;