1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-08-27 23:31:41 +02:00

[PM-6426] Merging main into branch and fixing merge conflicts

This commit is contained in:
Cesar Gonzalez 2024-04-30 10:11:58 -05:00
commit 86d273df46
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
107 changed files with 2523 additions and 947 deletions

View File

@ -30,6 +30,19 @@ const filters = {
safari: ["!build/safari/**/*"], safari: ["!build/safari/**/*"],
}; };
/**
* Converts a number to a tuple containing two Uint16's
* @param num {number} This number is expected to be a integer style number with no decimals
*
* @returns {number[]} A tuple containing two elements that are both numbers.
*/
function numToUint16s(num) {
var arr = new ArrayBuffer(4);
var view = new DataView(arr);
view.setUint32(0, num, false);
return [view.getUint16(0), view.getUint16(2)];
}
function buildString() { function buildString() {
var build = ""; var build = "";
if (process.env.MANIFEST_VERSION) { if (process.env.MANIFEST_VERSION) {
@ -258,8 +271,19 @@ function applyBetaLabels(manifest) {
manifest.short_name = "Bitwarden BETA"; manifest.short_name = "Bitwarden BETA";
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN."; manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
if (process.env.GITHUB_RUN_ID) { if (process.env.GITHUB_RUN_ID) {
manifest.version_name = `${manifest.version} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`; const existingVersionParts = manifest.version.split("."); // 3 parts expected 2024.4.0
manifest.version = `${manifest.version}.${parseInt(process.env.GITHUB_RUN_ID.slice(-4))}`;
// GITHUB_RUN_ID is a number like: 8853654662
// which will convert to [ 4024, 3206 ]
// and a single incremented id of 8853654663 will become [ 4024, 3207 ]
const runIdParts = numToUint16s(parseInt(process.env.GITHUB_RUN_ID));
// Only use the first 2 parts from the given version number and base the other 2 numbers from the GITHUB_RUN_ID
// Example: 2024.4.4024.3206
const betaVersion = `${existingVersionParts[0]}.${existingVersionParts[1]}.${runIdParts[0]}.${runIdParts[1]}`;
manifest.version_name = `${betaVersion} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
manifest.version = betaVersion;
} else { } else {
manifest.version = `${manifest.version}.0`; manifest.version = `${manifest.version}.0`;
} }

View File

@ -49,7 +49,7 @@
<button <button
type="button" type="button"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60" class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
(click)="lock()" (click)="lock(currentAccount.id)"
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock" [disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''" [title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
> >
@ -59,7 +59,7 @@
<button <button
type="button" type="button"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3" class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3"
(click)="logOut()" (click)="logOut(currentAccount.id)"
> >
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i> <i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
{{ "logOut" | i18n }} {{ "logOut" | i18n }}

View File

@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { AccountSwitcherService } from "./services/account-switcher.service"; import { AccountSwitcherService } from "./services/account-switcher.service";
@ -64,9 +65,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
this.location.back(); this.location.back();
} }
async lock(userId?: string) { async lock(userId: string) {
this.loading = true; this.loading = true;
await this.vaultTimeoutService.lock(userId ? userId : null); await this.vaultTimeoutService.lock(userId);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["lock"]); this.router.navigate(["lock"]);
@ -96,7 +97,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
.subscribe(() => this.router.navigate(["lock"])); .subscribe(() => this.router.navigate(["lock"]));
} }
async logOut() { async logOut(userId: UserId) {
this.loading = true; this.loading = true;
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" }, title: { key: "logOut" },
@ -105,7 +106,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
}); });
if (confirmed) { if (confirmed) {
this.messagingService.send("logout"); this.messagingService.send("logout", { userId });
} }
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.

View File

@ -58,6 +58,7 @@ describe("AccountSwitcherService", () => {
const accountInfo: AccountInfo = { const accountInfo: AccountInfo = {
name: "Test User 1", name: "Test User 1",
email: "test1@email.com", email: "test1@email.com",
emailVerified: true,
}; };
avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc"));
@ -89,6 +90,7 @@ describe("AccountSwitcherService", () => {
for (let i = 0; i < numberOfAccounts; i++) { for (let i = 0; i < numberOfAccounts; i++) {
seedAccounts[`${i}` as UserId] = { seedAccounts[`${i}` as UserId] = {
email: `test${i}@email.com`, email: `test${i}@email.com`,
emailVerified: true,
name: "Test User ${i}", name: "Test User ${i}",
}; };
seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked; seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked;
@ -113,6 +115,7 @@ describe("AccountSwitcherService", () => {
const user1AccountInfo: AccountInfo = { const user1AccountInfo: AccountInfo = {
name: "Test User 1", name: "Test User 1",
email: "", email: "",
emailVerified: true,
}; };
accountsSubject.next({ ["1" as UserId]: user1AccountInfo }); accountsSubject.next({ ["1" as UserId]: user1AccountInfo });
authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut }); authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut });

View File

@ -59,7 +59,7 @@ export class LockComponent extends BaseLockComponent {
policyApiService: PolicyApiServiceAbstraction, policyApiService: PolicyApiServiceAbstraction,
policyService: InternalPolicyService, policyService: InternalPolicyService,
passwordStrengthService: PasswordStrengthServiceAbstraction, passwordStrengthService: PasswordStrengthServiceAbstraction,
private authService: AuthService, authService: AuthService,
dialogService: DialogService, dialogService: DialogService,
deviceTrustService: DeviceTrustServiceAbstraction, deviceTrustService: DeviceTrustServiceAbstraction,
userVerificationService: UserVerificationService, userVerificationService: UserVerificationService,
@ -92,6 +92,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService, pinCryptoService,
biometricStateService, biometricStateService,
accountService, accountService,
authService,
kdfConfigService, kdfConfigService,
); );
this.successRoute = "/tabs/current"; this.successRoute = "/tabs/current";

View File

@ -11,7 +11,8 @@ import {
GENERATE_PASSWORD_ID, GENERATE_PASSWORD_ID,
NOOP_COMMAND_SUFFIX, NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants"; } from "@bitwarden/common/autofill/constants";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@ -65,7 +66,7 @@ describe("ContextMenuClickedHandler", () => {
let autofill: AutofillAction; let autofill: AutofillAction;
let authService: MockProxy<AuthService>; let authService: MockProxy<AuthService>;
let cipherService: MockProxy<CipherService>; let cipherService: MockProxy<CipherService>;
let stateService: MockProxy<StateService>; let accountService: FakeAccountService;
let totpService: MockProxy<TotpService>; let totpService: MockProxy<TotpService>;
let eventCollectionService: MockProxy<EventCollectionService>; let eventCollectionService: MockProxy<EventCollectionService>;
let userVerificationService: MockProxy<UserVerificationService>; let userVerificationService: MockProxy<UserVerificationService>;
@ -78,7 +79,7 @@ describe("ContextMenuClickedHandler", () => {
autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>(); autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>();
authService = mock(); authService = mock();
cipherService = mock(); cipherService = mock();
stateService = mock(); accountService = mockAccountServiceWith("userId" as UserId);
totpService = mock(); totpService = mock();
eventCollectionService = mock(); eventCollectionService = mock();
@ -88,10 +89,10 @@ describe("ContextMenuClickedHandler", () => {
autofill, autofill,
authService, authService,
cipherService, cipherService,
stateService,
totpService, totpService,
eventCollectionService, eventCollectionService,
userVerificationService, userVerificationService,
accountService,
); );
}); });

View File

@ -1,4 +1,7 @@
import { firstValueFrom, map } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -17,7 +20,6 @@ import {
NOOP_COMMAND_SUFFIX, NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants"; } from "@bitwarden/common/autofill/constants";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@ -26,6 +28,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory";
import { import {
authServiceFactory, authServiceFactory,
AuthServiceInitOptions, AuthServiceInitOptions,
@ -37,7 +40,6 @@ import { autofillSettingsServiceFactory } from "../../autofill/background/servic
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { CachedServices } from "../../platform/background/service-factories/factory-options";
import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory";
import { taskSchedulerServiceFactory } from "../../platform/background/service-factories/task-scheduler-service.factory"; import { taskSchedulerServiceFactory } from "../../platform/background/service-factories/task-scheduler-service.factory";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory"; import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory";
@ -72,10 +74,10 @@ export class ContextMenuClickedHandler {
private autofillAction: AutofillAction, private autofillAction: AutofillAction,
private authService: AuthService, private authService: AuthService,
private cipherService: CipherService, private cipherService: CipherService,
private stateService: StateService,
private totpService: TotpService, private totpService: TotpService,
private eventCollectionService: EventCollectionService, private eventCollectionService: EventCollectionService,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private accountService: AccountService,
) {} ) {}
static async mv3Create(cachedServices: CachedServices) { static async mv3Create(cachedServices: CachedServices) {
@ -130,10 +132,10 @@ export class ContextMenuClickedHandler {
(tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher), (tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher),
await authServiceFactory(cachedServices, serviceOptions), await authServiceFactory(cachedServices, serviceOptions),
await cipherServiceFactory(cachedServices, serviceOptions), await cipherServiceFactory(cachedServices, serviceOptions),
await stateServiceFactory(cachedServices, serviceOptions),
await totpServiceFactory(cachedServices, serviceOptions), await totpServiceFactory(cachedServices, serviceOptions),
await eventCollectionServiceFactory(cachedServices, serviceOptions), await eventCollectionServiceFactory(cachedServices, serviceOptions),
await userVerificationServiceFactory(cachedServices, serviceOptions), await userVerificationServiceFactory(cachedServices, serviceOptions),
await accountServiceFactory(cachedServices, serviceOptions),
); );
} }
@ -241,9 +243,10 @@ export class ContextMenuClickedHandler {
return; return;
} }
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. const activeUserId = await firstValueFrom(
// eslint-disable-next-line @typescript-eslint/no-floating-promises this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.stateService.setLastActive(new Date().getTime()); );
await this.accountService.setAccountActivity(activeUserId, new Date());
switch (info.parentMenuItemId) { switch (info.parentMenuItemId) {
case AUTOFILL_ID: case AUTOFILL_ID:
case AUTOFILL_IDENTITY_ID: case AUTOFILL_IDENTITY_ID:

View File

@ -1,4 +1,4 @@
import { Subject, firstValueFrom, merge, timeout } from "rxjs"; import { Subject, firstValueFrom, map, merge, timeout } from "rxjs";
import { import {
PinCryptoServiceAbstraction, PinCryptoServiceAbstraction,
@ -114,7 +114,7 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { import {
ActiveUserStateProvider, ActiveUserStateProvider,
@ -337,7 +337,7 @@ export default class MainBackground {
billingAccountProfileStateService: BillingAccountProfileStateService; billingAccountProfileStateService: BillingAccountProfileStateService;
// eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module // eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module
intraprocessMessagingSubject: Subject<Message<object>>; intraprocessMessagingSubject: Subject<Message<object>>;
userKeyInitService: UserKeyInitService; userAutoUnlockKeyService: UserAutoUnlockKeyService;
scriptInjectorService: BrowserScriptInjectorService; scriptInjectorService: BrowserScriptInjectorService;
kdfConfigService: kdfConfigServiceAbstraction; kdfConfigService: kdfConfigServiceAbstraction;
@ -922,6 +922,7 @@ export default class MainBackground {
this.autofillSettingsService, this.autofillSettingsService,
this.vaultTimeoutSettingsService, this.vaultTimeoutSettingsService,
this.biometricStateService, this.biometricStateService,
this.accountService,
this.taskSchedulerService, this.taskSchedulerService,
); );
@ -941,7 +942,6 @@ export default class MainBackground {
this.autofillService, this.autofillService,
this.platformUtilsService as BrowserPlatformUtilsService, this.platformUtilsService as BrowserPlatformUtilsService,
this.notificationsService, this.notificationsService,
this.stateService,
this.autofillSettingsService, this.autofillSettingsService,
this.systemService, this.systemService,
this.environmentService, this.environmentService,
@ -950,6 +950,7 @@ export default class MainBackground {
this.configService, this.configService,
this.fido2Background, this.fido2Background,
messageListener, messageListener,
this.accountService,
); );
this.nativeMessagingBackground = new NativeMessagingBackground( this.nativeMessagingBackground = new NativeMessagingBackground(
this.accountService, this.accountService,
@ -1039,10 +1040,10 @@ export default class MainBackground {
}, },
this.authService, this.authService,
this.cipherService, this.cipherService,
this.stateService,
this.totpService, this.totpService,
this.eventCollectionService, this.eventCollectionService,
this.userVerificationService, this.userVerificationService,
this.accountService,
); );
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler); this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
@ -1085,11 +1086,7 @@ export default class MainBackground {
} }
} }
this.userKeyInitService = new UserKeyInitService( this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
this.accountService,
this.cryptoService,
this.logService,
);
} }
async bootstrap() { async bootstrap() {
@ -1100,7 +1097,18 @@ export default class MainBackground {
// This is here instead of in in the InitService b/c we don't plan for // This is here instead of in in the InitService b/c we don't plan for
// side effects to run in the Browser InitService. // side effects to run in the Browser InitService.
this.userKeyInitService.listenForActiveUserChangesToSetUserKey(); const accounts = await firstValueFrom(this.accountService.accounts$);
const setUserKeyInMemoryPromises = [];
for (const userId of Object.keys(accounts) as UserId[]) {
// For each acct, we must await the process of setting the user key in memory
// if the auto user key is set to avoid race conditions of any code trying to access
// the user key from mem.
setUserKeyInMemoryPromises.push(
this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(userId),
);
}
await Promise.all(setUserKeyInMemoryPromises);
await (this.i18nService as I18nService).init(); await (this.i18nService as I18nService).init();
(this.eventUploadService as EventUploadService).init(true); (this.eventUploadService as EventUploadService).init(true);
@ -1188,7 +1196,12 @@ export default class MainBackground {
*/ */
async switchAccount(userId: UserId) { async switchAccount(userId: UserId) {
try { 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) { if (userId == null) {
this.loginEmailService.setRememberEmail(false); this.loginEmailService.setRememberEmail(false);
@ -1260,7 +1273,11 @@ export default class MainBackground {
//Needs to be checked before state is cleaned //Needs to be checked before state is cleaned
const needStorageReseed = await this.needsStorageReseed(); const needStorageReseed = await this.needsStorageReseed();
const newActiveUser = await this.stateService.clean({ userId: userId }); const newActiveUser = await firstValueFrom(
this.accountService.nextUpAccount$.pipe(map((a) => a?.id)),
);
await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);
await this.stateEventRunnerService.handleEvent("logout", userId); await this.stateEventRunnerService.handleEvent("logout", userId);

View File

@ -1,6 +1,7 @@
import { firstValueFrom, mergeMap } from "rxjs"; import { firstValueFrom, map, mergeMap } from "rxjs";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -19,7 +20,6 @@ import {
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background"; import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
import { AutofillService } from "../autofill/services/abstractions/autofill.service"; import { AutofillService } from "../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background"; import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
@ -37,7 +37,6 @@ export default class RuntimeBackground {
private autofillService: AutofillService, private autofillService: AutofillService,
private platformUtilsService: BrowserPlatformUtilsService, private platformUtilsService: BrowserPlatformUtilsService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private stateService: BrowserStateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private systemService: SystemService, private systemService: SystemService,
private environmentService: BrowserEnvironmentService, private environmentService: BrowserEnvironmentService,
@ -46,6 +45,7 @@ export default class RuntimeBackground {
private configService: ConfigService, private configService: ConfigService,
private fido2Background: Fido2Background, private fido2Background: Fido2Background,
private messageListener: MessageListener, private messageListener: MessageListener,
private accountService: AccountService,
) { ) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor // onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => { chrome.runtime.onInstalled.addListener((details: any) => {
@ -111,9 +111,10 @@ export default class RuntimeBackground {
switch (msg.sender) { switch (msg.sender) {
case "autofiller": case "autofiller":
case "autofill_cmd": { case "autofill_cmd": {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. const activeUserId = await firstValueFrom(
// eslint-disable-next-line @typescript-eslint/no-floating-promises this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.stateService.setLastActive(new Date().getTime()); );
await this.accountService.setAccountActivity(activeUserId, new Date());
const totpCode = await this.autofillService.doAutoFillActiveTab( const totpCode = await this.autofillService.doAutoFillActiveTab(
[ [
{ {

View File

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

View File

@ -6,9 +6,11 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { import {
AvatarModule, AvatarModule,
BadgeModule,
ButtonModule, ButtonModule,
I18nMockService, I18nMockService,
IconButtonModule, IconButtonModule,
ItemModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { PopupFooterComponent } from "./popup-footer.component"; import { PopupFooterComponent } from "./popup-footer.component";
@ -30,23 +32,34 @@ class ExtensionContainerComponent {}
@Component({ @Component({
selector: "vault-placeholder", selector: "vault-placeholder",
template: ` template: `
<div class="tw-mb-8 tw-text-main">vault item</div> <bit-item-group aria-label="Mock Vault Items">
<div class="tw-my-8 tw-text-main">vault item</div> <bit-item *ngFor="let item of data; index as i">
<div class="tw-my-8 tw-text-main">vault item</div> <button bit-item-content>
<div class="tw-my-8 tw-text-main">vault item</div> <i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
<div class="tw-my-8 tw-text-main">vault item</div> {{ i }} of {{ data.length - 1 }}
<div class="tw-my-8 tw-text-main">vault item</div> <span slot="secondary">Bar</span>
<div class="tw-my-8 tw-text-main">vault item</div> </button>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div> <ng-container slot="end">
<div class="tw-my-8 tw-text-main">vault item</div> <bit-item-action>
<div class="tw-my-8 tw-text-main">vault item</div> <button type="button" bitBadge variant="primary">Auto-fill</button>
<div class="tw-my-8 tw-text-main">vault item</div> </bit-item-action>
<div class="tw-my-8 tw-text-main">vault item last item</div> <bit-item-action>
<button type="button" bitIconButton="bwi-clone" aria-label="Copy item"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v" aria-label="More options"></button>
</bit-item-action>
</ng-container>
</bit-item>
</bit-item-group>
`, `,
standalone: true, standalone: true,
imports: [CommonModule, ItemModule, BadgeModule, IconButtonModule],
}) })
class VaultComponent {} class VaultComponent {
protected data = Array.from(Array(20).keys());
}
@Component({ @Component({
selector: "generator-placeholder", selector: "generator-placeholder",

View File

@ -1,5 +1,4 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -50,7 +49,6 @@ describe("Browser State Service", () => {
state.accounts[userId] = new Account({ state.accounts[userId] = new Account({
profile: { userId: userId }, profile: { userId: userId },
}); });
state.activeUserId = userId;
}); });
afterEach(() => { afterEach(() => {
@ -78,18 +76,8 @@ describe("Browser State Service", () => {
); );
}); });
describe("add Account", () => { it("exists", () => {
it("should add account", async () => { expect(sut).toBeDefined();
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();
});
}); });
}); });
}); });

View File

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

View File

@ -200,26 +200,29 @@ export class LocalBackedSessionStorageService
} }
private compareValues<T>(value1: T, value2: T): boolean { 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;
}
return JSON.stringify(value1) === JSON.stringify(value2);
} catch (e) {
this.logService.error(
`error comparing values\n${JSON.stringify(value1)}\n${JSON.stringify(value2)}`,
);
return true; 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

@ -1,12 +1,14 @@
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { filter, concatMap, Subject, takeUntil, firstValueFrom, tap, map } from "rxjs"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { MessageListener } from "@bitwarden/common/platform/messaging"; import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
@ -27,8 +29,9 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
</div>`, </div>`,
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
private lastActivity: number = null; private lastActivity: Date;
private activeUserId: string; private activeUserId: UserId;
private recordActivitySubject = new Subject<void>();
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@ -46,6 +49,7 @@ export class AppComponent implements OnInit, OnDestroy {
private dialogService: DialogService, private dialogService: DialogService,
private messageListener: MessageListener, private messageListener: MessageListener,
private toastService: ToastService, private toastService: ToastService,
private accountService: AccountService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -53,14 +57,13 @@ export class AppComponent implements OnInit, OnDestroy {
// Clear them aggressively to make sure this doesn't occur // Clear them aggressively to make sure this doesn't occur
await this.clearComponentStates(); await this.clearComponentStates();
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => { this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = userId; this.activeUserId = account?.id;
}); });
this.authService.activeAccountStatus$ this.authService.activeAccountStatus$
.pipe( .pipe(
map((status) => status === AuthenticationStatus.Unlocked), filter((status) => status === AuthenticationStatus.Unlocked),
filter((unlocked) => unlocked),
concatMap(async () => { concatMap(async () => {
await this.recordActivity(); await this.recordActivity();
}), }),
@ -200,13 +203,13 @@ export class AppComponent implements OnInit, OnDestroy {
return; return;
} }
const now = new Date().getTime(); const now = new Date();
if (this.lastActivity != null && now - this.lastActivity < 250) { if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return; return;
} }
this.lastActivity = now; this.lastActivity = now;
await this.stateService.setLastActive(now, { userId: this.activeUserId }); await this.accountService.setAccountActivity(this.activeUserId, now);
} }
private showToast(msg: any) { 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 { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -51,6 +52,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
formBuilder: FormBuilder, formBuilder: FormBuilder,
private filePopoutUtilsService: FilePopoutUtilsService, private filePopoutUtilsService: FilePopoutUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) { ) {
super( super(
i18nService, i18nService,
@ -66,6 +68,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
formBuilder, formBuilder,
billingAccountProfileStateService, billingAccountProfileStateService,
accountService,
); );
} }

View File

@ -3,6 +3,7 @@ import * as path from "path";
import { program } from "commander"; import { program } from "commander";
import * as jsdom from "jsdom"; import * as jsdom from "jsdom";
import { firstValueFrom } from "rxjs";
import { import {
InternalUserDecryptionOptionsServiceAbstraction, InternalUserDecryptionOptionsServiceAbstraction,
@ -79,7 +80,7 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { import {
ActiveUserStateProvider, ActiveUserStateProvider,
DerivedStateProvider, DerivedStateProvider,
@ -236,7 +237,7 @@ export class Main {
biometricStateService: BiometricStateService; biometricStateService: BiometricStateService;
billingAccountProfileStateService: BillingAccountProfileStateService; billingAccountProfileStateService: BillingAccountProfileStateService;
providerApiService: ProviderApiServiceAbstraction; providerApiService: ProviderApiServiceAbstraction;
userKeyInitService: UserKeyInitService; userAutoUnlockKeyService: UserAutoUnlockKeyService;
kdfConfigService: KdfConfigServiceAbstraction; kdfConfigService: KdfConfigServiceAbstraction;
constructor() { constructor() {
@ -709,11 +710,7 @@ export class Main {
this.providerApiService = new ProviderApiService(this.apiService); this.providerApiService = new ProviderApiService(this.apiService);
this.userKeyInitService = new UserKeyInitService( this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
this.accountService,
this.cryptoService,
this.logService,
);
} }
async run() { async run() {
@ -734,7 +731,7 @@ export class Main {
this.authService.logOut(() => { this.authService.logOut(() => {
/* Do nothing */ /* Do nothing */
}); });
const userId = await this.stateService.getUserId(); const userId = (await this.stateService.getUserId()) as UserId;
await Promise.all([ await Promise.all([
this.eventUploadService.uploadEvents(userId as UserId), this.eventUploadService.uploadEvents(userId as UserId),
this.syncService.setLastSync(new Date(0)), this.syncService.setLastSync(new Date(0)),
@ -745,9 +742,10 @@ export class Main {
this.passwordGenerationService.clear(), this.passwordGenerationService.clear(),
]); ]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId); await this.stateEventRunnerService.handleEvent("logout", userId);
await this.stateService.clean(); await this.stateService.clean();
await this.accountService.clean(userId);
process.env.BW_SESSION = null; process.env.BW_SESSION = null;
} }
@ -757,7 +755,11 @@ export class Main {
this.containerService.attachToGlobal(global); this.containerService.attachToGlobal(global);
await this.i18nService.init(); await this.i18nService.init();
this.twoFactorService.init(); this.twoFactorService.init();
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount) {
await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id);
}
} }
} }

View File

@ -9,7 +9,7 @@ import {
} from "@bitwarden/angular/auth/guards"; } from "@bitwarden/angular/auth/guards";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { LoginGuard } from "../auth/guards/login.guard"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { HintComponent } from "../auth/hint.component"; import { HintComponent } from "../auth/hint.component";
import { LockComponent } from "../auth/lock.component"; import { LockComponent } from "../auth/lock.component";
import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component";
@ -40,7 +40,7 @@ const routes: Routes = [
{ {
path: "login", path: "login",
component: LoginComponent, component: LoginComponent,
canActivate: [LoginGuard], canActivate: [maxAccountsGuardFn()],
}, },
{ {
path: "login-with-device", path: "login-with-device",

View File

@ -8,7 +8,7 @@ import {
ViewContainerRef, ViewContainerRef,
} from "@angular/core"; } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { firstValueFrom, map, Subject, takeUntil } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -18,9 +18,9 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.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 { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@ -107,11 +107,11 @@ export class AppComponent implements OnInit, OnDestroy {
loading = false; loading = false;
private lastActivity: number = null; private lastActivity: Date = null;
private modal: ModalRef = null; private modal: ModalRef = null;
private idleTimer: number = null; private idleTimer: number = null;
private isIdle = false; private isIdle = false;
private activeUserId: string = null; private activeUserId: UserId = null;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@ -150,12 +150,12 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService, private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService, private providerService: ProviderService,
private organizationService: InternalOrganizationServiceAbstraction, private accountService: AccountService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => { this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = userId; this.activeUserId = account?.id;
}); });
this.ngZone.runOutsideAngular(() => { this.ngZone.runOutsideAngular(() => {
@ -400,7 +400,8 @@ export class AppComponent implements OnInit, OnDestroy {
break; break;
case "switchAccount": { case "switchAccount": {
if (message.userId != null) { if (message.userId != null) {
await this.stateService.setActiveUser(message.userId); await this.stateService.clearDecryptedData(message.userId);
await this.accountService.switchAccount(message.userId);
} }
const locked = const locked =
(await this.authService.getAuthStatus(message.userId)) === (await this.authService.getAuthStatus(message.userId)) ===
@ -522,7 +523,7 @@ export class AppComponent implements OnInit, OnDestroy {
private async updateAppMenu() { private async updateAppMenu() {
let updateRequest: MenuUpdateRequest; let updateRequest: MenuUpdateRequest;
const stateAccounts = await firstValueFrom(this.stateService.accounts$); const stateAccounts = await firstValueFrom(this.accountService.accounts$);
if (stateAccounts == null || Object.keys(stateAccounts).length < 1) { if (stateAccounts == null || Object.keys(stateAccounts).length < 1) {
updateRequest = { updateRequest = {
accounts: null, accounts: null,
@ -531,32 +532,32 @@ export class AppComponent implements OnInit, OnDestroy {
} else { } else {
const accounts: { [userId: string]: MenuAccount } = {}; const accounts: { [userId: string]: MenuAccount } = {};
for (const i in stateAccounts) { for (const i in stateAccounts) {
const userId = i as UserId;
if ( if (
i != null && i != null &&
stateAccounts[i]?.profile?.userId != null && userId != null &&
!this.isAccountCleanUpInProgress(stateAccounts[i].profile.userId) // skip accounts that are being cleaned up !this.isAccountCleanUpInProgress(userId) // skip accounts that are being cleaned up
) { ) {
const userId = stateAccounts[i].profile.userId;
const availableTimeoutActions = await firstValueFrom( const availableTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId), this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
); );
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
accounts[userId] = { accounts[userId] = {
isAuthenticated: await this.stateService.getIsAuthenticated({ isAuthenticated: authStatus >= AuthenticationStatus.Locked,
userId: userId, isLocked: authStatus === AuthenticationStatus.Locked,
}),
isLocked:
(await this.authService.getAuthStatus(userId)) === AuthenticationStatus.Locked,
isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock), isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock),
email: stateAccounts[i].profile.email, email: stateAccounts[userId].email,
userId: stateAccounts[i].profile.userId, userId: userId,
hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId), hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId),
}; };
} }
} }
updateRequest = { updateRequest = {
accounts: accounts, accounts: accounts,
activeUserId: await this.stateService.getUserId(), activeUserId: await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
),
}; };
} }
@ -564,7 +565,9 @@ export class AppComponent implements OnInit, OnDestroy {
} }
private async logOut(expired: boolean, userId?: string) { private async logOut(expired: boolean, userId?: string) {
const userBeingLoggedOut = await this.stateService.getUserId({ userId: userId }); const userBeingLoggedOut =
(userId as UserId) ??
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
// Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted) // Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted)
// doesn't attempt to update a user that is being logged out as we will manually // doesn't attempt to update a user that is being logged out as we will manually
@ -572,9 +575,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.startAccountCleanUp(userBeingLoggedOut); this.startAccountCleanUp(userBeingLoggedOut);
let preLogoutActiveUserId; let preLogoutActiveUserId;
const nextUpAccount = await firstValueFrom(this.accountService.nextUpAccount$);
try { try {
// Provide the userId of the user to upload events for // Provide the userId of the user to upload events for
await this.eventUploadService.uploadEvents(userBeingLoggedOut as UserId); await this.eventUploadService.uploadEvents(userBeingLoggedOut);
await this.syncService.setLastSync(new Date(0), userBeingLoggedOut); await this.syncService.setLastSync(new Date(0), userBeingLoggedOut);
await this.cryptoService.clearKeys(userBeingLoggedOut); await this.cryptoService.clearKeys(userBeingLoggedOut);
await this.cipherService.clear(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut);
@ -582,22 +586,23 @@ export class AppComponent implements OnInit, OnDestroy {
await this.collectionService.clear(userBeingLoggedOut); await this.collectionService.clear(userBeingLoggedOut);
await this.passwordGenerationService.clear(userBeingLoggedOut); await this.passwordGenerationService.clear(userBeingLoggedOut);
await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut); await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut);
await this.biometricStateService.logout(userBeingLoggedOut as UserId); await this.biometricStateService.logout(userBeingLoggedOut);
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut as UserId); await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut);
preLogoutActiveUserId = this.activeUserId; preLogoutActiveUserId = this.activeUserId;
await this.stateService.clean({ userId: userBeingLoggedOut }); await this.stateService.clean({ userId: userBeingLoggedOut });
await this.accountService.clean(userBeingLoggedOut);
} finally { } finally {
this.finishAccountCleanUp(userBeingLoggedOut); this.finishAccountCleanUp(userBeingLoggedOut);
} }
if (this.activeUserId == null) { if (nextUpAccount == null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["login"]); this.router.navigate(["login"]);
} else if (preLogoutActiveUserId !== this.activeUserId) { } else if (preLogoutActiveUserId !== nextUpAccount.id) {
this.messagingService.send("switchAccount"); this.messagingService.send("switchAccount", { userId: nextUpAccount.id });
} }
await this.updateAppMenu(); await this.updateAppMenu();
@ -622,13 +627,13 @@ export class AppComponent implements OnInit, OnDestroy {
return; return;
} }
const now = new Date().getTime(); const now = new Date();
if (this.lastActivity != null && now - this.lastActivity < 250) { if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return; return;
} }
this.lastActivity = now; this.lastActivity = now;
await this.stateService.setLastActive(now, { userId: this.activeUserId }); await this.accountService.setAccountActivity(this.activeUserId, now);
// Idle states // Idle states
if (this.isIdle) { if (this.isIdle) {

View File

@ -1,110 +1,112 @@
<!-- Please remove this disable statement when editing this file! --> <!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type --> <!-- eslint-disable @angular-eslint/template/button-has-type -->
<button <ng-container *ngIf="view$ | async as view">
class="account-switcher" <button
(click)="toggle()" class="account-switcher"
cdkOverlayOrigin (click)="toggle()"
#trigger="cdkOverlayOrigin" cdkOverlayOrigin
[hidden]="!showSwitcher" #trigger="cdkOverlayOrigin"
aria-haspopup="dialog" [hidden]="!view.showSwitcher"
> aria-haspopup="dialog"
<ng-container *ngIf="activeAccount?.email != null; else noActiveAccount">
<app-avatar
[text]="activeAccount.name"
[id]="activeAccount.id"
[color]="activeAccount.avatarColor"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
*ngIf="activeAccount.email != null"
aria-hidden="true"
></app-avatar>
<div class="active-account">
<div>{{ activeAccount.email }}</div>
<span>{{ activeAccount.server }}</span>
<span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span>
</div>
</ng-container>
<ng-template #noActiveAccount>
<span>{{ "switchAccount" | i18n }}</span>
</ng-template>
<i
class="bwi"
aria-hidden="true"
[ngClass]="{ 'bwi-angle-down': !isOpen, 'bwi-angle-up': isOpen }"
></i>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="close()"
(detach)="close()"
[cdkConnectedOverlayOpen]="showSwitcher && isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
cdkConnectedOverlayMinWidth="250px"
>
<div
class="account-switcher-dropdown"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
> >
<div class="accounts" *ngIf="numberOfAccounts > 0"> <ng-container *ngIf="view.activeAccount; else noActiveAccount">
<button <app-avatar
*ngFor="let account of inactiveAccounts | keyvalue" [text]="view.activeAccount.name ?? view.activeAccount.email"
class="account" [id]="view.activeAccount.id"
(click)="switch(account.key)" [color]="view.activeAccount.avatarColor"
> [size]="25"
<app-avatar [circle]="true"
[text]="account.value.name ?? account.value.email" [fontSize]="14"
[id]="account.value.id" [dynamic]="true"
[size]="25" *ngIf="view.activeAccount.email != null"
[circle]="true" aria-hidden="true"
[fontSize]="14" ></app-avatar>
[dynamic]="true" <div class="active-account">
[color]="account.value.avatarColor" <div>{{ view.activeAccount.email }}</div>
*ngIf="account.value.email != null" <span>{{ view.activeAccount.server }}</span>
aria-hidden="true" <span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span>
></app-avatar> </div>
<div class="accountInfo">
<span class="sr-only">{{ "switchAccount" | i18n }}:&nbsp;</span>
<span class="email" aria-hidden="true">{{ account.value.email }}</span>
<span class="server" aria-hidden="true">
<span class="sr-only"> / </span>{{ account.value.server }}
</span>
<span class="status" aria-hidden="true"
><span class="sr-only">&nbsp;(</span
>{{
(account.value.authenticationStatus === authStatus.Unlocked ? "unlocked" : "locked")
| i18n
}}<span class="sr-only">)</span></span
>
</div>
<i
class="bwi bwi-2x text-muted"
[ngClass]="
account.value.authenticationStatus === authStatus.Unlocked ? 'bwi-unlock' : 'bwi-lock'
"
aria-hidden="true"
></i>
</button>
</div>
<ng-container *ngIf="activeAccount?.email != null">
<div class="border" *ngIf="numberOfAccounts > 0"></div>
<ng-container *ngIf="numberOfAccounts < 4">
<button type="button" class="add" (click)="addAccount()">
<i class="bwi bwi-plus" aria-hidden="true"></i> {{ "addAccount" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="numberOfAccounts === 4">
<span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span>
</ng-container>
</ng-container> </ng-container>
</div> <ng-template #noActiveAccount>
</ng-template> <span>{{ "switchAccount" | i18n }}</span>
</ng-template>
<i
class="bwi"
aria-hidden="true"
[ngClass]="{ 'bwi-angle-down': !isOpen, 'bwi-angle-up': isOpen }"
></i>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="close()"
(detach)="close()"
[cdkConnectedOverlayOpen]="view.showSwitcher && isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
cdkConnectedOverlayMinWidth="250px"
>
<div
class="account-switcher-dropdown"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<div class="accounts" *ngIf="view.numberOfAccounts > 0">
<button
*ngFor="let account of view.inactiveAccounts | keyvalue"
class="account"
(click)="switch(account.key)"
>
<app-avatar
[text]="account.value.name ?? account.value.email"
[id]="account.value.id"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
[color]="account.value.avatarColor"
*ngIf="account.value.email != null"
aria-hidden="true"
></app-avatar>
<div class="accountInfo">
<span class="sr-only">{{ "switchAccount" | i18n }}:&nbsp;</span>
<span class="email" aria-hidden="true">{{ account.value.email }}</span>
<span class="server" aria-hidden="true">
<span class="sr-only"> / </span>{{ account.value.server }}
</span>
<span class="status" aria-hidden="true"
><span class="sr-only">&nbsp;(</span
>{{
(account.value.authenticationStatus === authStatus.Unlocked ? "unlocked" : "locked")
| i18n
}}<span class="sr-only">)</span></span
>
</div>
<i
class="bwi bwi-2x text-muted"
[ngClass]="
account.value.authenticationStatus === authStatus.Unlocked ? 'bwi-unlock' : 'bwi-lock'
"
aria-hidden="true"
></i>
</button>
</div>
<ng-container *ngIf="view.activeAccount">
<div class="border" *ngIf="view.numberOfAccounts > 0"></div>
<ng-container *ngIf="view.numberOfAccounts < 4">
<button type="button" class="add" (click)="addAccount()">
<i class="bwi bwi-plus" aria-hidden="true"></i> {{ "addAccount" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="view.numberOfAccounts === 4">
<span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span>
</ng-container>
</ng-container>
</div>
</ng-template>
</ng-container>

View File

@ -1,19 +1,17 @@
import { animate, state, style, transition, trigger } from "@angular/animations"; import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay"; import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
type ActiveAccount = { type ActiveAccount = {
@ -52,12 +50,18 @@ type InactiveAccount = ActiveAccount & {
]), ]),
], ],
}) })
export class AccountSwitcherComponent implements OnInit, OnDestroy { export class AccountSwitcherComponent {
activeAccount?: ActiveAccount; activeAccount$: Observable<ActiveAccount | null>;
inactiveAccounts: { [userId: string]: InactiveAccount } = {}; inactiveAccounts$: Observable<{ [userId: string]: InactiveAccount }>;
authStatus = AuthenticationStatus; authStatus = AuthenticationStatus;
view$: Observable<{
activeAccount: ActiveAccount | null;
inactiveAccounts: { [userId: string]: InactiveAccount };
numberOfAccounts: number;
showSwitcher: boolean;
}>;
isOpen = false; isOpen = false;
overlayPosition: ConnectedPosition[] = [ overlayPosition: ConnectedPosition[] = [
{ {
@ -68,21 +72,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
}, },
]; ];
private destroy$ = new Subject<void>(); showSwitcher$: Observable<boolean>;
get showSwitcher() { numberOfAccounts$: Observable<number>;
const userIsInAVault = !Utils.isNullOrWhitespace(this.activeAccount?.email);
const userIsAddingAnAdditionalAccount = Object.keys(this.inactiveAccounts).length > 0;
return userIsInAVault || userIsAddingAnAdditionalAccount;
}
get numberOfAccounts() {
if (this.inactiveAccounts == null) {
this.isOpen = false;
return 0;
}
return Object.keys(this.inactiveAccounts).length;
}
constructor( constructor(
private stateService: StateService, private stateService: StateService,
@ -90,37 +82,65 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
private avatarService: AvatarService, private avatarService: AvatarService,
private messagingService: MessagingService, private messagingService: MessagingService,
private router: Router, private router: Router,
private tokenService: TokenService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction, private loginEmailService: LoginEmailServiceAbstraction,
) {} private accountService: AccountService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
if (active == null) {
return null;
}
async ngOnInit(): Promise<void> { return {
this.stateService.accounts$ id: active.id,
.pipe( name: active.name,
concatMap(async (accounts: { [userId: string]: Account }) => { email: active.email,
this.inactiveAccounts = await this.createInactiveAccounts(accounts); avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (await this.environmentService.getEnvironment())?.getHostname(),
};
}),
);
this.inactiveAccounts$ = combineLatest([
this.activeAccount$,
this.accountService.accounts$,
this.authService.authStatuses$,
]).pipe(
switchMap(async ([activeAccount, accounts, accountStatuses]) => {
// Filter out logged out accounts and active account
accounts = Object.fromEntries(
Object.entries(accounts).filter(
([id]: [UserId, AccountInfo]) =>
accountStatuses[id] !== AuthenticationStatus.LoggedOut || id === activeAccount?.id,
),
);
return this.createInactiveAccounts(accounts);
}),
);
this.showSwitcher$ = combineLatest([this.activeAccount$, this.inactiveAccounts$]).pipe(
map(([activeAccount, inactiveAccounts]) => {
const hasActiveUser = activeAccount != null;
const userIsAddingAnAdditionalAccount = Object.keys(inactiveAccounts).length > 0;
return hasActiveUser || userIsAddingAnAdditionalAccount;
}),
);
this.numberOfAccounts$ = this.inactiveAccounts$.pipe(
map((accounts) => Object.keys(accounts).length),
);
try { this.view$ = combineLatest([
this.activeAccount = { this.activeAccount$,
id: await this.tokenService.getUserId(), this.inactiveAccounts$,
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()), this.numberOfAccounts$,
email: await this.tokenService.getEmail(), this.showSwitcher$,
avatarColor: await firstValueFrom(this.avatarService.avatarColor$), ]).pipe(
server: (await this.environmentService.getEnvironment())?.getHostname(), map(([activeAccount, inactiveAccounts, numberOfAccounts, showSwitcher]) => ({
}; activeAccount,
} catch { inactiveAccounts,
this.activeAccount = undefined; numberOfAccounts,
} showSwitcher,
}), })),
takeUntil(this.destroy$), );
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
} }
toggle() { toggle() {
@ -144,11 +164,13 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
await this.loginEmailService.saveEmailSettings(); await this.loginEmailService.saveEmailSettings();
await this.router.navigate(["/login"]); await this.router.navigate(["/login"]);
await this.stateService.setActiveUser(null); const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.stateService.clearDecryptedData(activeAccount?.id as UserId);
await this.accountService.switchAccount(null);
} }
private async createInactiveAccounts(baseAccounts: { private async createInactiveAccounts(baseAccounts: {
[userId: string]: Account; [userId: string]: AccountInfo;
}): Promise<{ [userId: string]: InactiveAccount }> { }): Promise<{ [userId: string]: InactiveAccount }> {
const inactiveAccounts: { [userId: string]: InactiveAccount } = {}; const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
@ -159,8 +181,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
inactiveAccounts[userId] = { inactiveAccounts[userId] = {
id: userId, id: userId,
name: baseAccounts[userId].profile.name, name: baseAccounts[userId].name,
email: baseAccounts[userId].profile.email, email: baseAccounts[userId].email,
authenticationStatus: await this.authService.getAuthStatus(userId), authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)), avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
server: (await this.environmentService.getEnvironment(userId))?.getHostname(), server: (await this.environmentService.getEnvironment(userId))?.getHostname(),

View File

@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { UntypedFormControl } from "@angular/forms"; import { UntypedFormControl } from "@angular/forms";
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SearchBarService, SearchBarState } from "./search-bar.service"; import { SearchBarService, SearchBarState } from "./search-bar.service";
@ -18,7 +18,7 @@ export class SearchComponent implements OnInit, OnDestroy {
constructor( constructor(
private searchBarService: SearchBarService, private searchBarService: SearchBarService,
private stateService: StateService, private accountService: AccountService,
) { ) {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.state$.subscribe((state) => { this.searchBarService.state$.subscribe((state) => {
@ -33,7 +33,7 @@ export class SearchComponent implements OnInit, OnDestroy {
ngOnInit() { ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.activeAccountSubscription = this.stateService.activeAccount$.subscribe((value) => { this.activeAccountSubscription = this.accountService.activeAccount$.subscribe((_) => {
this.searchBarService.setSearchText(""); this.searchBarService.setSearchText("");
this.searchText.patchValue(""); this.searchText.patchValue("");
}); });

View File

@ -1,10 +1,12 @@
import { DOCUMENT } from "@angular/common"; import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core"; import { Inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@ -12,9 +14,10 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
@ -36,7 +39,8 @@ export class InitService {
private nativeMessagingService: NativeMessagingService, private nativeMessagingService: NativeMessagingService,
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
private encryptService: EncryptService, private encryptService: EncryptService,
private userKeyInitService: UserKeyInitService, private userAutoUnlockKeyService: UserAutoUnlockKeyService,
private accountService: AccountService,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
) {} ) {}
@ -44,7 +48,18 @@ export class InitService {
return async () => { return async () => {
this.nativeMessagingService.init(); this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
const accounts = await firstValueFrom(this.accountService.accounts$);
const setUserKeyInMemoryPromises = [];
for (const userId of Object.keys(accounts) as UserId[]) {
// For each acct, we must await the process of setting the user key in memory
// if the auto user key is set to avoid race conditions of any code trying to access
// the user key from mem.
setUserKeyInMemoryPromises.push(
this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(userId),
);
}
await Promise.all(setUserKeyInMemoryPromises);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@ -61,7 +61,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { LoginGuard } from "../../auth/guards/login.guard";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@ -104,7 +103,6 @@ const safeProviders: SafeProvider[] = [
safeProvider(InitService), safeProvider(InitService),
safeProvider(NativeMessagingService), safeProvider(NativeMessagingService),
safeProvider(SearchBarService), safeProvider(SearchBarService),
safeProvider(LoginGuard),
safeProvider(DialogService), safeProvider(DialogService),
safeProvider({ safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => void>, provide: APP_INITIALIZER as SafeInjectionToken<() => void>,
@ -194,6 +192,7 @@ const safeProviders: SafeProvider[] = [
AutofillSettingsServiceAbstraction, AutofillSettingsServiceAbstraction,
VaultTimeoutSettingsService, VaultTimeoutSettingsService,
BiometricStateService, BiometricStateService,
AccountServiceAbstraction,
TaskSchedulerService, TaskSchedulerService,
], ],
}), }),

View File

@ -4,6 +4,7 @@ import { ActivatedRoute } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -59,6 +60,10 @@ describe("GeneratorComponent", () => {
provide: CipherService, provide: CipherService,
useValue: mock<CipherService>(), useValue: mock<CipherService>(),
}, },
{
provide: AccountService,
useValue: mock<AccountService>(),
},
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();

View File

@ -4,6 +4,7 @@ import { FormBuilder } from "@angular/forms";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; 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 { 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -34,6 +35,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService: DialogService, dialogService: DialogService,
formBuilder: FormBuilder, formBuilder: FormBuilder,
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) { ) {
super( super(
i18nService, i18nService,
@ -49,6 +51,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
formBuilder, formBuilder,
billingAccountProfileStateService, billingAccountProfileStateService,
accountService,
); );
} }

View File

@ -1,29 +0,0 @@
import { Injectable } from "@angular/core";
import { CanActivate } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
const maxAllowedAccounts = 5;
@Injectable()
export class LoginGuard implements CanActivate {
protected homepage = "vault";
constructor(
private stateService: StateService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
async canActivate() {
const accounts = await firstValueFrom(this.stateService.accounts$);
if (accounts != null && Object.keys(accounts).length >= maxAllowedAccounts) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("accountLimitReached"));
return false;
}
return true;
}
}

View File

@ -0,0 +1,38 @@
import { inject } from "@angular/core";
import { CanActivateFn } from "@angular/router";
import { Observable, map } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
const maxAllowedAccounts = 5;
function maxAccountsGuard(): Observable<boolean> {
const authService = inject(AuthService);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
return authService.authStatuses$.pipe(
map((statuses) =>
Object.values(statuses).filter((status) => status != AuthenticationStatus.LoggedOut),
),
map((accounts) => {
if (accounts != null && Object.keys(accounts).length >= maxAllowedAccounts) {
toastService.showToast({
variant: "error",
title: null,
message: i18nService.t("accountLimitReached"),
});
return false;
}
return true;
}),
);
}
export function maxAccountsGuardFn(): CanActivateFn {
return () => maxAccountsGuard();
}

View File

@ -13,6 +13,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@ -50,7 +51,7 @@ describe("LockComponent", () => {
let component: LockComponent; let component: LockComponent;
let fixture: ComponentFixture<LockComponent>; let fixture: ComponentFixture<LockComponent>;
let stateServiceMock: MockProxy<StateService>; let stateServiceMock: MockProxy<StateService>;
const biometricStateService = mock<BiometricStateService>(); let biometricStateService: MockProxy<BiometricStateService>;
let messagingServiceMock: MockProxy<MessagingService>; let messagingServiceMock: MockProxy<MessagingService>;
let broadcasterServiceMock: MockProxy<BroadcasterService>; let broadcasterServiceMock: MockProxy<BroadcasterService>;
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>; let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
@ -62,7 +63,6 @@ describe("LockComponent", () => {
beforeEach(async () => { beforeEach(async () => {
stateServiceMock = mock<StateService>(); stateServiceMock = mock<StateService>();
stateServiceMock.activeAccount$ = of(null);
messagingServiceMock = mock<MessagingService>(); messagingServiceMock = mock<MessagingService>();
broadcasterServiceMock = mock<BroadcasterService>(); broadcasterServiceMock = mock<BroadcasterService>();
@ -73,6 +73,7 @@ describe("LockComponent", () => {
mockMasterPasswordService = new FakeMasterPasswordService(); mockMasterPasswordService = new FakeMasterPasswordService();
biometricStateService = mock();
biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false); biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false);
biometricStateService.promptAutomatically$ = of(false); biometricStateService.promptAutomatically$ = of(false);
biometricStateService.promptCancelled$ = of(false); biometricStateService.promptCancelled$ = of(false);
@ -165,6 +166,10 @@ describe("LockComponent", () => {
provide: AccountService, provide: AccountService,
useValue: accountService, useValue: accountService,
}, },
{
provide: AuthService,
useValue: mock(),
},
{ {
provide: KdfConfigService, provide: KdfConfigService,
useValue: mock<KdfConfigService>(), useValue: mock<KdfConfigService>(),

View File

@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@ -64,6 +65,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService: PinCryptoServiceAbstraction, pinCryptoService: PinCryptoServiceAbstraction,
biometricStateService: BiometricStateService, biometricStateService: BiometricStateService,
accountService: AccountService, accountService: AccountService,
authService: AuthService,
kdfConfigService: KdfConfigService, kdfConfigService: KdfConfigService,
) { ) {
super( super(
@ -89,6 +91,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService, pinCryptoService,
biometricStateService, biometricStateService,
accountService, accountService,
authService,
kdfConfigService, kdfConfigService,
); );
} }

View File

@ -65,9 +65,10 @@ export class Menubar {
isLocked = updateRequest.accounts[updateRequest.activeUserId]?.isLocked ?? true; isLocked = updateRequest.accounts[updateRequest.activeUserId]?.isLocked ?? true;
} }
const isLockable = !isLocked && updateRequest?.accounts[updateRequest.activeUserId]?.isLockable; const isLockable =
!isLocked && updateRequest?.accounts?.[updateRequest.activeUserId]?.isLockable;
const hasMasterPassword = const hasMasterPassword =
updateRequest?.accounts[updateRequest.activeUserId]?.hasMasterPassword ?? false; updateRequest?.accounts?.[updateRequest.activeUserId]?.hasMasterPassword ?? false;
this.items = [ this.items = [
new FileMenu( new FileMenu(

View File

@ -619,7 +619,7 @@ export class MemberDialogComponent implements OnDestroy {
} }
function mapCollectionToAccessItemView( function mapCollectionToAccessItemView(
collection: CollectionView, collection: CollectionAdminView,
organization: Organization, organization: Organization,
flexibleCollectionsV1Enabled: boolean, flexibleCollectionsV1Enabled: boolean,
accessSelection?: CollectionAccessSelectionView, accessSelection?: CollectionAccessSelectionView,
@ -631,7 +631,8 @@ function mapCollectionToAccessItemView(
labelName: collection.name, labelName: collection.name,
listName: collection.name, listName: collection.name,
readonly: readonly:
group !== undefined || !collection.canEdit(organization, flexibleCollectionsV1Enabled), group !== undefined ||
!collection.canEditUserAccess(organization, flexibleCollectionsV1Enabled),
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined, readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
viaGroupName: group?.name, viaGroupName: group?.name,
}; };

View File

@ -2,7 +2,7 @@ import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router } from "@angular/router"; import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery"; import * as jq from "jquery";
import { Subject, switchMap, takeUntil, timer } from "rxjs"; import { Subject, firstValueFrom, map, switchMap, takeUntil, timer } from "rxjs";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@ -10,6 +10,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.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 { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
@ -51,7 +52,7 @@ const PaymentMethodWarningsRefresh = 60000; // 1 Minute
templateUrl: "app.component.html", templateUrl: "app.component.html",
}) })
export class AppComponent implements OnDestroy, OnInit { export class AppComponent implements OnDestroy, OnInit {
private lastActivity: number = null; private lastActivity: Date = null;
private idleTimer: number = null; private idleTimer: number = null;
private isIdle = false; private isIdle = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@ -86,6 +87,7 @@ export class AppComponent implements OnDestroy, OnInit {
private stateEventRunnerService: StateEventRunnerService, private stateEventRunnerService: StateEventRunnerService,
private paymentMethodWarningService: PaymentMethodWarningService, private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: InternalOrganizationServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
) {} ) {}
ngOnInit() { ngOnInit() {
@ -298,15 +300,16 @@ export class AppComponent implements OnDestroy, OnInit {
} }
private async recordActivity() { private async recordActivity() {
const now = new Date().getTime(); const activeUserId = await firstValueFrom(
if (this.lastActivity != null && now - this.lastActivity < 250) { this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const now = new Date();
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return; return;
} }
this.lastActivity = now; this.lastActivity = now;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.accountService.setAccountActivity(activeUserId, now);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.stateService.setLastActive(now);
// Idle states // Idle states
if (this.isIdle) { if (this.isIdle) {
this.isIdle = false; this.isIdle = false;

View File

@ -42,7 +42,10 @@
: subscription.expirationWithGracePeriod : subscription.expirationWithGracePeriod
) | date: "mediumDate" ) | date: "mediumDate"
}} }}
<div *ngIf="subscription.hasSeparateGracePeriod" class="tw-text-muted"> <div
*ngIf="subscription.hasSeparateGracePeriod && !subscription.isInTrial"
class="tw-text-muted"
>
{{ {{
"selfHostGracePeriodHelp" "selfHostGracePeriodHelp"
| i18n: (subscription.expirationWithGracePeriod | date: "mediumDate") | i18n: (subscription.expirationWithGracePeriod | date: "mediumDate")

View File

@ -1,17 +1,19 @@
import { DOCUMENT } from "@angular/common"; import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core"; import { Inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
@ -28,14 +30,21 @@ export class InitService {
private cryptoService: CryptoServiceAbstraction, private cryptoService: CryptoServiceAbstraction,
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
private encryptService: EncryptService, private encryptService: EncryptService,
private userKeyInitService: UserKeyInitService, private userAutoUnlockKeyService: UserAutoUnlockKeyService,
private accountService: AccountService,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
) {} ) {}
init() { init() {
return async () => { return async () => {
await this.stateService.init(); await this.stateService.init();
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount) {
// If there is an active account, we must await the process of setting the user key in memory
// if the auto user key is set to avoid race conditions of any code trying to access the user key from mem.
await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id);
}
setTimeout(() => this.notificationsService.init(), 3000); setTimeout(() => this.notificationsService.init(), 3000);
await this.vaultTimeoutService.init(true); await this.vaultTimeoutService.init(true);

View File

@ -58,7 +58,7 @@
[bitMenuTriggerFor]="accountMenu" [bitMenuTriggerFor]="accountMenu"
class="tw-border-0 tw-bg-transparent tw-p-0" class="tw-border-0 tw-bg-transparent tw-p-0"
> >
<dynamic-avatar [id]="account.userId" [text]="account | userName"></dynamic-avatar> <dynamic-avatar [id]="account.id" [text]="account | userName"></dynamic-avatar>
</button> </button>
<bit-menu #accountMenu> <bit-menu #accountMenu>
@ -67,7 +67,7 @@
class="tw-flex tw-items-center tw-px-4 tw-py-1 tw-leading-tight tw-text-info" class="tw-flex tw-items-center tw-px-4 tw-py-1 tw-leading-tight tw-text-info"
appStopProp appStopProp
> >
<dynamic-avatar [id]="account.userId" [text]="account | userName"></dynamic-avatar> <dynamic-avatar [id]="account.id" [text]="account | userName"></dynamic-avatar>
<div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap"> <div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap">
<span>{{ "loggedInAs" | i18n }}</span> <span>{{ "loggedInAs" | i18n }}</span>
<small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted"> <small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted">

View File

@ -1,16 +1,17 @@
import { Component, Input } from "@angular/core"; import { Component, Input } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { combineLatest, map, Observable } from "rxjs"; import { map, Observable } from "rxjs";
import { User } from "@bitwarden/angular/pipes/user-name.pipe";
import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service"; import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserId } from "@bitwarden/common/types/guid";
import { AccountProfile } from "@bitwarden/common/platform/models/domain/account";
@Component({ @Component({
selector: "app-header", selector: "app-header",
@ -28,7 +29,7 @@ export class WebHeaderComponent {
@Input() icon: string; @Input() icon: string;
protected routeData$: Observable<{ titleId: string }>; protected routeData$: Observable<{ titleId: string }>;
protected account$: Observable<AccountProfile>; protected account$: Observable<User & { id: UserId }>;
protected canLock$: Observable<boolean>; protected canLock$: Observable<boolean>;
protected selfHosted: boolean; protected selfHosted: boolean;
protected hostname = location.hostname; protected hostname = location.hostname;
@ -38,12 +39,12 @@ export class WebHeaderComponent {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private stateService: StateService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private messagingService: MessagingService, private messagingService: MessagingService,
protected unassignedItemsBannerService: UnassignedItemsBannerService, protected unassignedItemsBannerService: UnassignedItemsBannerService,
private configService: ConfigService, private configService: ConfigService,
private accountService: AccountService,
) { ) {
this.routeData$ = this.route.data.pipe( this.routeData$ = this.route.data.pipe(
map((params) => { map((params) => {
@ -55,14 +56,7 @@ export class WebHeaderComponent {
this.selfHosted = this.platformUtilsService.isSelfHost(); this.selfHosted = this.platformUtilsService.isSelfHost();
this.account$ = combineLatest([ this.account$ = this.accountService.activeAccount$;
this.stateService.activeAccount$,
this.stateService.accounts$,
]).pipe(
map(([activeAccount, accounts]) => {
return accounts[activeAccount]?.profile;
}),
);
this.canLock$ = this.vaultTimeoutSettingsService this.canLock$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$() .availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));

View File

@ -5,6 +5,7 @@ import { FormBuilder } from "@angular/forms";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; 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 { 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -40,6 +41,7 @@ export class AddEditComponent extends BaseAddEditComponent {
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
protected dialogRef: DialogRef, protected dialogRef: DialogRef,
@Inject(DIALOG_DATA) params: { sendId: string }, @Inject(DIALOG_DATA) params: { sendId: string },
accountService: AccountService,
) { ) {
super( super(
i18nService, i18nService,
@ -55,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
formBuilder, formBuilder,
billingAccountProfileStateService, billingAccountProfileStateService,
accountService,
); );
this.sendId = params.sendId; this.sendId = params.sendId;

View File

@ -55,7 +55,6 @@ export default {
{ {
provide: StateService, provide: StateService,
useValue: { useValue: {
activeAccount$: new BehaviorSubject("1").asObservable(),
accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(), accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(),
async getShowFavicon() { async getShowFavicon() {
return true; return true;

View File

@ -31,6 +31,9 @@ export class CollectionAdminView extends CollectionView {
this.assigned = response.assigned; this.assigned = response.assigned;
} }
/**
* Whether the current user can edit the collection, including user and group access
*/
override canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { override canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return org?.flexibleCollections return org?.flexibleCollections
? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage ? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage
@ -43,4 +46,11 @@ export class CollectionAdminView extends CollectionView {
? org?.canDeleteAnyCollection || (!org?.limitCollectionCreationDeletion && this.manage) ? org?.canDeleteAnyCollection || (!org?.limitCollectionCreationDeletion && this.manage)
: org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned); : org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned);
} }
/**
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.canManageUsers;
}
} }

View File

@ -1,4 +1,4 @@
<form [formGroup]="formGroup" [bitSubmit]="submit"> <form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5"> <div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading"> <p class="tw-mt-8" *ngIf="!loading">
{{ "projectPeopleDescription" | i18n }} {{ "projectPeopleDescription" | i18n }}
@ -19,3 +19,9 @@
</button> </button>
</div> </div>
</form> </form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, Subject, switchMap, takeUntil, catchError, EMPTY } from "rxjs"; import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -37,11 +37,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
return convertToAccessPolicyItemViews(policies); return convertToAccessPolicyItemViews(policies);
}), }),
), ),
catchError(() => { catchError(async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.router.navigate(["/sm", this.organizationId, "projects"]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises return [];
this.router.navigate(["/sm", this.organizationId, "projects"]);
return EMPTY;
}), }),
); );
@ -99,17 +97,20 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) { if (this.formGroup.invalid) {
return; return;
} }
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
const showAccessRemovalWarning = const showAccessRemovalWarning =
await this.accessPolicySelectorService.showAccessRemovalWarning( await this.accessPolicySelectorService.showAccessRemovalWarning(
this.organizationId, this.organizationId,
this.formGroup.value.accessPolicies, formValues,
); );
if (showAccessRemovalWarning) { if (showAccessRemovalWarning) {
const confirmed = await this.showWarning(); const confirmed = await this.showWarning();
if (!confirmed) { if (!confirmed) {
this.setSelected(this.currentAccessPolicies); this.setSelected(this.currentAccessPolicies);
this.formGroup.enable();
return; return;
} }
} }
@ -117,7 +118,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
try { try {
const projectPeopleView = convertToProjectPeopleAccessPoliciesView( const projectPeopleView = convertToProjectPeopleAccessPoliciesView(
this.projectId, this.projectId,
this.formGroup.value.accessPolicies, formValues,
); );
const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies( const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies(
this.projectId, this.projectId,
@ -126,9 +127,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews); this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
if (showAccessRemovalWarning) { if (showAccessRemovalWarning) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.router.navigate(["sm", this.organizationId, "projects"]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["sm", this.organizationId, "projects"]);
} }
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"success", "success",
@ -139,6 +138,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
this.validationService.showError(e); this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies); this.setSelected(this.currentAccessPolicies);
} }
this.formGroup.enable();
}; };
private setSelected(policiesToSelect: ApItemViewType[]) { private setSelected(policiesToSelect: ApItemViewType[]) {

View File

@ -1,4 +1,4 @@
<form [formGroup]="formGroup" [bitSubmit]="submit"> <form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5"> <div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading"> <p class="tw-mt-8" *ngIf="!loading">
{{ "machineAccountPeopleDescription" | i18n }} {{ "machineAccountPeopleDescription" | i18n }}
@ -20,3 +20,9 @@
</button> </button>
</div> </div>
</form> </form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { catchError, combineLatest, EMPTY, Subject, switchMap, takeUntil } from "rxjs"; import { combineLatest, Subject, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -40,12 +40,6 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
return convertToAccessPolicyItemViews(policies); return convertToAccessPolicyItemViews(policies);
}), }),
), ),
catchError(() => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/sm", this.organizationId, "machine-accounts"]);
return EMPTY;
}),
); );
private potentialGrantees$ = combineLatest([this.route.params]).pipe( private potentialGrantees$ = combineLatest([this.route.params]).pipe(
@ -101,29 +95,32 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
if (this.isFormInvalid()) { if (this.isFormInvalid()) {
return; return;
} }
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
const showAccessRemovalWarning = const showAccessRemovalWarning =
await this.accessPolicySelectorService.showAccessRemovalWarning( await this.accessPolicySelectorService.showAccessRemovalWarning(
this.organizationId, this.organizationId,
this.formGroup.value.accessPolicies, formValues,
); );
if ( if (
await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies) await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies)
) { ) {
this.formGroup.enable();
return; return;
} }
try { try {
const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies( const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies(
this.serviceAccountId, this.serviceAccountId,
this.formGroup.value.accessPolicies, formValues,
); );
await this.handleAccessTokenAvailableWarning( await this.handleAccessTokenAvailableWarning(
showAccessRemovalWarning, showAccessRemovalWarning,
this.currentAccessPolicies, this.currentAccessPolicies,
this.formGroup.value.accessPolicies, formValues,
); );
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews); this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
@ -137,6 +134,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
this.validationService.showError(e); this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies); this.setSelected(this.currentAccessPolicies);
} }
this.formGroup.enable();
}; };
private setSelected(policiesToSelect: ApItemViewType[]) { private setSelected(policiesToSelect: ApItemViewType[]) {
@ -198,9 +196,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
selectedPolicies: ApItemValueType[], selectedPolicies: ApItemValueType[],
): Promise<void> { ): Promise<void> {
if (showAccessRemovalWarning) { if (showAccessRemovalWarning) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
} else if ( } else if (
this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies) this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies)
) { ) {

View File

@ -55,6 +55,7 @@
bitIconButton="bwi-close" bitIconButton="bwi-close"
buttonType="main" buttonType="main"
size="default" size="default"
[disabled]="disabled"
[attr.title]="'remove' | i18n" [attr.title]="'remove' | i18n"
[attr.aria-label]="'remove' | i18n" [attr.aria-label]="'remove' | i18n"
(click)="selectionList.deselectItem(item.id); handleBlur()" (click)="selectionList.deselectItem(item.id); handleBlur()"
@ -84,7 +85,14 @@
</bit-form-field> </bit-form-field>
<div class="tw-ml-3 tw-mt-7 tw-shrink-0"> <div class="tw-ml-3 tw-mt-7 tw-shrink-0">
<button type="button" bitButton buttonType="secondary" (click)="addButton()"> <button
type="button"
bitButton
buttonType="secondary"
[loading]="loading"
[disabled]="disabled"
(click)="addButton()"
>
{{ "add" | i18n }} {{ "add" | i18n }}
</button> </button>
</div> </div>

View File

@ -1,7 +1,7 @@
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs"; import { firstValueFrom, Subject } from "rxjs";
import { concatMap, take, takeUntil } from "rxjs/operators"; import { concatMap, map, take, takeUntil } from "rxjs/operators";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common"; import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -11,10 +11,12 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
@ -30,6 +32,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key"; import { UserKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -46,6 +49,7 @@ export class LockComponent implements OnInit, OnDestroy {
supportsBiometric: boolean; supportsBiometric: boolean;
biometricLock: boolean; biometricLock: boolean;
private activeUserId: UserId;
protected successRoute = "vault"; protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password"; protected forcePasswordResetRoute = "update-temp-password";
protected onSuccessfulSubmit: () => Promise<void>; protected onSuccessfulSubmit: () => Promise<void>;
@ -80,14 +84,16 @@ export class LockComponent implements OnInit, OnDestroy {
protected pinCryptoService: PinCryptoServiceAbstraction, protected pinCryptoService: PinCryptoServiceAbstraction,
protected biometricStateService: BiometricStateService, protected biometricStateService: BiometricStateService,
protected accountService: AccountService, protected accountService: AccountService,
protected authService: AuthService,
protected kdfConfigService: KdfConfigService, protected kdfConfigService: KdfConfigService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
this.stateService.activeAccount$ this.accountService.activeAccount$
.pipe( .pipe(
concatMap(async () => { concatMap(async (account) => {
await this.load(); this.activeUserId = account?.id;
await this.load(account?.id);
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
@ -116,7 +122,7 @@ export class LockComponent implements OnInit, OnDestroy {
}); });
if (confirmed) { if (confirmed) {
this.messagingService.send("logout"); this.messagingService.send("logout", { userId: this.activeUserId });
} }
} }
@ -321,23 +327,35 @@ export class LockComponent implements OnInit, OnDestroy {
} }
} }
private async load() { private async load(userId: UserId) {
// TODO: Investigate PM-3515 // TODO: Investigate PM-3515
// The loading of the lock component works as follows: // The loading of the lock component works as follows:
// 1. First, is locking a valid timeout action? If not, we will log the user out. // 1. If the user is unlocked, we're here in error so we navigate to the home page
// 2. If locking IS a valid timeout action, we proceed to show the user the lock screen. // 2. First, is locking a valid timeout action? If not, we will log the user out.
// 3. If locking IS a valid timeout action, we proceed to show the user the lock screen.
// The user will be able to unlock as follows: // The user will be able to unlock as follows:
// - If they have a PIN set, they will be presented with the PIN input // - If they have a PIN set, they will be presented with the PIN input
// - If they have a master password and no PIN, they will be presented with the master password input // - If they have a master password and no PIN, they will be presented with the master password input
// - If they have biometrics enabled, they will be presented with the biometric prompt // - If they have biometrics enabled, they will be presented with the biometric prompt
const isUnlocked = await firstValueFrom(
this.authService
.authStatusFor$(userId)
.pipe(map((status) => status === AuthenticationStatus.Unlocked)),
);
if (isUnlocked) {
// navigate to home
await this.router.navigate(["/"]);
return;
}
const availableVaultTimeoutActions = await firstValueFrom( const availableVaultTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
); );
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock); const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) { if (!supportsLock) {
return await this.vaultTimeoutService.logOut(); return await this.vaultTimeoutService.logOut(userId);
} }
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet(); this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();

View File

@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from "@angular/core"; import { Pipe, PipeTransform } from "@angular/core";
interface User { export interface User {
name?: string; name?: string;
email?: string; email?: string;
} }

View File

@ -53,7 +53,6 @@ import { ProviderApiService } from "@bitwarden/common/admin-console/services/pro
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import { import {
AccountService,
AccountService as AccountServiceAbstraction, AccountService as AccountServiceAbstraction,
InternalAccountService, InternalAccountService,
} from "@bitwarden/common/auth/abstractions/account.service"; } from "@bitwarden/common/auth/abstractions/account.service";
@ -164,7 +163,7 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service"; import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { import {
@ -1138,9 +1137,9 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider], deps: [StateProvider],
}), }),
safeProvider({ safeProvider({
provide: UserKeyInitService, provide: UserAutoUnlockKeyService,
useClass: UserKeyInitService, useClass: UserAutoUnlockKeyService,
deps: [AccountService, CryptoServiceAbstraction, LogService], deps: [CryptoServiceAbstraction],
}), }),
safeProvider({ safeProvider({
provide: ErrorHandler, provide: ErrorHandler,

View File

@ -5,6 +5,7 @@ import { Subject, firstValueFrom, takeUntil, map, BehaviorSubject, concatMap } f
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -118,6 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected dialogService: DialogService, protected dialogService: DialogService,
protected formBuilder: FormBuilder, protected formBuilder: FormBuilder,
protected billingAccountProfileStateService: BillingAccountProfileStateService, protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
) { ) {
this.typeOptions = [ this.typeOptions = [
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
@ -215,7 +217,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
} }
async load() { async load() {
this.emailVerified = await this.stateService.getEmailVerified(); this.emailVerified = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.emailVerified ?? false)),
);
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File; this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
if (this.send == null) { if (this.send == null) {

View File

@ -128,6 +128,7 @@ describe("AuthRequestLoginStrategy", () => {
masterPasswordService.masterKeySubject.next(masterKey); masterPasswordService.masterKeySubject.next(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
tokenService.decodeAccessToken.mockResolvedValue({ sub: mockUserId });
await authRequestLoginStrategy.logIn(credentials); await authRequestLoginStrategy.logIn(credentials);

View File

@ -218,7 +218,7 @@ describe("LoginStrategy", () => {
expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
}); });
it("throws if active account isn't found after being initialized", async () => { it("throws if new account isn't active after being initialized", async () => {
const idTokenResponse = identityTokenResponseFactory(); const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse); apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
@ -228,7 +228,8 @@ describe("LoginStrategy", () => {
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction); stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout); stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
accountService.activeAccountSubject.next(null); accountService.switchAccount = jest.fn(); // block internal switch to new account
accountService.activeAccountSubject.next(null); // simulate no active account
await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow(); await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow();
}); });

View File

@ -169,6 +169,12 @@ export abstract class LoginStrategy {
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId }); const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
const vaultTimeout = await this.stateService.getVaultTimeout({ userId }); const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
await this.accountService.addAccount(userId, {
name: accountInformation.name,
email: accountInformation.email,
emailVerified: accountInformation.email_verified,
});
// set access token and refresh token before account initialization so authN status can be accurate // set access token and refresh token before account initialization so authN status can be accurate
// User id will be derived from the access token. // User id will be derived from the access token.
await this.tokenService.setTokens( await this.tokenService.setTokens(
@ -178,6 +184,8 @@ export abstract class LoginStrategy {
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token. tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
); );
await this.accountService.switchAccount(userId);
await this.stateService.addAccount( await this.stateService.addAccount(
new Account({ new Account({
profile: { profile: {

View File

@ -164,6 +164,7 @@ describe("PasswordLoginStrategy", () => {
masterPasswordService.masterKeySubject.next(masterKey); masterPasswordService.masterKeySubject.next(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
await passwordLoginStrategy.logIn(credentials); await passwordLoginStrategy.logIn(credentials);
@ -199,6 +200,7 @@ describe("PasswordLoginStrategy", () => {
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => { it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false); policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const result = await passwordLoginStrategy.logIn(credentials); const result = await passwordLoginStrategy.logIn(credentials);
@ -213,6 +215,7 @@ describe("PasswordLoginStrategy", () => {
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => { it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false); policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const token2FAResponse = new IdentityTwoFactorResponse({ const token2FAResponse = new IdentityTwoFactorResponse({
TwoFactorProviders: ["0"], TwoFactorProviders: ["0"],

View File

@ -65,6 +65,7 @@ describe("UserDecryptionOptionsService", () => {
await fakeAccountService.addAccount(givenUser, { await fakeAccountService.addAccount(givenUser, {
name: "Test User 1", name: "Test User 1",
email: "test1@email.com", email: "test1@email.com",
emailVerified: false,
}); });
await fakeStateProvider.setUserState( await fakeStateProvider.setUserState(
USER_DECRYPTION_OPTIONS, USER_DECRYPTION_OPTIONS,

View File

@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { ReplaySubject } from "rxjs"; import { ReplaySubject, combineLatest, map } from "rxjs";
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service"; import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid"; import { UserId } from "../src/types/guid";
@ -7,15 +7,20 @@ import { UserId } from "../src/types/guid";
export function mockAccountServiceWith( export function mockAccountServiceWith(
userId: UserId, userId: UserId,
info: Partial<AccountInfo> = {}, info: Partial<AccountInfo> = {},
activity: Record<UserId, Date> = {},
): FakeAccountService { ): FakeAccountService {
const fullInfo: AccountInfo = { const fullInfo: AccountInfo = {
...info, ...info,
...{ ...{
name: "name", name: "name",
email: "email", email: "email",
emailVerified: true,
}, },
}; };
const service = new FakeAccountService({ [userId]: fullInfo });
const fullActivity = { [userId]: new Date(), ...activity };
const service = new FakeAccountService({ [userId]: fullInfo }, fullActivity);
service.activeAccountSubject.next({ id: userId, ...fullInfo }); service.activeAccountSubject.next({ id: userId, ...fullInfo });
return service; return service;
} }
@ -26,17 +31,46 @@ export class FakeAccountService implements AccountService {
accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1); accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class // eslint-disable-next-line rxjs/no-exposed-subjects -- test class
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1); activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
private _activeUserId: UserId; private _activeUserId: UserId;
get activeUserId() { get activeUserId() {
return this._activeUserId; return this._activeUserId;
} }
accounts$ = this.accountsSubject.asObservable(); accounts$ = this.accountsSubject.asObservable();
activeAccount$ = this.activeAccountSubject.asObservable(); activeAccount$ = this.activeAccountSubject.asObservable();
accountActivity$ = this.accountActivitySubject.asObservable();
get sortedUserIds$() {
return this.accountActivity$.pipe(
map((activity) => {
return Object.entries(activity)
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
.sort((a, b) => a.lastActive.getTime() - b.lastActive.getTime())
.map((a) => a.userId);
}),
);
}
get nextUpAccount$() {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
}
constructor(initialData: Record<UserId, AccountInfo>) { constructor(initialData: Record<UserId, AccountInfo>, accountActivity?: Record<UserId, Date>) {
this.accountsSubject.next(initialData); this.accountsSubject.next(initialData);
this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id)); this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id));
this.activeAccountSubject.next(null); 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> { async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
@ -53,10 +87,27 @@ export class FakeAccountService implements AccountService {
await this.mock.setAccountEmail(userId, email); await this.mock.setAccountEmail(userId, email);
} }
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
await this.mock.setAccountEmailVerified(userId, emailVerified);
}
async switchAccount(userId: UserId): Promise<void> { async switchAccount(userId: UserId): Promise<void> {
const next = const next =
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
this.activeAccountSubject.next(next); this.activeAccountSubject.next(next);
await this.mock.switchAccount(userId); await this.mock.switchAccount(userId);
} }
async clean(userId: UserId): Promise<void> {
const current = this.accountsSubject["_buffer"][0] ?? {};
const updated = { ...current, [userId]: loggedOutInfo };
this.accountsSubject.next(updated);
await this.mock.clean(userId);
}
} }
const loggedOutInfo: AccountInfo = {
name: undefined,
email: "",
emailVerified: false,
};

View File

@ -8,18 +8,44 @@ import { UserId } from "../../types/guid";
*/ */
export type AccountInfo = { export type AccountInfo = {
email: string; email: string;
emailVerified: boolean;
name: string | undefined; name: string | undefined;
}; };
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) { export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
return a?.email === b?.email && a?.name === b?.name; if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
for (const key of keys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
} }
export abstract class AccountService { export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>; accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>; activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
/**
* Observable of the last activity time for each account.
*/
accountActivity$: Observable<Record<UserId, Date>>;
/** Account list in order of descending recency */
sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
nextUpAccount$: Observable<{ id: UserId } & AccountInfo>;
/** /**
* Updates the `accounts$` observable with the new account data. * Updates the `accounts$` observable with the new account data.
*
* @note Also sets the last active date of the account to `now`.
* @param userId * @param userId
* @param accountData * @param accountData
*/ */
@ -36,11 +62,30 @@ export abstract class AccountService {
* @param email * @param email
*/ */
abstract setAccountEmail(userId: UserId, email: string): Promise<void>; abstract setAccountEmail(userId: UserId, email: string): Promise<void>;
/**
* updates the `accounts$` observable with the new email verification status for the account.
* @param userId
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/** /**
* Updates the `activeAccount$` observable with the new active account. * Updates the `activeAccount$` observable with the new active account.
* @param userId * @param userId
*/ */
abstract switchAccount(userId: UserId): Promise<void>; abstract switchAccount(userId: UserId): Promise<void>;
/**
* Cleans personal information for the given account from the `accounts$` observable. Does not remove the userId from the observable.
*
* @note Also sets the last active date of the account to `null`.
* @param userId
*/
abstract clean(userId: UserId): Promise<void>;
/**
* Updates the given user's last activity time.
* @param userId
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
} }
export abstract class InternalAccountService extends AccountService { export abstract class InternalAccountService extends AccountService {

View File

@ -1,3 +1,8 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { MockProxy, mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@ -6,15 +11,57 @@ import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
import { trackEmissions } from "../../../spec/utils"; import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service"; import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import { import {
ACCOUNT_ACCOUNTS, ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID, ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
AccountServiceImplementation, AccountServiceImplementation,
} from "./account.service"; } from "./account.service";
describe("accountInfoEqual", () => {
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
it("compares nulls", () => {
expect(accountInfoEqual(null, null)).toBe(true);
expect(accountInfoEqual(null, accountInfo)).toBe(false);
expect(accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares all keys, not just those defined in AccountInfo", () => {
const different = { ...accountInfo, extra: "extra" };
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
});
describe("accountService", () => { describe("accountService", () => {
let messagingService: MockProxy<MessagingService>; let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
@ -22,8 +69,8 @@ describe("accountService", () => {
let sut: AccountServiceImplementation; let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>; let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>; let activeAccountIdState: FakeGlobalState<UserId>;
const userId = "userId" as UserId; const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name" }; const userInfo = { email: "email", name: "name", emailVerified: true };
beforeEach(() => { beforeEach(() => {
messagingService = mock(); messagingService = mock();
@ -86,6 +133,25 @@ describe("accountService", () => {
expect(currentValue).toEqual({ [userId]: userInfo }); expect(currentValue).toEqual({ [userId]: userInfo });
}); });
it("sets the last active date of the account to now", async () => {
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({});
await sut.addAccount(userId, userInfo);
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: expect.any(Date) });
});
it.each([null, undefined, 123, "not a guid"])(
"does not set last active if the userId is not a valid guid",
async (userId) => {
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({});
await expect(sut.addAccount(userId as UserId, userInfo)).rejects.toThrow(
"userId is required",
);
},
);
}); });
describe("setAccountName", () => { describe("setAccountName", () => {
@ -134,6 +200,58 @@ describe("accountService", () => {
}); });
}); });
describe("setAccountEmailVerified", () => {
const initialState = { [userId]: userInfo };
initialState[userId].emailVerified = false;
beforeEach(() => {
accountsState.stateSubject.next(initialState);
});
it("should update the account", async () => {
await sut.setAccountEmailVerified(userId, true);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, emailVerified: true },
});
});
it("should not update if the email is the same", async () => {
await sut.setAccountEmailVerified(userId, false);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
});
describe("clean", () => {
beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo });
});
it("removes account info of the given user", async () => {
await sut.clean(userId);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: {
email: "",
emailVerified: false,
name: undefined,
},
});
});
it("removes account activity of the given user", async () => {
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({ [userId]: new Date() });
await sut.clean(userId);
expect(state.nextMock).toHaveBeenCalledWith({});
});
});
describe("switchAccount", () => { describe("switchAccount", () => {
beforeEach(() => { beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo }); accountsState.stateSubject.next({ [userId]: userInfo });
@ -152,4 +270,83 @@ describe("accountService", () => {
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist"); expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
}); });
}); });
describe("account activity", () => {
let state: FakeGlobalState<Record<UserId, Date>>;
beforeEach(() => {
state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
});
describe("accountActivity$", () => {
it("returns the account activity state", async () => {
state.stateSubject.next({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
});
});
it("returns an empty object when account activity is null", async () => {
state.stateSubject.next(null);
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({});
});
});
describe("sortedUserIds$", () => {
it("returns the sorted user ids by date with most recent first", async () => {
state.stateSubject.next({
[toId("user1")]: new Date(3),
[toId("user2")]: new Date(2),
[toId("user3")]: new Date(1),
});
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([
"user1" as UserId,
"user2" as UserId,
"user3" as UserId,
]);
});
it("returns an empty array when account activity is null", async () => {
state.stateSubject.next(null);
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([]);
});
});
describe("setAccountActivity", () => {
const userId = Utils.newGuid() as UserId;
it("sets the account activity", async () => {
await sut.setAccountActivity(userId, new Date(1));
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: new Date(1) });
});
it("does not update if the activity is the same", async () => {
state.stateSubject.next({ [userId]: new Date(1) });
await sut.setAccountActivity(userId, new Date(1));
expect(state.nextMock).not.toHaveBeenCalled();
});
it.each([null, undefined, 123, "not a guid"])(
"does not set last active if the userId is not a valid guid",
async (userId) => {
await sut.setAccountActivity(userId as UserId, new Date(1));
expect(state.nextMock).not.toHaveBeenCalled();
},
);
});
});
}); });
function toId(userId: string) {
return userId as UserId;
}

View File

@ -1,4 +1,4 @@
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs"; import { combineLatestWith, map, distinctUntilChanged, shareReplay, combineLatest } from "rxjs";
import { import {
AccountInfo, AccountInfo,
@ -7,8 +7,9 @@ import {
} from "../../auth/abstractions/account.service"; } from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { import {
ACCOUNT_MEMORY, ACCOUNT_DISK,
GlobalState, GlobalState,
GlobalStateProvider, GlobalStateProvider,
KeyDefinition, KeyDefinition,
@ -16,25 +17,36 @@ import {
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>( export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_MEMORY, ACCOUNT_DISK,
"accounts", "accounts",
{ {
deserializer: (accountInfo) => accountInfo, deserializer: (accountInfo) => accountInfo,
}, },
); );
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", { export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_DISK, "activeAccountId", {
deserializer: (id: UserId) => id, deserializer: (id: UserId) => id,
}); });
export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK, "activity", {
deserializer: (activity) => new Date(activity),
});
const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
name: undefined,
};
export class AccountServiceImplementation implements InternalAccountService { export class AccountServiceImplementation implements InternalAccountService {
private lock = new Subject<UserId>();
private logout = new Subject<UserId>();
private accountsState: GlobalState<Record<UserId, AccountInfo>>; private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>; private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$; accounts$;
activeAccount$; activeAccount$;
accountActivity$;
sortedUserIds$;
nextUpAccount$;
constructor( constructor(
private messagingService: MessagingService, private messagingService: MessagingService,
@ -53,14 +65,40 @@ export class AccountServiceImplementation implements InternalAccountService {
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)), distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }), shareReplay({ bufferSize: 1, refCount: false }),
); );
this.accountActivity$ = this.globalStateProvider
.get(ACCOUNT_ACTIVITY)
.state$.pipe(map((activity) => activity ?? {}));
this.sortedUserIds$ = this.accountActivity$.pipe(
map((activity) => {
return Object.entries(activity)
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()) // later dates first
.map((a) => a.userId);
}),
);
this.nextUpAccount$ = combineLatest([
this.accounts$,
this.activeAccount$,
this.sortedUserIds$,
]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
} }
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> { async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
if (!Utils.isGuid(userId)) {
throw new Error("userId is required");
}
await this.accountsState.update((accounts) => { await this.accountsState.update((accounts) => {
accounts ||= {}; accounts ||= {};
accounts[userId] = accountData; accounts[userId] = accountData;
return accounts; return accounts;
}); });
await this.setAccountActivity(userId, new Date());
} }
async setAccountName(userId: UserId, name: string): Promise<void> { async setAccountName(userId: UserId, name: string): Promise<void> {
@ -71,6 +109,15 @@ export class AccountServiceImplementation implements InternalAccountService {
await this.setAccountInfo(userId, { email }); await this.setAccountInfo(userId, { email });
} }
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
await this.setAccountInfo(userId, { emailVerified });
}
async clean(userId: UserId) {
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
await this.removeAccountActivity(userId);
}
async switchAccount(userId: UserId): Promise<void> { async switchAccount(userId: UserId): Promise<void> {
await this.activeAccountIdState.update( await this.activeAccountIdState.update(
(_, accounts) => { (_, accounts) => {
@ -94,6 +141,37 @@ export class AccountServiceImplementation implements InternalAccountService {
); );
} }
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
if (!Utils.isGuid(userId)) {
// only store for valid userIds
return;
}
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
activity ||= {};
activity[userId] = lastActivity;
return activity;
},
{
shouldUpdate: (oldActivity) => oldActivity?.[userId]?.getTime() !== lastActivity?.getTime(),
},
);
}
async removeAccountActivity(userId: UserId): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
if (activity == null) {
return activity;
}
delete activity[userId];
return activity;
},
{ shouldUpdate: (oldActivity) => oldActivity?.[userId] != null },
);
}
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow // TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
async delete(): Promise<void> { async delete(): Promise<void> {
try { try {

View File

@ -56,6 +56,7 @@ describe("AuthService", () => {
status: AuthenticationStatus.Unlocked, status: AuthenticationStatus.Unlocked,
id: userId, id: userId,
email: "email", email: "email",
emailVerified: false,
name: "name", name: "name",
}; };
@ -109,6 +110,7 @@ describe("AuthService", () => {
status: AuthenticationStatus.Unlocked, status: AuthenticationStatus.Unlocked,
id: Utils.newGuid() as UserId, id: Utils.newGuid() as UserId,
email: "email2", email: "email2",
emailVerified: false,
name: "name2", name: "name2",
}; };
@ -126,7 +128,11 @@ describe("AuthService", () => {
it("requests auth status for all known users", async () => { it("requests auth status for all known users", async () => {
const userId2 = Utils.newGuid() as UserId; const userId2 = Utils.newGuid() as UserId;
await accountService.addAccount(userId2, { email: "email2", name: "name2" }); await accountService.addAccount(userId2, {
email: "email2",
emailVerified: false,
name: "name2",
});
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked)); const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
sut.authStatusFor$ = mockFn; sut.authStatusFor$ = mockFn;
@ -147,11 +153,14 @@ describe("AuthService", () => {
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
}); });
it("emits LoggedOut when userId is null", async () => { it.each([null, undefined, "not a userId"])(
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual( "emits LoggedOut when userId is invalid (%s)",
AuthenticationStatus.LoggedOut, async () => {
); expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
}); AuthenticationStatus.LoggedOut,
);
},
);
it("emits LoggedOut when there is no access token", async () => { it("emits LoggedOut when there is no access token", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(false)); tokenService.hasAccessToken$.mockReturnValue(of(false));

View File

@ -2,6 +2,7 @@ import {
Observable, Observable,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
firstValueFrom,
map, map,
of, of,
shareReplay, shareReplay,
@ -12,6 +13,7 @@ import { ApiService } from "../../abstractions/api.service";
import { CryptoService } from "../../platform/abstractions/crypto.service"; import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import { StateService } from "../../platform/abstractions/state.service"; import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AccountService } from "../abstractions/account.service"; import { AccountService } from "../abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
@ -39,13 +41,16 @@ export class AuthService implements AuthServiceAbstraction {
this.authStatuses$ = this.accountService.accounts$.pipe( this.authStatuses$ = this.accountService.accounts$.pipe(
map((accounts) => Object.keys(accounts) as UserId[]), map((accounts) => Object.keys(accounts) as UserId[]),
switchMap((entries) => switchMap((entries) => {
combineLatest( if (entries.length === 0) {
return of([] as { userId: UserId; status: AuthenticationStatus }[]);
}
return combineLatest(
entries.map((userId) => entries.map((userId) =>
this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))), this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))),
), ),
), );
), }),
map((statuses) => { map((statuses) => {
return statuses.reduce( return statuses.reduce(
(acc, { userId, status }) => { (acc, { userId, status }) => {
@ -59,7 +64,7 @@ export class AuthService implements AuthServiceAbstraction {
} }
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> { authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
if (userId == null) { if (!Utils.isGuid(userId)) {
return of(AuthenticationStatus.LoggedOut); return of(AuthenticationStatus.LoggedOut);
} }
@ -84,17 +89,8 @@ export class AuthService implements AuthServiceAbstraction {
} }
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> { async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
// If we don't have an access token or userId, we're logged out userId ??= await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
const isAuthenticated = await this.stateService.getIsAuthenticated({ userId: userId }); return await firstValueFrom(this.authStatusFor$(userId as UserId));
if (!isAuthenticated) {
return AuthenticationStatus.LoggedOut;
}
// Note: since we aggresively set the auto user key to memory if it exists on app init (see InitService)
// we only need to check if the user key is in memory.
const hasUserKey = await this.cryptoService.hasUserKeyInMemory(userId as UserId);
return hasUserKey ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked;
} }
logOut(callback: () => void) { logOut(callback: () => void) {

View File

@ -90,6 +90,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
const user1AccountInfo: AccountInfo = { const user1AccountInfo: AccountInfo = {
name: "Test User 1", name: "Test User 1",
email: "test1@email.com", email: "test1@email.com",
emailVerified: true,
}; };
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId })); activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));

View File

@ -58,4 +58,16 @@ export class SelfHostedOrganizationSubscriptionView implements View {
get isExpiredAndOutsideGracePeriod() { get isExpiredAndOutsideGracePeriod() {
return this.hasExpiration && this.expirationWithGracePeriod < new Date(); return this.hasExpiration && this.expirationWithGracePeriod < new Date();
} }
/**
* In the case of a trial, where there is no grace period, the expirationWithGracePeriod and expirationWithoutGracePeriod will
* be exactly the same. This can be used to hide the grace period note.
*/
get isInTrial() {
return (
this.expirationWithGracePeriod &&
this.expirationWithoutGracePeriod &&
this.expirationWithGracePeriod.getTime() === this.expirationWithoutGracePeriod.getTime()
);
}
} }

View File

@ -25,11 +25,10 @@ export type InitOptions = {
export abstract class StateService<T extends Account = Account> { export abstract class StateService<T extends Account = Account> {
accounts$: Observable<{ [userId: string]: T }>; accounts$: Observable<{ [userId: string]: T }>;
activeAccount$: Observable<string>;
addAccount: (account: T) => Promise<void>; addAccount: (account: T) => Promise<void>;
setActiveUser: (userId: string) => Promise<void>; clearDecryptedData: (userId: UserId) => Promise<void>;
clean: (options?: StorageOptions) => Promise<UserId>; clean: (options?: StorageOptions) => Promise<void>;
init: (initOptions?: InitOptions) => Promise<void>; init: (initOptions?: InitOptions) => Promise<void>;
/** /**
@ -122,8 +121,6 @@ export abstract class StateService<T extends Account = Account> {
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>; getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>; setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>; getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>; setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>; getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
@ -147,8 +144,6 @@ export abstract class StateService<T extends Account = Account> {
*/ */
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>; setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>; getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
getLastActive: (options?: StorageOptions) => Promise<number>;
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
getLastSync: (options?: StorageOptions) => Promise<string>; getLastSync: (options?: StorageOptions) => Promise<string>;
setLastSync: (value: string, options?: StorageOptions) => Promise<void>; setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
@ -180,5 +175,4 @@ export abstract class StateService<T extends Account = Account> {
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>; setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>; getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>; setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
nextUpActiveUser: () => Promise<UserId>;
} }

View File

@ -3,6 +3,33 @@ import * as path from "path";
import { Utils } from "./utils"; import { Utils } from "./utils";
describe("Utils Service", () => { describe("Utils Service", () => {
describe("isGuid", () => {
it("is false when null", () => {
expect(Utils.isGuid(null)).toBe(false);
});
it("is false when undefined", () => {
expect(Utils.isGuid(undefined)).toBe(false);
});
it("is false when empty", () => {
expect(Utils.isGuid("")).toBe(false);
});
it("is false when not a string", () => {
expect(Utils.isGuid(123 as any)).toBe(false);
});
it("is false when not a guid", () => {
expect(Utils.isGuid("not a guid")).toBe(false);
});
it("is true when a guid", () => {
// we use a limited guid scope in which all zeroes is invalid
expect(Utils.isGuid("00000000-0000-1000-8000-000000000000")).toBe(true);
});
});
describe("getDomain", () => { describe("getDomain", () => {
it("should fail for invalid urls", () => { it("should fail for invalid urls", () => {
expect(Utils.getDomain(null)).toBeNull(); expect(Utils.getDomain(null)).toBeNull();

View File

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

View File

@ -31,10 +31,12 @@ describe("EnvironmentService", () => {
[testUser]: { [testUser]: {
name: "name", name: "name",
email: "email", email: "email",
emailVerified: false,
}, },
[alternateTestUser]: { [alternateTestUser]: {
name: "name", name: "name",
email: "email", email: "email",
emailVerified: false,
}, },
}); });
stateProvider = new FakeStateProvider(accountService); stateProvider = new FakeStateProvider(accountService);
@ -47,6 +49,7 @@ describe("EnvironmentService", () => {
id: userId, id: userId,
email: "test@example.com", email: "test@example.com",
name: `Test Name ${userId}`, name: `Test Name ${userId}`,
emailVerified: false,
}); });
await awaitAsync(); await awaitAsync();
}; };

View File

@ -1,4 +1,4 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, firstValueFrom, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest"; import { Jsonify, JsonValue } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service"; import { AccountService } from "../../auth/abstractions/account.service";
@ -33,10 +33,7 @@ const keys = {
state: "state", state: "state",
stateVersion: "stateVersion", stateVersion: "stateVersion",
global: "global", 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 tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
accountActivity: "accountActivity",
}; };
const partialKeys = { const partialKeys = {
@ -58,9 +55,6 @@ export class StateService<
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({}); protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
accounts$ = this.accountsSubject.asObservable(); accounts$ = this.accountsSubject.asObservable();
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
activeAccount$ = this.activeAccountSubject.asObservable();
private hasBeenInited = false; private hasBeenInited = false;
protected isRecoveredSession = false; protected isRecoveredSession = false;
@ -112,36 +106,16 @@ export class StateService<
} }
// Get all likely authenticated accounts // Get all likely authenticated accounts
const authenticatedAccounts = ( const authenticatedAccounts = await firstValueFrom(
(await this.storageService.get<string[]>(keys.authenticatedAccounts)) ?? [] this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))),
).filter((account) => account != null); );
await this.updateState(async (state) => { await this.updateState(async (state) => {
for (const i in authenticatedAccounts) { for (const i in authenticatedAccounts) {
state = await this.syncAccountFromDisk(authenticatedAccounts[i]); state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
} }
// After all individual accounts have been added
state.authenticatedAccounts = authenticatedAccounts;
const storedActiveUser = await this.storageService.get<string>(keys.activeUserId);
if (storedActiveUser != null) {
state.activeUserId = storedActiveUser;
}
await this.pushAccounts(); await this.pushAccounts();
this.activeAccountSubject.next(state.activeUserId);
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// account service tracks logged out accounts, but State service does not, so we need to add the active account
// if it's not in the accounts list.
if (state.activeUserId != null && this.accountsSubject.value[state.activeUserId] == null) {
const activeDiskAccount = await this.getAccountFromDisk({ userId: state.activeUserId });
await this.accountService.addAccount(state.activeUserId as UserId, {
name: activeDiskAccount.profile.name,
email: activeDiskAccount.profile.email,
});
}
await this.accountService.switchAccount(state.activeUserId as UserId);
// End TODO
return state; return state;
}); });
@ -161,61 +135,25 @@ export class StateService<
return state; return state;
}); });
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// The determination of state should be handled by the various services that control those values.
await this.accountService.addAccount(userId as UserId, {
name: diskAccount.profile.name,
email: diskAccount.profile.email,
});
return state; return state;
} }
async addAccount(account: TAccount) { async addAccount(account: TAccount) {
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId); await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
await this.updateState(async (state) => { await this.updateState(async (state) => {
state.authenticatedAccounts.push(account.profile.userId);
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
state.accounts[account.profile.userId] = account; state.accounts[account.profile.userId] = account;
return state; return state;
}); });
await this.scaffoldNewAccountStorage(account); await this.scaffoldNewAccountStorage(account);
await this.setLastActive(new Date().getTime(), { userId: account.profile.userId });
// TODO: Temporary update to avoid routing all account status changes through account service for now.
await this.accountService.addAccount(account.profile.userId as UserId, {
name: account.profile.name,
email: account.profile.email,
});
await this.setActiveUser(account.profile.userId);
} }
async setActiveUser(userId: string): Promise<void> { async clean(options?: StorageOptions): Promise<void> {
await this.clearDecryptedDataForActiveUser();
await this.updateState(async (state) => {
state.activeUserId = userId;
await this.storageService.save(keys.activeUserId, userId);
this.activeAccountSubject.next(state.activeUserId);
// TODO: temporary update to avoid routing all account status changes through account service for now.
await this.accountService.switchAccount(userId as UserId);
return state;
});
await this.pushAccounts();
}
async clean(options?: StorageOptions): Promise<UserId> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
await this.deAuthenticateAccount(options.userId); await this.deAuthenticateAccount(options.userId);
let currentUser = (await this.state())?.activeUserId;
if (options.userId === currentUser) {
currentUser = await this.dynamicallySetActiveUser();
}
await this.removeAccountFromDisk(options?.userId); await this.removeAccountFromDisk(options?.userId);
await this.removeAccountFromMemory(options?.userId); await this.removeAccountFromMemory(options?.userId);
await this.pushAccounts(); await this.pushAccounts();
return currentUser as UserId;
} }
/** /**
@ -515,24 +453,6 @@ export class StateService<
); );
} }
async getEmailVerified(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.profile.emailVerified ?? false
);
}
async setEmailVerified(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.emailVerified = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> { async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
return ( return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@ -642,35 +562,6 @@ export class StateService<
); );
} }
async getLastActive(options?: StorageOptions): Promise<number> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
const accountActivity = await this.storageService.get<{ [userId: string]: number }>(
keys.accountActivity,
options,
);
if (accountActivity == null || Object.keys(accountActivity).length < 1) {
return null;
}
return accountActivity[options.userId];
}
async setLastActive(value: number, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
if (options.userId == null) {
return;
}
const accountActivity =
(await this.storageService.get<{ [userId: string]: number }>(
keys.accountActivity,
options,
)) ?? {};
accountActivity[options.userId] = value;
await this.storageService.save(keys.accountActivity, accountActivity, options);
}
async getLastSync(options?: StorageOptions): Promise<string> { async getLastSync(options?: StorageOptions): Promise<string> {
return ( return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
@ -910,24 +801,28 @@ export class StateService<
} }
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> { protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
return await this.state().then(async (state) => { return await this.state().then(async (state) => {
if (state.accounts == null) { if (state.accounts == null) {
return null; return null;
} }
return state.accounts[await this.getUserIdFromMemory(options)]; return state.accounts[userId];
});
}
protected async getUserIdFromMemory(options: StorageOptions): Promise<string> {
return await this.state().then((state) => {
return options?.userId != null
? state.accounts[options.userId]?.profile?.userId
: state.activeUserId;
}); });
} }
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> { protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
if (options?.userId == null && (await this.state())?.activeUserId == null) { const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
if (userId == null) {
return null; return null;
} }
@ -1086,53 +981,76 @@ export class StateService<
} }
protected async defaultInMemoryOptions(): Promise<StorageOptions> { protected async defaultInMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Memory, storageLocation: StorageLocation.Memory,
userId: (await this.state()).activeUserId, userId,
}; };
} }
protected async defaultOnDiskOptions(): Promise<StorageOptions> { protected async defaultOnDiskOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Session, htmlStorageLocation: HtmlStorageLocation.Session,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), userId,
useSecureStorage: false, useSecureStorage: false,
}; };
} }
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> { protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Local, htmlStorageLocation: HtmlStorageLocation.Local,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), userId,
useSecureStorage: false, useSecureStorage: false,
}; };
} }
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> { protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Memory, htmlStorageLocation: HtmlStorageLocation.Memory,
userId: (await this.state())?.activeUserId ?? (await this.getUserId()), userId,
useSecureStorage: false, useSecureStorage: false,
}; };
} }
protected async defaultSecureStorageOptions(): Promise<StorageOptions> { protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
useSecureStorage: true, useSecureStorage: true,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), userId,
}; };
} }
protected async getActiveUserIdFromStorage(): Promise<string> { protected async getActiveUserIdFromStorage(): Promise<string> {
return await this.storageService.get<string>(keys.activeUserId); return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
} }
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> { protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId; userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount( const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
); );
@ -1143,7 +1061,10 @@ export class StateService<
} }
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> { protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId; userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount( const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
); );
@ -1154,7 +1075,10 @@ export class StateService<
} }
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> { protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId; userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.setUserKeyAutoUnlock(null, { userId: userId }); await this.setUserKeyAutoUnlock(null, { userId: userId });
await this.setUserKeyBiometric(null, { userId: userId }); await this.setUserKeyBiometric(null, { userId: userId });
await this.setCryptoMasterKeyAuto(null, { userId: userId }); await this.setCryptoMasterKeyAuto(null, { userId: userId });
@ -1163,8 +1087,11 @@ export class StateService<
} }
protected async removeAccountFromMemory(userId: string = null): Promise<void> { protected async removeAccountFromMemory(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.updateState(async (state) => { await this.updateState(async (state) => {
userId = userId ?? state.activeUserId;
delete state.accounts[userId]; delete state.accounts[userId];
return state; return state;
}); });
@ -1178,15 +1105,16 @@ export class StateService<
return Object.assign(this.createAccount(), persistentAccountInformation); return Object.assign(this.createAccount(), persistentAccountInformation);
} }
protected async clearDecryptedDataForActiveUser(): Promise<void> { async clearDecryptedData(userId: UserId): Promise<void> {
await this.updateState(async (state) => { await this.updateState(async (state) => {
const userId = state?.activeUserId;
if (userId != null && state?.accounts[userId]?.data != null) { if (userId != null && state?.accounts[userId]?.data != null) {
state.accounts[userId].data = new AccountData(); state.accounts[userId].data = new AccountData();
} }
return state; return state;
}); });
await this.pushAccounts();
} }
protected createAccount(init: Partial<TAccount> = null): TAccount { protected createAccount(init: Partial<TAccount> = null): TAccount {
@ -1201,14 +1129,6 @@ export class StateService<
// We must have a manual call to clear tokens as we can't leverage state provider to clean // We must have a manual call to clear tokens as we can't leverage state provider to clean
// up our data as we have secure storage in the mix. // up our data as we have secure storage in the mix.
await this.tokenService.clearTokens(userId as UserId); await this.tokenService.clearTokens(userId as UserId);
await this.setLastActive(null, { userId: userId });
await this.updateState(async (state) => {
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
return state;
});
} }
protected async removeAccountFromDisk(userId: string) { protected async removeAccountFromDisk(userId: string) {
@ -1217,32 +1137,6 @@ export class StateService<
await this.removeAccountFromSecureStorage(userId); await this.removeAccountFromSecureStorage(userId);
} }
async nextUpActiveUser() {
const accounts = (await this.state())?.accounts;
if (accounts == null || Object.keys(accounts).length < 1) {
return null;
}
let newActiveUser;
for (const userId in accounts) {
if (userId == null) {
continue;
}
if (await this.getIsAuthenticated({ userId: userId })) {
newActiveUser = userId;
break;
}
newActiveUser = null;
}
return newActiveUser as UserId;
}
protected async dynamicallySetActiveUser() {
const newActiveUser = await this.nextUpActiveUser();
await this.setActiveUser(newActiveUser);
return newActiveUser;
}
protected async saveSecureStorageKey<T extends JsonValue>( protected async saveSecureStorageKey<T extends JsonValue>(
key: string, key: string,
value: T, value: T,

View File

@ -1,10 +1,12 @@
import { firstValueFrom, timeout } from "rxjs"; import { firstValueFrom, map, timeout } from "rxjs";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { UserId } from "../../types/guid";
import { MessagingService } from "../abstractions/messaging.service"; import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service"; import { StateService } from "../abstractions/state.service";
@ -27,6 +29,7 @@ export class SystemService implements SystemServiceAbstraction {
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private accountService: AccountService,
private taskSchedulerService?: TaskSchedulerService, private taskSchedulerService?: TaskSchedulerService,
) { ) {
void this.taskSchedulerService?.registerTaskHandler( void this.taskSchedulerService?.registerTaskHandler(
@ -36,12 +39,14 @@ export class SystemService implements SystemServiceAbstraction {
} }
async startProcessReload(authService: AuthService): Promise<void> { async startProcessReload(authService: AuthService): Promise<void> {
const accounts = await firstValueFrom(this.stateService.accounts$); const accounts = await firstValueFrom(this.accountService.accounts$);
if (accounts != null) { if (accounts != null) {
const keys = Object.keys(accounts); const keys = Object.keys(accounts);
if (keys.length > 0) { if (keys.length > 0) {
for (const userId of keys) { for (const userId of keys) {
if ((await authService.getAuthStatus(userId)) === AuthenticationStatus.Unlocked) { let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
status = await authService.getAuthStatus(userId);
if (status === AuthenticationStatus.Unlocked) {
return; return;
} }
} }
@ -71,15 +76,24 @@ export class SystemService implements SystemServiceAbstraction {
clearInterval(this.reloadInterval); clearInterval(this.reloadInterval);
this.reloadInterval = null; this.reloadInterval = null;
const currentUser = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500))); const currentUser = await firstValueFrom(
this.accountService.activeAccount$.pipe(
map((a) => a?.id),
timeout(500),
),
);
// Replace current active user if they will be logged out on reload // Replace current active user if they will be logged out on reload
if (currentUser != null) { if (currentUser != null) {
const timeoutAction = await firstValueFrom( const timeoutAction = await firstValueFrom(
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)), this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
); );
if (timeoutAction === VaultTimeoutAction.LogOut) { if (timeoutAction === VaultTimeoutAction.LogOut) {
const nextUser = await this.stateService.nextUpActiveUser(); const nextUser = await firstValueFrom(
await this.stateService.setActiveUser(nextUser); this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)),
);
// Can be removed once we migrate password generation history to state providers
await this.stateService.clearDecryptedData(currentUser);
await this.accountService.switchAccount(nextUser);
} }
} }

View File

@ -0,0 +1,71 @@
import { mock } from "jest-mock-extended";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { UserKey } from "../../types/key";
import { KeySuffixOptions } from "../enums";
import { Utils } from "../misc/utils";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { CryptoService } from "./crypto.service";
import { UserAutoUnlockKeyService } from "./user-auto-unlock-key.service";
describe("UserAutoUnlockKeyService", () => {
let userAutoUnlockKeyService: UserAutoUnlockKeyService;
const mockUserId = Utils.newGuid() as UserId;
const cryptoService = mock<CryptoService>();
beforeEach(() => {
userAutoUnlockKeyService = new UserAutoUnlockKeyService(cryptoService);
});
describe("setUserKeyInMemoryIfAutoUserKeySet", () => {
it("does nothing if the userId is null", async () => {
// Act
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(null);
// Assert
expect(cryptoService.getUserKeyFromStorage).not.toHaveBeenCalled();
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
it("does nothing if the autoUserKey is null", async () => {
// Arrange
const userId = mockUserId;
cryptoService.getUserKeyFromStorage.mockResolvedValue(null);
// Act
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
// Assert
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
KeySuffixOptions.Auto,
userId,
);
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
it("sets the user key in memory if the autoUserKey is not null", async () => {
// Arrange
const userId = mockUserId;
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockAutoUserKey: UserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
cryptoService.getUserKeyFromStorage.mockResolvedValue(mockAutoUserKey);
// Act
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
// Assert
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
KeySuffixOptions.Auto,
userId,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockAutoUserKey, userId);
});
});
});

View File

@ -0,0 +1,36 @@
import { UserId } from "../../types/guid";
import { CryptoService } from "../abstractions/crypto.service";
import { KeySuffixOptions } from "../enums";
// TODO: this is a half measure improvement which allows us to reduce some side effects today (cryptoService.getUserKey setting user key in memory if auto key exists)
// but ideally, in the future, we would be able to put this logic into the cryptoService
// after the vault timeout settings service is transitioned to state provider so that
// the getUserKey logic can simply go to the correct location based on the vault timeout settings
// similar to the TokenService (it would either go to secure storage for the auto user key or memory for the user key)
export class UserAutoUnlockKeyService {
constructor(private cryptoService: CryptoService) {}
/**
* The presence of the user key in memory dictates whether the user's vault is locked or unlocked.
* However, for users that have the auto unlock user key set, we need to set the user key in memory
* on application bootstrap and on active account changes so that the user's vault loads unlocked.
* @param userId - The user id to check for an auto user key.
*/
async setUserKeyInMemoryIfAutoUserKeySet(userId: UserId): Promise<void> {
if (userId == null) {
return;
}
const autoUserKey = await this.cryptoService.getUserKeyFromStorage(
KeySuffixOptions.Auto,
userId,
);
if (autoUserKey == null) {
return;
}
await this.cryptoService.setUserKey(autoUserKey, userId);
}
}

View File

@ -1,162 +0,0 @@
import { mock } from "jest-mock-extended";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { UserKey } from "../../types/key";
import { LogService } from "../abstractions/log.service";
import { KeySuffixOptions } from "../enums";
import { Utils } from "../misc/utils";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { CryptoService } from "./crypto.service";
import { UserKeyInitService } from "./user-key-init.service";
describe("UserKeyInitService", () => {
let userKeyInitService: UserKeyInitService;
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const cryptoService = mock<CryptoService>();
const logService = mock<LogService>();
beforeEach(() => {
userKeyInitService = new UserKeyInitService(accountService, cryptoService, logService);
});
describe("listenForActiveUserChangesToSetUserKey()", () => {
it("calls setUserKeyInMemoryIfAutoUserKeySet if there is an active user", () => {
// Arrange
accountService.activeAccountSubject.next({
id: mockUserId,
name: "name",
email: "email",
});
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet = jest.fn();
// Act
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
// Assert
expect(subscription).not.toBeFalsy();
expect((userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet).toHaveBeenCalledWith(
mockUserId,
);
});
it("calls setUserKeyInMemoryIfAutoUserKeySet if there is an active user and tracks subsequent emissions", () => {
// Arrange
accountService.activeAccountSubject.next({
id: mockUserId,
name: "name",
email: "email",
});
const mockUser2Id = Utils.newGuid() as UserId;
jest
.spyOn(userKeyInitService as any, "setUserKeyInMemoryIfAutoUserKeySet")
.mockImplementation(() => Promise.resolve());
// Act
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
accountService.activeAccountSubject.next({
id: mockUser2Id,
name: "name",
email: "email",
});
// Assert
expect(subscription).not.toBeFalsy();
expect((userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet).toHaveBeenCalledTimes(
2,
);
expect(
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
).toHaveBeenNthCalledWith(1, mockUserId);
expect(
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
).toHaveBeenNthCalledWith(2, mockUser2Id);
subscription.unsubscribe();
});
it("does not call setUserKeyInMemoryIfAutoUserKeySet if there is not an active user", () => {
// Arrange
accountService.activeAccountSubject.next(null);
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet = jest.fn();
// Act
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
// Assert
expect(subscription).not.toBeFalsy();
expect(
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
).not.toHaveBeenCalledWith(mockUserId);
});
});
describe("setUserKeyInMemoryIfAutoUserKeySet", () => {
it("does nothing if the userId is null", async () => {
// Act
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(null);
// Assert
expect(cryptoService.getUserKeyFromStorage).not.toHaveBeenCalled();
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
it("does nothing if the autoUserKey is null", async () => {
// Arrange
const userId = mockUserId;
cryptoService.getUserKeyFromStorage.mockResolvedValue(null);
// Act
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
// Assert
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
KeySuffixOptions.Auto,
userId,
);
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
it("sets the user key in memory if the autoUserKey is not null", async () => {
// Arrange
const userId = mockUserId;
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockAutoUserKey: UserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
cryptoService.getUserKeyFromStorage.mockResolvedValue(mockAutoUserKey);
// Act
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
// Assert
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
KeySuffixOptions.Auto,
userId,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockAutoUserKey, userId);
});
});
});

View File

@ -1,57 +0,0 @@
import { EMPTY, Subscription, catchError, filter, from, switchMap } from "rxjs";
import { AccountService } from "../../auth/abstractions/account.service";
import { UserId } from "../../types/guid";
import { CryptoService } from "../abstractions/crypto.service";
import { LogService } from "../abstractions/log.service";
import { KeySuffixOptions } from "../enums";
// TODO: this is a half measure improvement which allows us to reduce some side effects today (cryptoService.getUserKey setting user key in memory if auto key exists)
// but ideally, in the future, we would be able to put this logic into the cryptoService
// after the vault timeout settings service is transitioned to state provider so that
// the getUserKey logic can simply go to the correct location based on the vault timeout settings
// similar to the TokenService (it would either go to secure storage for the auto user key or memory for the user key)
export class UserKeyInitService {
constructor(
private accountService: AccountService,
private cryptoService: CryptoService,
private logService: LogService,
) {}
// Note: must listen for changes to support account switching
listenForActiveUserChangesToSetUserKey(): Subscription {
return this.accountService.activeAccount$
.pipe(
filter((activeAccount) => activeAccount != null),
switchMap((activeAccount) =>
from(this.setUserKeyInMemoryIfAutoUserKeySet(activeAccount?.id)).pipe(
catchError((err: unknown) => {
this.logService.warning(
`setUserKeyInMemoryIfAutoUserKeySet failed with error: ${err}`,
);
// Returning EMPTY to protect observable chain from cancellation in case of error
return EMPTY;
}),
),
),
)
.subscribe();
}
private async setUserKeyInMemoryIfAutoUserKeySet(userId: UserId) {
if (userId == null) {
return;
}
const autoUserKey = await this.cryptoService.getUserKeyFromStorage(
KeySuffixOptions.Auto,
userId,
);
if (autoUserKey == null) {
return;
}
await this.cryptoService.setUserKey(autoUserKey, userId);
}
}

View File

@ -1,7 +1,6 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { mockAccountServiceWith, trackEmissions } from "../../../../spec"; import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { SingleUserStateProvider } from "../user-state.provider"; import { SingleUserStateProvider } from "../user-state.provider";
@ -14,7 +13,7 @@ describe("DefaultActiveUserStateProvider", () => {
id: userId, id: userId,
name: "name", name: "name",
email: "email", email: "email",
status: AuthenticationStatus.Locked, emailVerified: false,
}; };
const accountService = mockAccountServiceWith(userId, accountInfo); const accountService = mockAccountServiceWith(userId, accountInfo);
let sut: DefaultActiveUserStateProvider; let sut: DefaultActiveUserStateProvider;

View File

@ -82,6 +82,7 @@ describe("DefaultActiveUserState", () => {
activeAccountSubject.next({ activeAccountSubject.next({
id: userId, id: userId,
email: `test${id}@example.com`, email: `test${id}@example.com`,
emailVerified: false,
name: `Test User ${id}`, name: `Test User ${id}`,
}); });
await awaitAsync(); await awaitAsync();

View File

@ -69,7 +69,12 @@ describe("DefaultStateProvider", () => {
userId?: UserId, userId?: UserId,
) => Observable<string>, ) => Observable<string>,
) => { ) => {
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut }; const accountInfo = {
email: "email",
emailVerified: false,
name: "name",
status: AuthenticationStatus.LoggedOut,
};
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", { const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s, deserializer: (s) => s,
}); });
@ -114,7 +119,12 @@ describe("DefaultStateProvider", () => {
); );
describe("getUserState$", () => { describe("getUserState$", () => {
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut }; const accountInfo = {
email: "email",
emailVerified: false,
name: "name",
status: AuthenticationStatus.LoggedOut,
};
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", { const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s, deserializer: (s) => s,
}); });

View File

@ -38,6 +38,7 @@ export const BILLING_DISK = new StateDefinition("billing", "disk");
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk"); export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const ACCOUNT_DISK = new StateDefinition("account", "disk");
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory"); export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");

View File

@ -1,9 +1,10 @@
import { MockProxy, any, mock } from "jest-mock-extended"; import { MockProxy, any, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountInfo } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service"; import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
@ -13,7 +14,6 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service"; import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils"; import { Utils } from "../../platform/misc/utils";
import { Account } from "../../platform/models/domain/account";
import { StateEventRunnerService } from "../../platform/state"; import { StateEventRunnerService } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { CipherService } from "../../vault/abstractions/cipher.service"; import { CipherService } from "../../vault/abstractions/cipher.service";
@ -39,7 +39,6 @@ describe("VaultTimeoutService", () => {
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>; let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>; let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
let accountsSubject: BehaviorSubject<Record<string, Account>>;
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>; let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>; let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
@ -65,10 +64,6 @@ describe("VaultTimeoutService", () => {
lockedCallback = jest.fn(); lockedCallback = jest.fn();
loggedOutCallback = jest.fn(); loggedOutCallback = jest.fn();
accountsSubject = new BehaviorSubject(null);
stateService.accounts$ = accountsSubject;
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock); vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject); vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject);
@ -127,21 +122,39 @@ describe("VaultTimeoutService", () => {
return Promise.resolve(accounts[userId]?.vaultTimeout); return Promise.resolve(accounts[userId]?.vaultTimeout);
}); });
stateService.getLastActive.mockImplementation((options) => {
return Promise.resolve(accounts[options.userId]?.lastActive);
});
stateService.getUserId.mockResolvedValue(globalSetups?.userId); stateService.getUserId.mockResolvedValue(globalSetups?.userId);
stateService.activeAccount$ = new BehaviorSubject<string>(globalSetups?.userId); // Set desired user active and known users on accounts service : note the only thing that matters here is that the ID are set
if (globalSetups?.userId) { if (globalSetups?.userId) {
accountService.activeAccountSubject.next({ accountService.activeAccountSubject.next({
id: globalSetups.userId as UserId, id: globalSetups.userId as UserId,
email: null, email: null,
emailVerified: false,
name: null, name: null,
}); });
} }
accountService.accounts$ = of(
Object.entries(accounts).reduce(
(agg, [id]) => {
agg[id] = {
email: "",
emailVerified: true,
name: "",
};
return agg;
},
{} as Record<string, AccountInfo>,
),
);
accountService.accountActivity$ = of(
Object.entries(accounts).reduce(
(agg, [id, info]) => {
agg[id] = info.lastActive ? new Date(info.lastActive) : null;
return agg;
},
{} as Record<string, Date>,
),
);
platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false); platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false);
@ -158,16 +171,6 @@ describe("VaultTimeoutService", () => {
], ],
); );
}); });
const accountsSubjectValue: Record<string, Account> = Object.keys(accounts).reduce(
(agg, key) => {
const newPartial: Record<string, unknown> = {};
newPartial[key] = null; // No values actually matter on this other than the key
return Object.assign(agg, newPartial);
},
{} as Record<string, Account>,
);
accountsSubject.next(accountsSubjectValue);
}; };
const expectUserToHaveLocked = (userId: string) => { const expectUserToHaveLocked = (userId: string) => {

View File

@ -1,4 +1,4 @@
import { firstValueFrom, timeout } from "rxjs"; import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
@ -64,14 +64,25 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
// Get whether or not the view is open a single time so it can be compared for each user // Get whether or not the view is open a single time so it can be compared for each user
const isViewOpen = await this.platformUtilsService.isViewOpen(); const isViewOpen = await this.platformUtilsService.isViewOpen();
const activeUserId = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500))); await firstValueFrom(
combineLatest([
const accounts = await firstValueFrom(this.stateService.accounts$); this.accountService.activeAccount$,
for (const userId in accounts) { this.accountService.accountActivity$,
if (userId != null && (await this.shouldLock(userId, activeUserId, isViewOpen))) { ]).pipe(
await this.executeTimeoutAction(userId); switchMap(async ([activeAccount, accountActivity]) => {
} const activeUserId = activeAccount?.id;
} for (const userIdString in accountActivity) {
const userId = userIdString as UserId;
if (
userId != null &&
(await this.shouldLock(userId, accountActivity[userId], activeUserId, isViewOpen))
) {
await this.executeTimeoutAction(userId);
}
}
}),
),
);
} }
async lock(userId?: string): Promise<void> { async lock(userId?: string): Promise<void> {
@ -123,6 +134,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private async shouldLock( private async shouldLock(
userId: string, userId: string,
lastActive: Date,
activeUserId: string, activeUserId: string,
isViewOpen: boolean, isViewOpen: boolean,
): Promise<boolean> { ): Promise<boolean> {
@ -146,13 +158,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
return false; return false;
} }
const lastActive = await this.stateService.getLastActive({ userId: userId });
if (lastActive == null) { if (lastActive == null) {
return false; return false;
} }
const vaultTimeoutSeconds = vaultTimeout * 60; const vaultTimeoutSeconds = vaultTimeout * 60;
const diffSeconds = (new Date().getTime() - lastActive) / 1000; const diffSeconds = (new Date().getTime() - lastActive.getTime()) / 1000;
return diffSeconds >= vaultTimeoutSeconds; return diffSeconds >= vaultTimeoutSeconds;
} }

View File

@ -57,13 +57,14 @@ import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-st
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag"; import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider"; import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version"; import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3; export const MIN_VERSION = 3;
export const CURRENT_VERSION = 59; export const CURRENT_VERSION = 60;
export type MinVersion = typeof MIN_VERSION; export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() { export function createMigrationBuilder() {
@ -124,7 +125,8 @@ export function createMigrationBuilder() {
.with(AuthRequestMigrator, 55, 56) .with(AuthRequestMigrator, 55, 56)
.with(CipherServiceMigrator, 56, 57) .with(CipherServiceMigrator, 56, 57)
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58) .with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
.with(KdfConfigMigrator, 58, CURRENT_VERSION); .with(KdfConfigMigrator, 58, 59)
.with(KnownAccountsMigrator, 59, CURRENT_VERSION);
} }
export async function currentVersion( export async function currentVersion(

View File

@ -27,6 +27,14 @@ const exampleJSON = {
}, },
global_serviceName_key: "global_serviceName_key", global_serviceName_key: "global_serviceName_key",
user_userId_serviceName_key: "user_userId_serviceName_key", user_userId_serviceName_key: "user_userId_serviceName_key",
global_account_accounts: {
"c493ed01-4e08-4e88-abc7-332f380ca760": {
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
otherStuff: "otherStuff4",
},
},
}; };
describe("RemoveLegacyEtmKeyMigrator", () => { describe("RemoveLegacyEtmKeyMigrator", () => {
@ -81,6 +89,41 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
const accounts = await sut.getAccounts(); const accounts = await sut.getAccounts();
expect(accounts).toEqual([]); expect(accounts).toEqual([]);
}); });
it("handles global scoped known accounts for version 60 and after", async () => {
sut.currentVersion = 60;
const accounts = await sut.getAccounts();
expect(accounts).toEqual([
// Note, still gets values stored in state service objects, just grabs user ids from global
{
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
account: { otherStuff: "otherStuff1" },
},
{
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
account: { otherStuff: "otherStuff2" },
},
]);
});
});
describe("getKnownUserIds", () => {
it("returns all user ids", async () => {
const userIds = await sut.getKnownUserIds();
expect(userIds).toEqual([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
]);
});
it("returns all user ids when version is 60 or greater", async () => {
sut.currentVersion = 60;
const userIds = await sut.getKnownUserIds();
expect(userIds).toEqual([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
]);
});
}); });
describe("getFromGlobal", () => { describe("getFromGlobal", () => {

View File

@ -162,7 +162,7 @@ export class MigrationHelper {
async getAccounts<ExpectedAccountType>(): Promise< async getAccounts<ExpectedAccountType>(): Promise<
{ userId: string; account: ExpectedAccountType }[] { userId: string; account: ExpectedAccountType }[]
> { > {
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? []; const userIds = await this.getKnownUserIds();
return Promise.all( return Promise.all(
userIds.map(async (userId) => ({ userIds.map(async (userId) => ({
userId, userId,
@ -171,6 +171,17 @@ export class MigrationHelper {
); );
} }
/**
* Helper method to read known users ids.
*/
async getKnownUserIds(): Promise<string[]> {
if (this.currentVersion < 61) {
return knownAccountUserIdsBuilderPre61(this.storageService);
} else {
return knownAccountUserIdsBuilder(this.storageService);
}
}
/** /**
* Builds a user storage key appropriate for the current version. * Builds a user storage key appropriate for the current version.
* *
@ -233,3 +244,18 @@ function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
function globalKeyBuilderPre9(): string { function globalKeyBuilderPre9(): string {
throw Error("No key builder should be used for versions prior to 9."); throw Error("No key builder should be used for versions prior to 9.");
} }
async function knownAccountUserIdsBuilderPre61(
storageService: AbstractStorageService,
): Promise<string[]> {
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
}
async function knownAccountUserIdsBuilder(
storageService: AbstractStorageService,
): Promise<string[]> {
const accounts = await storageService.get<Record<string, unknown>>(
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
);
return Object.keys(accounts ?? {});
}

View File

@ -0,0 +1,145 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
KnownAccountsMigrator,
} from "./60-known-accounts";
const migrateJson = () => {
return {
authenticatedAccounts: ["user1", "user2"],
activeUserId: "user1",
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
accountActivity: {
user1: 1609459200000, // 2021-01-01
user2: 1609545600000, // 2021-01-02
},
};
};
const rollbackJson = () => {
return {
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
global_account_accounts: {
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
},
global_account_activeAccountId: "user1",
global_account_activity: {
user1: "2021-01-01T00:00:00.000Z",
user2: "2021-01-02T00:00:00.000Z",
},
};
};
describe("ReplicateKnownAccounts", () => {
let helper: MockProxy<MigrationHelper>;
let sut: KnownAccountsMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateJson(), 59);
sut = new KnownAccountsMigrator(59, 60);
});
it("migrates accounts", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS, {
user1: {
email: "user1",
name: "User 1",
emailVerified: true,
},
user2: {
email: "",
emailVerified: false,
name: undefined,
},
});
expect(helper.remove).toHaveBeenCalledWith("authenticatedAccounts");
});
it("migrates active account it", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID, "user1");
expect(helper.remove).toHaveBeenCalledWith("activeUserId");
});
it("migrates account activity", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY, {
user1: '"2021-01-01T00:00:00.000Z"',
user2: '"2021-01-02T00:00:00.000Z"',
});
expect(helper.remove).toHaveBeenCalledWith("accountActivity");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJson(), 60);
sut = new KnownAccountsMigrator(59, 60);
});
it("rolls back authenticated accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("authenticatedAccounts", ["user1", "user2"]);
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS);
});
it("rolls back active account id", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("activeUserId", "user1");
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID);
});
it("rolls back account activity", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("accountActivity", {
user1: 1609459200000,
user2: 1609545600000,
});
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY);
});
});
});

View File

@ -0,0 +1,111 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
export const ACCOUNT_ACCOUNTS: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "accounts",
};
export const ACCOUNT_ACTIVE_ACCOUNT_ID: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "activeAccountId",
};
export const ACCOUNT_ACTIVITY: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "activity",
};
type ExpectedAccountType = {
profile?: {
email?: string;
name?: string;
emailVerified?: boolean;
};
};
export class KnownAccountsMigrator extends Migrator<59, 60> {
async migrate(helper: MigrationHelper): Promise<void> {
await this.migrateAuthenticatedAccounts(helper);
await this.migrateActiveAccountId(helper);
await this.migrateAccountActivity(helper);
}
async rollback(helper: MigrationHelper): Promise<void> {
// authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back
const accounts = (await helper.getFromGlobal<Record<string, unknown>>(ACCOUNT_ACCOUNTS)) ?? {};
await helper.set("authenticatedAccounts", Object.keys(accounts));
await helper.removeFromGlobal(ACCOUNT_ACCOUNTS);
// Active Account Id
const activeAccountId = await helper.getFromGlobal<string>(ACCOUNT_ACTIVE_ACCOUNT_ID);
if (activeAccountId) {
await helper.set("activeUserId", activeAccountId);
}
await helper.removeFromGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID);
// Account Activity
const accountActivity = await helper.getFromGlobal<Record<string, string>>(ACCOUNT_ACTIVITY);
if (accountActivity) {
const toStore = Object.entries(accountActivity).reduce(
(agg, [userId, dateString]) => {
agg[userId] = new Date(dateString).getTime();
return agg;
},
{} as Record<string, number>,
);
await helper.set("accountActivity", toStore);
}
await helper.removeFromGlobal(ACCOUNT_ACTIVITY);
}
private async migrateAuthenticatedAccounts(helper: MigrationHelper) {
const authenticatedAccounts = (await helper.get<string[]>("authenticatedAccounts")) ?? [];
const accounts = await Promise.all(
authenticatedAccounts.map(async (userId) => {
const account = await helper.get<ExpectedAccountType>(userId);
return { userId, account };
}),
);
const accountsToStore = accounts.reduce(
(agg, { userId, account }) => {
if (account?.profile) {
agg[userId] = {
email: account.profile.email ?? "",
emailVerified: account.profile.emailVerified ?? false,
name: account.profile.name,
};
}
return agg;
},
{} as Record<string, { email: string; emailVerified: boolean; name: string | undefined }>,
);
await helper.setToGlobal(ACCOUNT_ACCOUNTS, accountsToStore);
await helper.remove("authenticatedAccounts");
}
private async migrateAccountActivity(helper: MigrationHelper) {
const stored = await helper.get<Record<string, Date>>("accountActivity");
const accountActivity = Object.entries(stored ?? {}).reduce(
(agg, [userId, dateMs]) => {
agg[userId] = JSON.stringify(new Date(dateMs));
return agg;
},
{} as Record<string, string>,
);
await helper.setToGlobal(ACCOUNT_ACTIVITY, accountActivity);
await helper.remove("accountActivity");
}
private async migrateActiveAccountId(helper: MigrationHelper) {
const activeAccountId = await helper.get<string>("activeUserId");
await helper.setToGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID, activeAccountId);
await helper.remove("activeUserId");
}
}

View File

@ -62,6 +62,7 @@ describe("SendService", () => {
accountService.activeAccountSubject.next({ accountService.activeAccountSubject.next({
id: mockUserId, id: mockUserId,
email: "email", email: "email",
emailVerified: false,
name: "name", name: "name",
}); });

View File

@ -326,7 +326,10 @@ export class SyncService implements SyncServiceAbstraction {
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations); await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor); await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId); await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId);
await this.stateService.setEmailVerified(response.emailVerified); await this.accountService.setAccountEmailVerified(
response.id as UserId,
response.emailVerified,
);
await this.billingAccountProfileStateService.setHasPremium( await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally, response.premiumPersonally,

View File

@ -0,0 +1,33 @@
import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element";
@Directive({
selector: "bitA11yCell",
standalone: true,
providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }],
})
export class A11yCellDirective implements FocusableElement {
@HostBinding("attr.role")
role: "gridcell" | null;
@ContentChild(FocusableElement)
private focusableChild: FocusableElement;
getFocusTarget() {
let focusTarget: HTMLElement;
if (this.focusableChild) {
focusTarget = this.focusableChild.getFocusTarget();
} else {
focusTarget = this.elementRef.nativeElement.querySelector("button, a");
}
if (!focusTarget) {
return this.elementRef.nativeElement;
}
return focusTarget;
}
constructor(private elementRef: ElementRef<HTMLElement>) {}
}

View File

@ -0,0 +1,145 @@
import {
AfterViewInit,
ContentChildren,
Directive,
HostBinding,
HostListener,
Input,
QueryList,
} from "@angular/core";
import type { A11yCellDirective } from "./a11y-cell.directive";
import { A11yRowDirective } from "./a11y-row.directive";
@Directive({
selector: "bitA11yGrid",
standalone: true,
})
export class A11yGridDirective implements AfterViewInit {
@HostBinding("attr.role")
role = "grid";
@ContentChildren(A11yRowDirective)
rows: QueryList<A11yRowDirective>;
/** The number of pages to navigate on `PageUp` and `PageDown` */
@Input() pageSize = 5;
private grid: A11yCellDirective[][];
/** The row that currently has focus */
private activeRow = 0;
/** The cell that currently has focus */
private activeCol = 0;
@HostListener("keydown", ["$event"])
onKeyDown(event: KeyboardEvent) {
switch (event.code) {
case "ArrowUp":
this.updateCellFocusByDelta(-1, 0);
break;
case "ArrowRight":
this.updateCellFocusByDelta(0, 1);
break;
case "ArrowDown":
this.updateCellFocusByDelta(1, 0);
break;
case "ArrowLeft":
this.updateCellFocusByDelta(0, -1);
break;
case "Home":
this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
break;
case "End":
this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
break;
case "PageUp":
this.updateCellFocusByDelta(-this.pageSize, 0);
break;
case "PageDown":
this.updateCellFocusByDelta(this.pageSize, 0);
break;
default:
return;
}
/** Prevent default scrolling behavior */
event.preventDefault();
}
ngAfterViewInit(): void {
this.initializeGrid();
}
private initializeGrid(): void {
try {
this.grid = this.rows.map((listItem) => {
listItem.role = "row";
return [...listItem.cells];
});
this.grid.flat().forEach((cell) => {
cell.role = "gridcell";
cell.getFocusTarget().tabIndex = -1;
});
this.getActiveCellContent().tabIndex = 0;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Unable to initialize grid");
}
}
/** Get the focusable content of the active cell */
private getActiveCellContent(): HTMLElement {
return this.grid[this.activeRow][this.activeCol].getFocusTarget();
}
/** Move focus via a delta against the currently active gridcell */
private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
const prevActive = this.getActiveCellContent();
this.activeCol += colDelta;
this.activeRow += rowDelta;
// Row upper bound
if (this.activeRow >= this.grid.length) {
this.activeRow = this.grid.length - 1;
}
// Row lower bound
if (this.activeRow < 0) {
this.activeRow = 0;
}
// Column upper bound
if (this.activeCol >= this.grid[this.activeRow].length) {
if (this.activeRow < this.grid.length - 1) {
// Wrap to next row on right arrow
this.activeCol = 0;
this.activeRow += 1;
} else {
this.activeCol = this.grid[this.activeRow].length - 1;
}
}
// Column lower bound
if (this.activeCol < 0) {
if (this.activeRow > 0) {
// Wrap to prev row on left arrow
this.activeRow -= 1;
this.activeCol = this.grid[this.activeRow].length - 1;
} else {
this.activeCol = 0;
}
}
const nextActive = this.getActiveCellContent();
nextActive.tabIndex = 0;
nextActive.focus();
if (nextActive !== prevActive) {
prevActive.tabIndex = -1;
}
}
}

View File

@ -0,0 +1,31 @@
import {
AfterViewInit,
ContentChildren,
Directive,
HostBinding,
QueryList,
ViewChildren,
} from "@angular/core";
import { A11yCellDirective } from "./a11y-cell.directive";
@Directive({
selector: "bitA11yRow",
standalone: true,
})
export class A11yRowDirective implements AfterViewInit {
@HostBinding("attr.role")
role: "row" | null;
cells: A11yCellDirective[];
@ViewChildren(A11yCellDirective)
private viewCells: QueryList<A11yCellDirective>;
@ContentChildren(A11yCellDirective)
private contentCells: QueryList<A11yCellDirective>;
ngAfterViewInit(): void {
this.cells = [...this.viewCells, ...this.contentCells];
}
}

View File

@ -1,5 +1,7 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core"; import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element";
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info"; export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record<BadgeVariant, string[]> = { const styles: Record<BadgeVariant, string[]> = {
@ -22,8 +24,9 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
@Directive({ @Directive({
selector: "span[bitBadge], a[bitBadge], button[bitBadge]", selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
providers: [{ provide: FocusableElement, useExisting: BadgeDirective }],
}) })
export class BadgeDirective { export class BadgeDirective implements FocusableElement {
@HostBinding("class") get classList() { @HostBinding("class") get classList() {
return [ return [
"tw-inline-block", "tw-inline-block",
@ -62,6 +65,10 @@ export class BadgeDirective {
*/ */
@Input() truncate = true; @Input() truncate = true;
getFocusTarget() {
return this.el.nativeElement;
}
private hasHoverEffects = false; private hasHoverEffects = false;
constructor(private el: ElementRef<HTMLElement>) { constructor(private el: ElementRef<HTMLElement>) {

View File

@ -1,6 +1,7 @@
import { Component, HostBinding, Input } from "@angular/core"; import { Component, ElementRef, HostBinding, Input } from "@angular/core";
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
import { FocusableElement } from "../shared/focusable-element";
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light"; export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
@ -123,9 +124,12 @@ const sizes: Record<IconButtonSize, string[]> = {
@Component({ @Component({
selector: "button[bitIconButton]:not(button[bitButton])", selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html", templateUrl: "icon-button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }], providers: [
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
],
}) })
export class BitIconButtonComponent implements ButtonLikeAbstraction { export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
@Input("bitIconButton") icon: string; @Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonType; @Input() buttonType: IconButtonType;
@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") { setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
this.buttonType = value; this.buttonType = value;
} }
getFocusTarget() {
return this.elementRef.nativeElement;
}
constructor(private elementRef: ElementRef) {}
} }

View File

@ -16,6 +16,7 @@ export * from "./form-field";
export * from "./icon-button"; export * from "./icon-button";
export * from "./icon"; export * from "./icon";
export * from "./input"; export * from "./input";
export * from "./item";
export * from "./layout"; export * from "./layout";
export * from "./link"; export * from "./link";
export * from "./menu"; export * from "./menu";

View File

@ -3,12 +3,7 @@ import { take } from "rxjs/operators";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
/** import { FocusableElement } from "../shared/focusable-element";
* Interface for implementing focusable components. Used by the AutofocusDirective.
*/
export abstract class FocusableElement {
focus: () => void;
}
/** /**
* Directive to focus an element. * Directive to focus an element.
@ -46,7 +41,7 @@ export class AutofocusDirective {
private focus() { private focus() {
if (this.focusableElement) { if (this.focusableElement) {
this.focusableElement.focus(); this.focusableElement.getFocusTarget().focus();
} else { } else {
this.el.nativeElement.focus(); this.el.nativeElement.focus();
} }

View File

@ -0,0 +1 @@
export * from "./item.module";

View File

@ -0,0 +1,12 @@
import { Component } from "@angular/core";
import { A11yCellDirective } from "../a11y/a11y-cell.directive";
@Component({
selector: "bit-item-action",
standalone: true,
imports: [],
template: `<ng-content></ng-content>`,
providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }],
})
export class ItemActionComponent extends A11yCellDirective {}

View File

@ -0,0 +1,16 @@
<div class="tw-flex tw-gap-2 tw-items-center">
<ng-content select="[slot=start]"></ng-content>
<div class="tw-flex tw-flex-col tw-items-start tw-text-start tw-w-full [&_p]:tw-mb-0">
<div class="tw-text-main tw-text-base">
<ng-content></ng-content>
</div>
<div class="tw-text-muted tw-text-sm">
<ng-content select="[slot=secondary]"></ng-content>
</div>
</div>
</div>
<div class="tw-flex tw-gap-2 tw-items-center">
<ng-content select="[slot=end]"></ng-content>
</div>

View File

@ -0,0 +1,15 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
selector: "bit-item-content, [bit-item-content]",
standalone: true,
imports: [CommonModule],
templateUrl: `item-content.component.html`,
host: {
class:
"fvw-target tw-outline-none tw-text-main hover:tw-text-main hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between",
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ItemContentComponent {}

View File

@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
selector: "bit-item-group",
standalone: true,
imports: [],
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: "tw-block",
},
})
export class ItemGroupComponent {}

View File

@ -0,0 +1,21 @@
<!-- TODO: Colors will be finalized in the extension refresh feature branch -->
<div
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-bg-primary-300/20 tw-text-main tw-border-solid tw-border-b tw-border-0 tw-rounded-lg tw-mb-1.5"
[ngClass]="
focusVisibleWithin()
? 'tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-primary-600 tw-border-transparent'
: 'tw-border-b-secondary-300 [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-border-b-transparent'
"
>
<bit-item-action class="item-main-content tw-block tw-w-full">
<ng-content></ng-content>
</bit-item-action>
<div
#endSlot
class="tw-p-2 tw-flex tw-gap-1 tw-items-center"
[hidden]="endSlot.childElementCount === 0"
>
<ng-content select="[slot=end]"></ng-content>
</div>
</div>

View File

@ -0,0 +1,29 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, HostListener, signal } from "@angular/core";
import { A11yRowDirective } from "../a11y/a11y-row.directive";
import { ItemActionComponent } from "./item-action.component";
@Component({
selector: "bit-item",
standalone: true,
imports: [CommonModule, ItemActionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "item.component.html",
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
})
export class ItemComponent extends A11yRowDirective {
/**
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
*/
protected focusVisibleWithin = signal(false);
@HostListener("focusin", ["$event.target"])
onFocusIn(target: HTMLElement) {
this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
}
@HostListener("focusout")
onFocusOut() {
this.focusVisibleWithin.set(false);
}
}

View File

@ -0,0 +1,141 @@
import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
import * as stories from "./item.stories";
<Meta of={stories} />
```ts
import { ItemModule } from "@bitwarden/components";
```
# Item
`<bit-item>` is a horizontal card that contains one or more interactive actions.
It is a generic container that can be used for either standalone content, an alternative to tables,
or to list nav links.
<Canvas>
<Story of={stories.Default} />
</Canvas>
## Primary Content
The primary content of an item is supplied by `bit-item-content`.
### Content Types
The content can be a button, anchor, or static container.
```html
<bit-item>
<a bit-item-content routerLink="..."> Hi, I am a link. </a>
</bit-item>
<bit-item>
<button bit-item-content (click)="...">And I am a button.</button>
</bit-item>
<bit-item>
<bit-item-content> I'm just static :( </bit-item-content>
</bit-item>
```
<Canvas>
<Story of={stories.ContentTypes} />
</Canvas>
### Content Slots
`bit-item-content` contains the following slots to help position the content:
| Slot | Description |
| ------------------ | --------------------------------------------------- |
| default | primary text or arbitrary content; fan favorite |
| `slot="secondary"` | supporting text; under the default slot |
| `slot="start"` | commonly an icon or avatar; before the default slot |
| `slot="end"` | commonly an icon; after the default slot |
- Note: There is also an `end` slot within `bit-item` itself. Place
[interactive secondary actions](#secondary-actions) there, and place non-interactive content (such
as icons) in `bit-item-content`
```html
<bit-item>
<button bit-item-content type="button">
<bit-avatar slot="start" text="Foo"></bit-avatar>
foo@bitwarden.com
<ng-container slot="secondary">
<div>Bitwarden.com</div>
<div><em>locked</em></div>
</ng-container>
<i slot="end" class="bwi bwi-lock" aria-hidden="true"></i>
</button>
</bit-item>
```
<Canvas>
<Story of={stories.ContentSlots} />
</Canvas>
## Secondary Actions
Secondary interactive actions can be placed in the item through the `"end"` slot, outside of
`bit-item-content`.
Each action must be wrapped by `<bit-item-action>`.
Actions are commonly icon buttons or badge buttons.
```html
<bit-item>
<button bit-item-content>...</button>
<ng-container slot="end">
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone" aria-label="Copy"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v" aria-label="Options"></button>
</bit-item-action>
</ng-container>
</bit-item>
```
## Item Groups
Groups of items can be associated by wrapping them in the `<bit-item-group>`.
<Canvas>
<Story of={stories.MultipleActionList} />
</Canvas>
<Canvas>
<Story of={stories.SingleActionList} />
</Canvas>
### A11y
Keyboard nav is currently disabled due to a bug when used within a virtual scroll viewport.
Item groups utilize arrow-based keyboard navigation
([further reading here](https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/#kbd_label)).
Use `aria-label` or `aria-labelledby` to give groups an accessible name.
```html
<bit-item-group aria-label="My Items">
<bit-item>...</bit-item>
<bit-item>...</bit-item>
<bit-item>...</bit-item>
</bit-item-group>
```
### Virtual Scrolling
<Canvas>
<Story of={stories.VirtualScrolling} />
</Canvas>

Some files were not shown because too many files have changed in this diff Show More