Add `AccountActivityService` that handles storing account last active data

This commit is contained in:
Matt Gibson 2024-04-23 16:24:53 -04:00
parent d651193d50
commit 16f03dbbcc
No known key found for this signature in database
GPG Key ID: 963EE038B0581878
21 changed files with 344 additions and 114 deletions

View File

@ -1,6 +1,7 @@
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 {
@ -67,6 +68,7 @@ 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>;
@ -82,6 +84,7 @@ describe("ContextMenuClickedHandler", () => {
accountService = mockAccountServiceWith("userId" as UserId);
totpService = mock();
eventCollectionService = mock();
accountActivityService = mock();
sut = new ContextMenuClickedHandler(
copyToClipboard,
@ -93,6 +96,7 @@ describe("ContextMenuClickedHandler", () => {
eventCollectionService,
userVerificationService,
accountService,
accountActivityService,
);
});

View File

@ -1,6 +1,7 @@
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";
@ -77,6 +78,7 @@ export class ContextMenuClickedHandler {
private eventCollectionService: EventCollectionService,
private userVerificationService: UserVerificationService,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {}
static async mv3Create(cachedServices: CachedServices) {
@ -134,6 +136,7 @@ export class ContextMenuClickedHandler {
await eventCollectionServiceFactory(cachedServices, serviceOptions),
await userVerificationServiceFactory(cachedServices, serviceOptions),
await accountServiceFactory(cachedServices, serviceOptions),
await accountActivityServiceFactory(cachedServices, serviceOptions),
);
}
@ -244,7 +247,7 @@ export class ContextMenuClickedHandler {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.accountService.setAccountActivity(activeUserId, new Date());
await this.accountActivityService.setAccountActivity(activeUserId, new Date());
switch (info.parentMenuItemId) {
case AUTOFILL_ID:
case AUTOFILL_IDENTITY_ID:

View File

@ -1,4 +1,4 @@
import { Subject, firstValueFrom, merge } from "rxjs";
import { Subject, firstValueFrom, map, merge } from "rxjs";
import {
PinCryptoServiceAbstraction,
@ -1183,7 +1183,11 @@ export default class MainBackground {
*/
async switchAccount(userId: UserId) {
try {
await this.stateService.setActiveUser(userId);
const currentlyActiveAccount = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
// can be removed once password generation history is migrated to state providers
await this.stateService.clearDecryptedData(currentlyActiveAccount);
await this.accountService.switchAccount(userId);
if (userId == null) {

View File

@ -1,6 +1,7 @@
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";
@ -46,6 +47,7 @@ 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) => {
@ -110,7 +112,7 @@ export default class RuntimeBackground {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.accountService.setAccountActivity(activeUserId, new Date());
await this.accountActivityService.setAccountActivity(activeUserId, new Date());
const totpCode = await this.autofillService.doAutoFillActiveTab(
[
{

View File

@ -1,5 +1,4 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -53,7 +52,6 @@ describe("Browser State Service", () => {
state.accounts[userId] = new Account({
profile: { userId: userId },
});
state.activeUserId = userId;
});
afterEach(() => {
@ -82,18 +80,8 @@ describe("Browser State Service", () => {
);
});
describe("add Account", () => {
it("should add account", async () => {
const newUserId = "newUserId" as UserId;
const newAcct = new Account({
profile: { userId: newUserId },
});
await sut.addAccount(newAcct);
const accts = await firstValueFrom(sut.accounts$);
expect(accts[newUserId]).toBeDefined();
});
it("exists", () => {
expect(sut).toBeDefined();
});
});
});

View File

@ -30,8 +30,6 @@ export class DefaultBrowserStateService
initializeAs: "record",
})
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
@sessionSync({ initializer: (s: string) => s })
protected activeAccountSubject: BehaviorSubject<string>;
protected accountDeserializer = Account.fromJSON;

View File

@ -2,6 +2,7 @@ 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";
@ -50,6 +51,7 @@ export class AppComponent implements OnInit, OnDestroy {
private messageListener: MessageListener,
private toastService: ToastService,
private accountService: AccountService,
private accountActivityService: AccountActivityService,
) {}
async ngOnInit() {
@ -209,7 +211,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
this.lastActivity = now;
await this.accountService.setAccountActivity(this.activeUserId, now);
await this.accountActivityService.setAccountActivity(this.activeUserId, now);
}
private showToast(msg: any) {

View File

@ -6,6 +6,7 @@ import { first } from "rxjs/operators";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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";
@ -51,6 +52,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
formBuilder: FormBuilder,
private filePopoutUtilsService: FilePopoutUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) {
super(
i18nService,
@ -66,6 +68,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
dialogService,
formBuilder,
billingAccountProfileStateService,
accountService,
);
}

View File

@ -39,7 +39,6 @@ export class FakeAccountService implements AccountService {
}
accounts$ = this.accountsSubject.asObservable();
activeAccount$ = this.activeAccountSubject.asObservable();
accountActivity$ = this.accountActivitySubject.asObservable();
constructor(initialData: Record<UserId, AccountInfo>, accountActivity?: Record<UserId, Date>) {
this.accountsSubject.next(initialData);

View File

@ -0,0 +1,32 @@
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,7 +30,6 @@ export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
accountActivity$: Observable<Record<UserId, Date>>;
/**
* Updates the `accounts$` observable with the new account data.
*
@ -69,12 +68,6 @@ 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

@ -0,0 +1,179 @@
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

@ -0,0 +1,66 @@
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,6 +64,7 @@ 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>>;
@ -74,9 +75,15 @@ describe("accountService", () => {
beforeEach(() => {
messagingService = mock();
logService = mock();
accountActivityService = mock();
globalStateProvider = new FakeGlobalStateProvider();
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
sut = new AccountServiceImplementation(
messagingService,
logService,
globalStateProvider,
accountActivityService,
);
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID);
@ -134,16 +141,12 @@ describe("accountService", () => {
});
it("sets the last active date of the account to now", async () => {
const emissions = trackEmissions(sut.accountActivity$);
await sut.addAccount(userId, userInfo);
expect(emissions).toEqual([
null, // initial data
{
[userId]: expect.anything(),
},
]);
expect(emissions[1][userId]).toAlmostEqual(new Date(), 100);
expect(accountActivityService.setAccountActivity).toHaveBeenCalledWith(
userId,
expect.any(Date),
);
});
});
@ -236,13 +239,9 @@ 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);
const activityState = await firstValueFrom(state.state$);
expect(activityState).toEqual({});
expect(accountActivityService.removeAccountActivity).toHaveBeenCalledWith(userId);
});
});
@ -264,30 +263,4 @@ describe("accountService", () => {
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
});
});
describe("setAccountActivity", () => {
it("should update the account activity", async () => {
await sut.setAccountActivity(userId, new Date());
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: expect.any(Date) });
});
it("should not update if the activity is the same", async () => {
const date = new Date();
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({ [userId]: date });
await sut.setAccountActivity(userId, date);
expect(state.nextMock).not.toHaveBeenCalled();
});
it("should update accountActivity$ with the new activity", async () => {
await sut.setAccountActivity(userId, new Date());
const currentState = await firstValueFrom(sut.accountActivity$);
expect(currentState[userId]).toBeInstanceOf(Date);
});
});
});

View File

@ -1,4 +1,4 @@
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
import { combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
import {
AccountInfo,
@ -14,6 +14,7 @@ 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,
@ -38,19 +39,17 @@ const loggedOutInfo: AccountInfo = {
};
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$;
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,7 +63,6 @@ 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$;
}
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
@ -73,7 +71,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accounts[userId] = accountData;
return accounts;
});
await this.setAccountActivity(userId, new Date());
await this.accountActivityService.setAccountActivity(userId, new Date());
}
async setAccountName(userId: UserId, name: string): Promise<void> {
@ -90,7 +88,7 @@ export class AccountServiceImplementation implements InternalAccountService {
async clean(userId: UserId) {
await this.setAccountInfo(userId, loggedOutInfo);
await this.removeAccountActivity(userId);
await this.accountActivityService.removeAccountActivity(userId);
}
async switchAccount(userId: UserId): Promise<void> {
@ -116,19 +114,6 @@ export class AccountServiceImplementation implements InternalAccountService {
);
}
async setAccountActivity(userId: UserId, date: Date): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
activity ??= {};
activity[userId] = date;
return activity;
},
{
shouldUpdate: (activity) => activity?.[userId] != date,
},
);
}
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
async delete(): Promise<void> {
try {
@ -161,16 +146,4 @@ export class AccountServiceImplementation implements InternalAccountService {
},
);
}
private async removeAccountActivity(userId: UserId): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
delete activity?.[userId];
return activity;
},
{
shouldUpdate: (activity) => activity?.[userId] != null,
},
);
}
}

View File

@ -29,6 +29,7 @@ export abstract class StateService<T extends Account = Account> {
accounts$: Observable<{ [userId: string]: T }>;
addAccount: (account: T) => Promise<void>;
clearDecryptedData: (userId: UserId) => Promise<void>;
setActiveUser: (userId: string) => Promise<void>;
clean: (options?: StorageOptions) => Promise<UserId>;
init: (initOptions?: InitOptions) => Promise<void>;

View File

@ -9,9 +9,7 @@ export class State<
> {
accounts: { [userId: string]: TAccount } = {};
globals: TGlobalState;
activeUserId: string;
authenticatedAccounts: string[] = [];
accountActivity: { [userId: string]: number } = {};
constructor(globals: TGlobalState) {
this.globals = globals;

View File

@ -35,7 +35,6 @@ const keys = {
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
};
@ -123,11 +122,6 @@ export class StateService<
// 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();
return state;
@ -160,7 +154,6 @@ export class StateService<
return state;
});
await this.scaffoldNewAccountStorage(account);
await this.setActiveUser(account.profile.userId);
}
async setActiveUser(userId: string): Promise<void> {
@ -1176,6 +1169,18 @@ export class StateService<
return Object.assign(this.createAccount(), persistentAccountInformation);
}
async clearDecryptedData(userId: UserId): Promise<void> {
await this.updateState(async (state) => {
if (userId != null && state?.accounts[userId]?.data != null) {
state.accounts[userId].data = new AccountData();
}
return state;
});
await this.pushAccounts();
}
protected async clearDecryptedDataForActiveUser(): Promise<void> {
await this.updateState(async (state) => {
const userId = state?.activeUserId;

View File

@ -78,7 +78,8 @@ export class SystemService implements SystemServiceAbstraction {
);
if (timeoutAction === VaultTimeoutAction.LogOut) {
const nextUser = await this.stateService.nextUpActiveUser();
await this.stateService.setActiveUser(nextUser);
// Can be removed once we migrate password generation history to state providers
await this.stateService.clearDecryptedData(currentUser);
await this.accountService.switchAccount(nextUser);
}
}

View File

@ -4,6 +4,7 @@ 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";
@ -36,6 +37,7 @@ 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]>;
@ -60,6 +62,7 @@ describe("VaultTimeoutService", () => {
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
accountActivityService = mock();
lockedCallback = jest.fn();
loggedOutCallback = jest.fn();
@ -84,6 +87,7 @@ describe("VaultTimeoutService", () => {
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
accountActivityService,
lockedCallback,
loggedOutCallback,
);
@ -146,7 +150,7 @@ describe("VaultTimeoutService", () => {
{} as Record<string, AccountInfo>,
),
);
accountService.accountActivity$ = of(
accountActivityService.accountActivity$ = of(
Object.entries(accounts).reduce(
(agg, [id, info]) => {
agg[id] = info.lastActive ? new Date(info.lastActive) : null;

View File

@ -3,6 +3,7 @@ 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";
@ -36,6 +37,7 @@ 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,
) {}
@ -67,7 +69,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await firstValueFrom(
combineLatest([
this.accountService.activeAccount$,
this.accountService.accountActivity$,
this.accountActivityService.accountActivity$,
]).pipe(
switchMap(async ([activeAccount, accountActivity]) => {
const activeUserId = activeAccount?.id;