1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 00:53:22 +00:00
Files
browser/libs/common/src/auth/services/account.service.ts
Todd Martin 91991d2da6 feat(account): [PM-29545] Update AccountInfo creationDate to use Date instead of string
* Add creationDate of account to AccountInfo

* Added initialization of creationDate.

* Removed extra changes.

* Fixed tests to initialize creation date

* Added helper method to abstract account initialization in tests.

* More test updates.

* Linting

* Additional test fixes.

* Fixed spec reference

* Fixed imports

* Linting.

* Fixed browser test.

* Modified tsconfig to reference spec file.

* Fixed import.

* Removed dependency on os.  This is necessary so that the @bitwarden/common/spec lib package can be referenced in tests without node.

* Revert "Removed dependency on os.  This is necessary so that the @bitwarden/common/spec lib package can be referenced in tests without node."

This reverts commit 669f6557b6.

* Updated stories to hard-code new field.

* Removed changes to tsconfig

* Revert "Removed changes to tsconfig"

This reverts commit b7d916e8dc.

* Updated to use Date

* Updated to use Date on sync.

* Changes to tests that can't use mock function

* Prettier updates

* Update equality to handle Date type.

* Change to type comparison.

* Simplified equality comparison to just use properties.

* Added comment.

* Updated comment to reference Date.

* Added back in internal method tests.
2025-12-26 14:42:19 -05:00

319 lines
9.6 KiB
TypeScript

// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
combineLatestWith,
map,
distinctUntilChanged,
shareReplay,
combineLatest,
BehaviorSubject,
Observable,
switchMap,
filter,
timeout,
of,
} from "rxjs";
import {
Account,
AccountInfo,
InternalAccountService,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import {
ACCOUNT_DISK,
GlobalState,
GlobalStateProvider,
KeyDefinition,
SingleUserStateProvider,
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_DISK,
"accounts",
{
deserializer: (accountInfo) => ({
...accountInfo,
creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined,
}),
},
);
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_DISK, "activeAccountId", {
deserializer: (id: UserId) => id,
});
export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK, "activity", {
deserializer: (activity) => new Date(activity),
});
export const ACCOUNT_VERIFY_NEW_DEVICE_LOGIN = new UserKeyDefinition<boolean>(
ACCOUNT_DISK,
"verifyNewDeviceLogin",
{
deserializer: (verifyDevices) => verifyDevices,
clearOn: ["logout"],
},
);
const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
};
/**
* An rxjs map operator that extracts the UserId from an account, or throws if the account or UserId are null.
*/
export const getUserId = map<Account | null, UserId>((account) => {
if (account == null) {
throw new Error("Null or undefined account");
}
return account.id;
});
/**
* An rxjs map operator that extracts the UserId from an account, or returns undefined if the account or UserId are null.
*/
export const getOptionalUserId = map<Account | null, UserId | null>(
(account) => account?.id ?? null,
);
export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
private _showHeader$ = new BehaviorSubject<boolean>(true);
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
accountActivity$: Observable<Record<UserId, Date>>;
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
showHeader$ = this._showHeader$.asObservable();
constructor(
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider,
private singleUserStateProvider: SingleUserStateProvider,
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
this.accounts$ = this.accountsState.state$.pipe(
map((accounts) => (accounts == null ? {} : accounts)),
);
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.accountActivity$ = this.globalStateProvider
.get(ACCOUNT_ACTIVITY)
.state$.pipe(map((activity) => activity ?? {}));
this.sortedUserIds$ = this.accountActivity$.pipe(
map((activity) => {
return Object.entries(activity)
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()) // later dates first
.map((a) => a.userId);
}),
);
this.nextUpAccount$ = combineLatest([
this.accounts$,
this.activeAccount$,
this.sortedUserIds$,
]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
this.accountVerifyNewDeviceLogin$ = this.activeAccountIdState.state$.pipe(
switchMap(
(userId) =>
this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).state$,
),
);
}
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
if (!Utils.isGuid(userId)) {
throw new Error("userId is required");
}
await this.accountsState.update((accounts) => {
accounts ||= {};
accounts[userId] = accountData;
return accounts;
});
await this.setAccountActivity(userId, new Date());
}
async setAccountName(userId: UserId, name: string): Promise<void> {
await this.setAccountInfo(userId, { name });
}
async setAccountEmail(userId: UserId, email: string): Promise<void> {
await this.setAccountInfo(userId, { email });
}
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
await this.setAccountInfo(userId, { emailVerified });
}
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
await this.setAccountInfo(userId, { creationDate });
}
async clean(userId: UserId) {
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
await this.removeAccountActivity(userId);
}
async switchAccount(userId: UserId | null): Promise<void> {
let updateActivity = false;
await this.activeAccountIdState.update(
(_, __) => {
updateActivity = true;
return userId;
},
{
combineLatestWith: this.accountsState.state$.pipe(
filter((accounts) => {
if (userId == null) {
// Don't worry about accounts when we are about to set active user to null
return true;
}
return accounts?.[userId] != null;
}),
// If we don't get the desired account with enough time, just return empty as that will result in the same error
timeout({ first: 1000, with: () => of({} as Record<UserId, AccountInfo>) }),
),
shouldUpdate: (id, accounts) => {
if (userId != null && accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
// update only if userId changes
return id !== userId;
},
},
);
if (updateActivity) {
await this.setAccountActivity(userId, new Date());
}
}
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
if (!Utils.isGuid(userId)) {
// only store for valid userIds
return;
}
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
activity ||= {};
activity[userId] = lastActivity;
return activity;
},
{
shouldUpdate: (oldActivity) => oldActivity?.[userId]?.getTime() !== lastActivity?.getTime(),
},
);
}
async setAccountVerifyNewDeviceLogin(
userId: UserId,
setVerifyNewDeviceLogin: boolean,
): Promise<void> {
if (!Utils.isGuid(userId)) {
// only store for valid userIds
return;
}
await this.singleUserStateProvider
.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN)
.update(() => setVerifyNewDeviceLogin, {
shouldUpdate: (previousValue) => previousValue !== setVerifyNewDeviceLogin,
});
}
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 {
this.messagingService?.send("logout");
} catch (e) {
this.logService.error(e);
throw e;
}
}
async setShowHeader(visible: boolean): Promise<void> {
this._showHeader$.next(visible);
}
private accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
return (
a.email === b.email &&
a.emailVerified === b.emailVerified &&
a.name === b.name &&
a.creationDate?.getTime() === b.creationDate?.getTime()
);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };
}
await this.accountsState.update(
(accounts) => {
accounts[userId] = newAccountInfo(accounts[userId]);
return accounts;
},
{
// Avoid unnecessary updates
// TODO: Faster comparison, maybe include a hash on the objects?
shouldUpdate: (accounts) => {
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
},
},
);
}
}