Add `AccountActivityService` that handles storing account last active data
This commit is contained in:
parent
d651193d50
commit
16f03dbbcc
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
[
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue