Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Will Martin 2024-04-30 10:58:29 -04:00 committed by GitHub
commit 8693418d3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
154 changed files with 3199 additions and 1590 deletions

View File

@ -164,6 +164,10 @@ jobs:
run: npm run dist:mv3
working-directory: browser-source/apps/browser
- name: Build Chrome Manifest v3 Beta
run: npm run dist:chrome:beta
working-directory: browser-source/apps/browser
- name: Gulp
run: gulp ci
working-directory: browser-source/apps/browser
@ -196,6 +200,13 @@ jobs:
path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
if-no-files-found: error
- name: Upload Chrome MV3 Beta artifact (DO NOT USE FOR PROD)
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-beta-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/dist-chrome-mv3-beta.zip
if-no-files-found: error
- name: Upload Firefox artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:

View File

@ -230,6 +230,17 @@ jobs:
url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }}
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
update-summary:
name: Display commit
needs: artifact-check
runs-on: ubuntu-22.04
steps:
- name: Display commit SHA
run: |
REPO_URL="https://github.com/bitwarden/clients/commit"
COMMIT_SHA="${{ needs.artifact-check.outputs.artifact-build-commit }}"
echo ":steam_locomotive: View [commit]($REPO_URL/$COMMIT_SHA)" >> $GITHUB_STEP_SUMMARY
azure-deploy:
name: Deploy Web Vault to ${{ inputs.environment }} Storage Account
needs:

View File

@ -30,11 +30,27 @@ const filters = {
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() {
var build = "";
if (process.env.MANIFEST_VERSION) {
build = `-mv${process.env.MANIFEST_VERSION}`;
}
if (process.env.BETA_BUILD === "1") {
build += "-beta";
}
if (process.env.BUILD_NUMBER && process.env.BUILD_NUMBER !== "") {
build = `-${process.env.BUILD_NUMBER}`;
}
@ -65,6 +81,9 @@ function distFirefox() {
manifest.optional_permissions = manifest.optional_permissions.filter(
(permission) => permission !== "privacy",
);
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -72,6 +91,9 @@ function distFirefox() {
function distOpera() {
return dist("opera", (manifest) => {
delete manifest.applications;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -81,6 +103,9 @@ function distChrome() {
delete manifest.applications;
delete manifest.sidebar_action;
delete manifest.commands._execute_sidebar_action;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -90,6 +115,9 @@ function distEdge() {
delete manifest.applications;
delete manifest.sidebar_action;
delete manifest.commands._execute_sidebar_action;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -210,6 +238,9 @@ async function safariCopyBuild(source, dest) {
delete manifest.commands._execute_sidebar_action;
delete manifest.optional_permissions;
manifest.permissions.push("nativeMessaging");
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
}),
),
@ -235,6 +266,30 @@ async function ciCoverage(cb) {
.pipe(gulp.dest(paths.coverage));
}
function applyBetaLabels(manifest) {
manifest.name = "Bitwarden Password Manager BETA";
manifest.short_name = "Bitwarden BETA";
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
if (process.env.GITHUB_RUN_ID) {
const existingVersionParts = manifest.version.split("."); // 3 parts expected 2024.4.0
// 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 {
manifest.version = `${manifest.version}.0`;
}
return manifest;
}
exports["dist:firefox"] = distFirefox;
exports["dist:chrome"] = distChrome;
exports["dist:opera"] = distOpera;

View File

@ -7,10 +7,14 @@
"build:watch": "webpack --watch",
"build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch",
"build:prod": "cross-env NODE_ENV=production webpack",
"build:prod:beta": "cross-env BETA_BUILD=1 NODE_ENV=production webpack",
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
"dist": "npm run build:prod && gulp dist",
"dist:beta": "npm run build:prod:beta && cross-env BETA_BUILD=1 gulp dist",
"dist:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && cross-env MANIFEST_VERSION=3 gulp dist",
"dist:mv3:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist",
"dist:chrome": "npm run build:prod && gulp dist:chrome",
"dist:chrome:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist:chrome",
"dist:firefox": "npm run build:prod && gulp dist:firefox",
"dist:opera": "npm run build:prod && gulp dist:opera",
"dist:safari": "npm run build:prod && gulp dist:safari",

View File

@ -49,7 +49,7 @@
<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"
(click)="lock()"
(click)="lock(currentAccount.id)"
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
>
@ -59,7 +59,7 @@
<button
type="button"
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>
{{ "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 { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { AccountSwitcherService } from "./services/account-switcher.service";
@ -64,9 +65,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
this.location.back();
}
async lock(userId?: string) {
async lock(userId: string) {
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.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["lock"]);
@ -96,7 +97,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
.subscribe(() => this.router.navigate(["lock"]));
}
async logOut() {
async logOut(userId: UserId) {
this.loading = true;
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
@ -105,7 +106,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
});
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.

View File

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

View File

@ -110,7 +110,7 @@ export class AccountSwitcherService {
}),
);
// Create a reusable observable that listens to the the switchAccountFinish message and returns the userId from the message
// Create a reusable observable that listens to the switchAccountFinish message and returns the userId from the message
this.switchAccountFinished$ = fromChromeEvent<[message: { command: string; userId: string }]>(
chrome.runtime.onMessage,
).pipe(

View File

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

View File

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

View File

@ -1,4 +1,7 @@
import { firstValueFrom, map } from "rxjs";
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 { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -17,7 +20,6 @@ import {
NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants";
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 { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory";
import {
authServiceFactory,
AuthServiceInitOptions,
@ -37,7 +40,6 @@ import { autofillSettingsServiceFactory } from "../../autofill/background/servic
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
import { Account } from "../../models/account";
import { CachedServices } from "../../platform/background/service-factories/factory-options";
import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory";
import { BrowserApi } from "../../platform/browser/browser-api";
import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory";
import {
@ -71,10 +73,10 @@ export class ContextMenuClickedHandler {
private autofillAction: AutofillAction,
private authService: AuthService,
private cipherService: CipherService,
private stateService: StateService,
private totpService: TotpService,
private eventCollectionService: EventCollectionService,
private userVerificationService: UserVerificationService,
private accountService: AccountService,
) {}
static async mv3Create(cachedServices: CachedServices) {
@ -128,10 +130,10 @@ export class ContextMenuClickedHandler {
(tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher),
await authServiceFactory(cachedServices, serviceOptions),
await cipherServiceFactory(cachedServices, serviceOptions),
await stateServiceFactory(cachedServices, serviceOptions),
await totpServiceFactory(cachedServices, serviceOptions),
await eventCollectionServiceFactory(cachedServices, serviceOptions),
await userVerificationServiceFactory(cachedServices, serviceOptions),
await accountServiceFactory(cachedServices, serviceOptions),
);
}
@ -239,9 +241,10 @@ export class ContextMenuClickedHandler {
return;
}
// 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.stateService.setLastActive(new Date().getTime());
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.accountService.setAccountActivity(activeUserId, new Date());
switch (info.parentMenuItemId) {
case AUTOFILL_ID:
case AUTOFILL_IDENTITY_ID:

View File

@ -1,4 +1,4 @@
import { Subject, firstValueFrom, merge } from "rxjs";
import { Subject, firstValueFrom, map, merge, timeout } from "rxjs";
import {
PinCryptoServiceAbstraction,
@ -71,6 +71,7 @@ import {
} from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -112,7 +113,7 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
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 {
ActiveUserStateProvider,
@ -333,7 +334,7 @@ export default class MainBackground {
billingAccountProfileStateService: BillingAccountProfileStateService;
// eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module
intraprocessMessagingSubject: Subject<Message<object>>;
userKeyInitService: UserKeyInitService;
userAutoUnlockKeyService: UserAutoUnlockKeyService;
scriptInjectorService: BrowserScriptInjectorService;
kdfConfigService: kdfConfigServiceAbstraction;
@ -490,7 +491,7 @@ export default class MainBackground {
this.accountService,
this.singleUserStateProvider,
);
this.derivedStateProvider = new BackgroundDerivedStateProvider(storageServiceProvider);
this.derivedStateProvider = new BackgroundDerivedStateProvider();
this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider,
this.singleUserStateProvider,
@ -520,6 +521,7 @@ export default class MainBackground {
this.storageService,
this.logService,
new MigrationBuilderService(),
ClientType.Browser,
);
this.stateService = new DefaultBrowserStateService(
@ -900,6 +902,7 @@ export default class MainBackground {
this.autofillSettingsService,
this.vaultTimeoutSettingsService,
this.biometricStateService,
this.accountService,
);
// Other fields
@ -918,7 +921,6 @@ export default class MainBackground {
this.autofillService,
this.platformUtilsService as BrowserPlatformUtilsService,
this.notificationsService,
this.stateService,
this.autofillSettingsService,
this.systemService,
this.environmentService,
@ -927,6 +929,7 @@ export default class MainBackground {
this.configService,
this.fido2Background,
messageListener,
this.accountService,
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.accountService,
@ -1016,10 +1019,10 @@ export default class MainBackground {
},
this.authService,
this.cipherService,
this.stateService,
this.totpService,
this.eventCollectionService,
this.userVerificationService,
this.accountService,
);
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
@ -1062,11 +1065,7 @@ export default class MainBackground {
}
}
this.userKeyInitService = new UserKeyInitService(
this.accountService,
this.cryptoService,
this.logService,
);
this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
}
async bootstrap() {
@ -1077,7 +1076,18 @@ export default class MainBackground {
// This is here instead of in in the InitService b/c we don't plan for
// 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();
(this.eventUploadService as EventUploadService).init(true);
@ -1159,7 +1169,12 @@ export default class MainBackground {
*/
async switchAccount(userId: UserId) {
try {
await this.stateService.setActiveUser(userId);
const currentlyActiveAccount = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
// can be removed once password generation history is migrated to state providers
await this.stateService.clearDecryptedData(currentlyActiveAccount);
await this.accountService.switchAccount(userId);
if (userId == null) {
this.loginEmailService.setRememberEmail(false);
@ -1196,7 +1211,18 @@ export default class MainBackground {
}
async logout(expired: boolean, userId?: UserId) {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
userId ??= (
await firstValueFrom(
this.accountService.activeAccount$.pipe(
timeout({
first: 2000,
with: () => {
throw new Error("No active account found to logout");
},
}),
),
)
)?.id;
await this.eventUploadService.uploadEvents(userId as UserId);
@ -1220,7 +1246,11 @@ export default class MainBackground {
//Needs to be checked before state is cleaned
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);

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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -19,7 +20,6 @@ import {
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
import { AutofillService } from "../autofill/services/abstractions/autofill.service";
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 { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
@ -37,7 +37,6 @@ export default class RuntimeBackground {
private autofillService: AutofillService,
private platformUtilsService: BrowserPlatformUtilsService,
private notificationsService: NotificationsService,
private stateService: BrowserStateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private systemService: SystemService,
private environmentService: BrowserEnvironmentService,
@ -46,6 +45,7 @@ export default class RuntimeBackground {
private configService: ConfigService,
private fido2Background: Fido2Background,
private messageListener: MessageListener,
private accountService: AccountService,
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@ -85,7 +85,11 @@ export default class RuntimeBackground {
this.messageListener.allMessages$
.pipe(
mergeMap(async (message: any) => {
await this.processMessage(message);
try {
await this.processMessage(message);
} catch (err) {
this.logService.error(err);
}
}),
)
.subscribe();
@ -107,9 +111,10 @@ export default class RuntimeBackground {
switch (msg.sender) {
case "autofiller":
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.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.stateService.setLastActive(new Date().getTime());
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
await this.accountService.setAccountActivity(activeUserId, new Date());
const totpCode = await this.autofillService.doAutoFillActiveTab(
[
{

View File

@ -3,15 +3,10 @@ import { DerivedStateProvider } from "@bitwarden/common/platform/state";
import { BackgroundDerivedStateProvider } from "../../state/background-derived-state.provider";
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type DerivedStateProviderFactoryOptions = FactoryOptions;
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions &
StorageServiceProviderInitOptions;
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions;
export async function derivedStateProviderFactory(
cache: { derivedStateProvider?: DerivedStateProvider } & CachedServices,
@ -21,7 +16,6 @@ export async function derivedStateProviderFactory(
cache,
"derivedStateProvider",
opts,
async () =>
new BackgroundDerivedStateProvider(await storageServiceProviderFactory(cache, opts)),
async () => new BackgroundDerivedStateProvider(),
);
}

View File

@ -1,3 +1,4 @@
import { ClientType } from "@bitwarden/common/enums";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
@ -27,6 +28,7 @@ export async function migrationRunnerFactory(
await diskStorageServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
new MigrationBuilderService(),
ClientType.Browser,
),
);
}

View File

@ -238,10 +238,6 @@ export class BrowserApi {
return typeof window !== "undefined" && window === BrowserApi.getBackgroundPage();
}
static getApplicationVersion(): string {
return chrome.runtime.getManifest().version;
}
/**
* Gets the extension views that match the given properties. This method is not
* available within background service worker. As a result, it will return an

View File

@ -203,7 +203,7 @@ describe("BrowserPopupUtils", () => {
expect(BrowserPopupUtils["buildPopoutUrl"]).not.toHaveBeenCalled();
});
it("replaces any existing `uilocation=` query params within the passed extension url path to state the the uilocaiton is a popup", async () => {
it("replaces any existing `uilocation=` query params within the passed extension url path to state the uilocation is a popup", async () => {
const url = "popup/index.html?uilocation=sidebar#/tabs/vault";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);

View File

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

View File

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

View File

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

View File

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

View File

@ -200,26 +200,29 @@ export class LocalBackedSessionStorageService
}
private compareValues<T>(value1: T, value2: T): boolean {
if (value1 == null && value2 == null) {
try {
if (value1 == null && value2 == null) {
return true;
}
if (value1 && value2 == null) {
return false;
}
if (value1 == null && value2) {
return false;
}
if (typeof value1 !== "object" || typeof value2 !== "object") {
return value1 === value2;
}
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;
}
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

@ -175,11 +175,13 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
}
getApplicationVersion(): Promise<string> {
return Promise.resolve(BrowserApi.getApplicationVersion());
const manifest = chrome.runtime.getManifest();
return Promise.resolve(manifest.version_name ?? manifest.version);
}
async getApplicationVersionNumber(): Promise<string> {
return (await this.getApplicationVersion()).split(RegExp("[+|-]"))[0].trim();
getApplicationVersionNumber(): Promise<string> {
const manifest = chrome.runtime.getManifest();
return Promise.resolve(manifest.version.split(RegExp("[+|-]"))[0].trim());
}
supportsWebAuthn(win: Window): boolean {

View File

@ -1,9 +1,5 @@
import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -16,14 +12,11 @@ export class BackgroundDerivedStateProvider extends DefaultDerivedStateProvider
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
const [cacheKey, storageService] = storageLocation;
return new BackgroundDerivedState(
parentState$,
deriveDefinition,
storageService,
cacheKey,
deriveDefinition.buildCacheKey(),
dependencies,
);
}

View File

@ -1,10 +1,7 @@
import { Observable, Subscription } from "rxjs";
import { Observable, Subscription, concatMap } from "rxjs";
import { Jsonify } from "type-fest";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state";
@ -22,11 +19,10 @@ export class BackgroundDerivedState<
constructor(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
memoryStorage: AbstractStorageService & ObservableStorageService,
portName: string,
dependencies: TDeps,
) {
super(parentState$, deriveDefinition, memoryStorage, dependencies);
super(parentState$, deriveDefinition, dependencies);
// listen for foreground derived states to connect
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
@ -42,7 +38,20 @@ export class BackgroundDerivedState<
});
port.onMessage.addListener(listenerCallback);
const stateSubscription = this.state$.subscribe();
const stateSubscription = this.state$
.pipe(
concatMap(async (state) => {
await this.sendMessage(
{
action: "nextState",
data: JSON.stringify(state),
id: Utils.newGuid(),
},
port,
);
}),
)
.subscribe();
this.portSubscriptions.set(port, stateSubscription);
});

View File

@ -4,14 +4,13 @@
*/
import { NgZone } from "@angular/core";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec/utils";
import { mock } from "jest-mock-extended";
import { Subject, firstValueFrom } from "rxjs";
import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
import { awaitAsync, trackEmissions, ObservableTracker } from "@bitwarden/common/spec";
import { mockPorts } from "../../../spec/mock-port.spec-util";
@ -22,6 +21,7 @@ const stateDefinition = new StateDefinition("test", "memory");
const deriveDefinition = new DeriveDefinition(stateDefinition, "test", {
derive: (dateString: string) => (dateString == null ? null : new Date(dateString)),
deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)),
cleanupDelayMs: 1000,
});
// Mock out the runInsideAngular operator so we don't have to deal with zone.js
@ -35,7 +35,6 @@ describe("foreground background derived state interactions", () => {
let foreground: ForegroundDerivedState<Date>;
let background: BackgroundDerivedState<string, Date, Record<string, unknown>>;
let parentState$: Subject<string>;
let memoryStorage: FakeStorageService;
const initialParent = "2020-01-01";
const ngZone = mock<NgZone>();
const portName = "testPort";
@ -43,16 +42,9 @@ describe("foreground background derived state interactions", () => {
beforeEach(() => {
mockPorts();
parentState$ = new Subject<string>();
memoryStorage = new FakeStorageService();
background = new BackgroundDerivedState(
parentState$,
deriveDefinition,
memoryStorage,
portName,
{},
);
foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
background = new BackgroundDerivedState(parentState$, deriveDefinition, portName, {});
foreground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
});
afterEach(() => {
@ -72,21 +64,13 @@ describe("foreground background derived state interactions", () => {
});
it("should initialize a late-connected foreground", async () => {
const newForeground = new ForegroundDerivedState(
deriveDefinition,
memoryStorage,
portName,
ngZone,
);
const backgroundEmissions = trackEmissions(background.state$);
const newForeground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
const backgroundTracker = new ObservableTracker(background.state$);
parentState$.next(initialParent);
await awaitAsync();
const foregroundTracker = new ObservableTracker(newForeground.state$);
const foregroundEmissions = trackEmissions(newForeground.state$);
await awaitAsync(10);
expect(backgroundEmissions).toEqual([new Date(initialParent)]);
expect(foregroundEmissions).toEqual([new Date(initialParent)]);
expect(await backgroundTracker.expectEmission()).toEqual(new Date(initialParent));
expect(await foregroundTracker.expectEmission()).toEqual(new Date(initialParent));
});
describe("forceValue", () => {

View File

@ -1,11 +1,6 @@
import { NgZone } from "@angular/core";
import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -14,19 +9,18 @@ import { DerivedStateDependencies } from "@bitwarden/common/src/types/state";
import { ForegroundDerivedState } from "./foreground-derived-state";
export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider {
constructor(
storageServiceProvider: StorageServiceProvider,
private ngZone: NgZone,
) {
super(storageServiceProvider);
constructor(private ngZone: NgZone) {
super();
}
override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
_parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
_dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
const [cacheKey, storageService] = storageLocation;
return new ForegroundDerivedState(deriveDefinition, storageService, cacheKey, this.ngZone);
return new ForegroundDerivedState(
deriveDefinition,
deriveDefinition.buildCacheKey(),
this.ngZone,
);
}
}

View File

@ -1,11 +1,5 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { NgZone } from "@angular/core";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { awaitAsync } from "@bitwarden/common/../spec";
import { mock } from "jest-mock-extended";
import { DeriveDefinition } from "@bitwarden/common/platform/state";
@ -32,15 +26,12 @@ jest.mock("../browser/run-inside-angular.operator", () => {
describe("ForegroundDerivedState", () => {
let sut: ForegroundDerivedState<Date>;
let memoryStorage: FakeStorageService;
const portName = "testPort";
const ngZone = mock<NgZone>();
beforeEach(() => {
memoryStorage = new FakeStorageService();
memoryStorage.internalUpdateValuesRequireDeserialization(true);
mockPorts();
sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
sut = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
});
afterEach(() => {
@ -67,18 +58,4 @@ describe("ForegroundDerivedState", () => {
expect(disconnectSpy).toHaveBeenCalled();
expect(sut["port"]).toBeNull();
});
it("should emit when the memory storage updates", async () => {
const dateString = "2020-01-01";
const emissions = trackEmissions(sut.state$);
await memoryStorage.save(deriveDefinition.storageKey, {
derived: true,
value: new Date(dateString),
});
await awaitAsync();
expect(emissions).toEqual([new Date(dateString)]);
});
});

View File

@ -6,19 +6,14 @@ import {
filter,
firstValueFrom,
map,
merge,
of,
share,
switchMap,
tap,
timer,
} from "rxjs";
import { Jsonify, JsonObject } from "type-fest";
import { Jsonify } from "type-fest";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
import { DerivedStateDependencies } from "@bitwarden/common/types/state";
@ -27,41 +22,28 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
import { runInsideAngular } from "../browser/run-inside-angular.operator";
export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
private storageKey: string;
private port: chrome.runtime.Port;
private backgroundResponses$: Observable<DerivedStateMessage>;
state$: Observable<TTo>;
constructor(
private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>,
private memoryStorage: AbstractStorageService & ObservableStorageService,
private portName: string,
private ngZone: NgZone,
) {
this.storageKey = deriveDefinition.storageKey;
const initialStorageGet$ = defer(() => {
return this.getStoredValue();
}).pipe(
filter((s) => s.derived),
map((s) => s.value),
);
const latestStorage$ = this.memoryStorage.updates$.pipe(
filter((s) => s.key === this.storageKey),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await this.getStoredValue();
}),
filter((s) => s.derived),
map((s) => s.value),
);
const latestValueFromPort$ = (port: chrome.runtime.Port) => {
return fromChromeEvent(port.onMessage).pipe(
map(([message]) => message as DerivedStateMessage),
filter((message) => message.originator === "background" && message.action === "nextState"),
map((message) => {
const json = JSON.parse(message.data) as Jsonify<TTo>;
return this.deriveDefinition.deserialize(json);
}),
);
};
this.state$ = defer(() => of(this.initializePort())).pipe(
switchMap(() => merge(initialStorageGet$, latestStorage$)),
switchMap(() => latestValueFromPort$(this.port)),
share({
connector: () => new ReplaySubject<TTo>(1),
resetOnRefCountZero: () =>
@ -130,28 +112,4 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
this.port = null;
this.backgroundResponses$ = null;
}
protected async getStoredValue(): Promise<{ derived: boolean; value: TTo | null }> {
if (this.memoryStorage.valuesRequireDeserialization) {
const storedJson = await this.memoryStorage.get<
Jsonify<{ derived: true; value: JsonObject }>
>(this.storageKey);
if (!storedJson?.derived) {
return { derived: false, value: null };
}
const value = this.deriveDefinition.deserialize(storedJson.value as any);
return { derived: true, value };
} else {
const stored = await this.memoryStorage.get<{ derived: true; value: TTo }>(this.storageKey);
if (!stored?.derived) {
return { derived: false, value: null };
}
return { derived: true, value: stored.value };
}
}
}

View File

@ -1,12 +1,14 @@
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
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 { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
@ -27,8 +29,9 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
</div>`,
})
export class AppComponent implements OnInit, OnDestroy {
private lastActivity: number = null;
private activeUserId: string;
private lastActivity: Date;
private activeUserId: UserId;
private recordActivitySubject = new Subject<void>();
private destroy$ = new Subject<void>();
@ -46,6 +49,7 @@ export class AppComponent implements OnInit, OnDestroy {
private dialogService: DialogService,
private messageListener: MessageListener,
private toastService: ToastService,
private accountService: AccountService,
) {}
async ngOnInit() {
@ -53,14 +57,13 @@ export class AppComponent implements OnInit, OnDestroy {
// Clear them aggressively to make sure this doesn't occur
await this.clearComponentStates();
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => {
this.activeUserId = userId;
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = account?.id;
});
this.authService.activeAccountStatus$
.pipe(
map((status) => status === AuthenticationStatus.Unlocked),
filter((unlocked) => unlocked),
filter((status) => status === AuthenticationStatus.Unlocked),
concatMap(async () => {
await this.recordActivity();
}),
@ -200,13 +203,13 @@ export class AppComponent implements OnInit, OnDestroy {
return;
}
const now = new Date().getTime();
if (this.lastActivity != null && now - this.lastActivity < 250) {
const now = new Date();
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return;
}
this.lastActivity = now;
await this.stateService.setLastActive(now, { userId: this.activeUserId });
await this.accountService.setAccountActivity(this.activeUserId, now);
}
private showToast(msg: any) {

View File

@ -13,6 +13,7 @@ import {
SYSTEM_THEME_OBSERVABLE,
SafeInjectionToken,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
@ -45,6 +46,7 @@ import {
UserNotificationSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@ -473,7 +475,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DerivedStateProvider,
useClass: ForegroundDerivedStateProvider,
deps: [StorageServiceProvider, NgZone],
deps: [NgZone],
}),
safeProvider({
provide: AutofillSettingsServiceAbstraction,
@ -558,6 +560,10 @@ const safeProviders: SafeProvider[] = [
OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE,
],
}),
safeProvider({
provide: CLIENT_TYPE,
useValue: ClientType.Browser,
}),
];
@NgModule({

View File

@ -5,7 +5,7 @@
<div bitDialogTitle>Bitwarden</div>
<div bitDialogContent>
<p>&copy; Bitwarden Inc. 2015-{{ year }}</p>
<p>{{ "version" | i18n }}: {{ version }}</p>
<p>{{ "version" | i18n }}: {{ version$ | async }}</p>
<ng-container *ngIf="data$ | async as data">
<p *ngIf="data.isCloud">
{{ "serverVersion" | i18n }}: {{ data.serverConfig?.version }}

View File

@ -1,14 +1,13 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { combineLatest, map } from "rxjs";
import { Observable, combineLatest, defer, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule, DialogModule } from "@bitwarden/components";
import { BrowserApi } from "../../platform/browser/browser-api";
@Component({
templateUrl: "about.component.html",
standalone: true,
@ -16,7 +15,7 @@ import { BrowserApi } from "../../platform/browser/browser-api";
})
export class AboutComponent {
protected year = new Date().getFullYear();
protected version = BrowserApi.getApplicationVersion();
protected version$: Observable<string>;
protected data$ = combineLatest([
this.configService.serverConfig$,
@ -26,5 +25,8 @@ export class AboutComponent {
constructor(
private configService: ConfigService,
private environmentService: EnvironmentService,
) {}
private platformUtilsService: PlatformUtilsService,
) {
this.version$ = defer(() => this.platformUtilsService.getApplicationVersion());
}
}

View File

@ -21,6 +21,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
@ -86,6 +87,7 @@ export class SettingsComponent implements OnInit {
private destroy$ = new Subject<void>();
constructor(
private accountService: AccountService,
private policyService: PolicyService,
private formBuilder: FormBuilder,
private platformUtilsService: PlatformUtilsService,
@ -434,8 +436,9 @@ export class SettingsComponent implements OnInit {
type: "info",
});
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (confirmed) {
this.messagingService.send("logout");
this.messagingService.send("logout", { userId: userId });
}
}

View File

@ -6,6 +6,7 @@ import { first } from "rxjs/operators";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -51,6 +52,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
formBuilder: FormBuilder,
private filePopoutUtilsService: FilePopoutUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) {
super(
i18nService,
@ -66,6 +68,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
dialogService,
formBuilder,
billingAccountProfileStateService,
accountService,
);
}

View File

@ -71,7 +71,7 @@
"papaparse": "5.4.1",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"tldts": "6.1.16",
"tldts": "6.1.18",
"zxcvbn": "4.4.2"
}
}

View File

@ -3,6 +3,7 @@ import * as path from "path";
import { program } from "commander";
import * as jsdom from "jsdom";
import { firstValueFrom } from "rxjs";
import {
InternalUserDecryptionOptionsServiceAbstraction,
@ -79,7 +80,7 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StateService } from "@bitwarden/common/platform/services/state.service";
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 {
ActiveUserStateProvider,
DerivedStateProvider,
@ -236,7 +237,7 @@ export class Main {
biometricStateService: BiometricStateService;
billingAccountProfileStateService: BillingAccountProfileStateService;
providerApiService: ProviderApiServiceAbstraction;
userKeyInitService: UserKeyInitService;
userAutoUnlockKeyService: UserAutoUnlockKeyService;
kdfConfigService: KdfConfigServiceAbstraction;
constructor() {
@ -314,7 +315,7 @@ export class Main {
this.singleUserStateProvider,
);
this.derivedStateProvider = new DefaultDerivedStateProvider(storageServiceProvider);
this.derivedStateProvider = new DefaultDerivedStateProvider();
this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider,
@ -344,6 +345,7 @@ export class Main {
this.storageService,
this.logService,
new MigrationBuilderService(),
ClientType.Cli,
);
this.stateService = new StateService(
@ -708,11 +710,7 @@ export class Main {
this.providerApiService = new ProviderApiService(this.apiService);
this.userKeyInitService = new UserKeyInitService(
this.accountService,
this.cryptoService,
this.logService,
);
this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
}
async run() {
@ -733,7 +731,7 @@ export class Main {
this.authService.logOut(() => {
/* Do nothing */
});
const userId = await this.stateService.getUserId();
const userId = (await this.stateService.getUserId()) as UserId;
await Promise.all([
this.eventUploadService.uploadEvents(userId as UserId),
this.syncService.setLastSync(new Date(0)),
@ -744,9 +742,10 @@ export class Main {
this.passwordGenerationService.clear(),
]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId);
await this.stateEventRunnerService.handleEvent("logout", userId);
await this.stateService.clean();
await this.accountService.clean(userId);
process.env.BW_SESSION = null;
}
@ -756,7 +755,11 @@ export class Main {
this.containerService.attachToGlobal(global);
await this.i18nService.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";
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 { LockComponent } from "../auth/lock.component";
import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component";
@ -40,7 +40,7 @@ const routes: Routes = [
{
path: "login",
component: LoginComponent,
canActivate: [LoginGuard],
canActivate: [maxAccountsGuardFn()],
},
{
path: "login-with-device",

View File

@ -8,7 +8,7 @@ import {
ViewContainerRef,
} from "@angular/core";
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 { 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 { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.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 { 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 { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@ -107,11 +107,11 @@ export class AppComponent implements OnInit, OnDestroy {
loading = false;
private lastActivity: number = null;
private lastActivity: Date = null;
private modal: ModalRef = null;
private idleTimer: number = null;
private isIdle = false;
private activeUserId: string = null;
private activeUserId: UserId = null;
private destroy$ = new Subject<void>();
@ -150,12 +150,12 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService,
private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
) {}
ngOnInit() {
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => {
this.activeUserId = userId;
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = account?.id;
});
this.ngZone.runOutsideAngular(() => {
@ -400,7 +400,8 @@ export class AppComponent implements OnInit, OnDestroy {
break;
case "switchAccount": {
if (message.userId != null) {
await this.stateService.setActiveUser(message.userId);
await this.stateService.clearDecryptedData(message.userId);
await this.accountService.switchAccount(message.userId);
}
const locked =
(await this.authService.getAuthStatus(message.userId)) ===
@ -522,7 +523,7 @@ export class AppComponent implements OnInit, OnDestroy {
private async updateAppMenu() {
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) {
updateRequest = {
accounts: null,
@ -531,32 +532,32 @@ export class AppComponent implements OnInit, OnDestroy {
} else {
const accounts: { [userId: string]: MenuAccount } = {};
for (const i in stateAccounts) {
const userId = i as UserId;
if (
i != null &&
stateAccounts[i]?.profile?.userId != null &&
!this.isAccountCleanUpInProgress(stateAccounts[i].profile.userId) // skip accounts that are being cleaned up
userId != null &&
!this.isAccountCleanUpInProgress(userId) // skip accounts that are being cleaned up
) {
const userId = stateAccounts[i].profile.userId;
const availableTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
);
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
accounts[userId] = {
isAuthenticated: await this.stateService.getIsAuthenticated({
userId: userId,
}),
isLocked:
(await this.authService.getAuthStatus(userId)) === AuthenticationStatus.Locked,
isAuthenticated: authStatus >= AuthenticationStatus.Locked,
isLocked: authStatus === AuthenticationStatus.Locked,
isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock),
email: stateAccounts[i].profile.email,
userId: stateAccounts[i].profile.userId,
email: stateAccounts[userId].email,
userId: userId,
hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId),
};
}
}
updateRequest = {
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) {
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)
// 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);
let preLogoutActiveUserId;
const nextUpAccount = await firstValueFrom(this.accountService.nextUpAccount$);
try {
// 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.cryptoService.clearKeys(userBeingLoggedOut);
await this.cipherService.clear(userBeingLoggedOut);
@ -582,22 +586,23 @@ export class AppComponent implements OnInit, OnDestroy {
await this.collectionService.clear(userBeingLoggedOut);
await this.passwordGenerationService.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;
await this.stateService.clean({ userId: userBeingLoggedOut });
await this.accountService.clean(userBeingLoggedOut);
} finally {
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.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["login"]);
} else if (preLogoutActiveUserId !== this.activeUserId) {
this.messagingService.send("switchAccount");
} else if (preLogoutActiveUserId !== nextUpAccount.id) {
this.messagingService.send("switchAccount", { userId: nextUpAccount.id });
}
await this.updateAppMenu();
@ -622,13 +627,13 @@ export class AppComponent implements OnInit, OnDestroy {
return;
}
const now = new Date().getTime();
if (this.lastActivity != null && now - this.lastActivity < 250) {
const now = new Date();
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return;
}
this.lastActivity = now;
await this.stateService.setLastActive(now, { userId: this.activeUserId });
await this.accountService.setAccountActivity(this.activeUserId, now);
// Idle states
if (this.isIdle) {

View File

@ -1,110 +1,112 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<button
class="account-switcher"
(click)="toggle()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
[hidden]="!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"
<ng-container *ngIf="view$ | async as view">
<button
class="account-switcher"
(click)="toggle()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
[hidden]="!view.showSwitcher"
aria-haspopup="dialog"
>
<div class="accounts" *ngIf="numberOfAccounts > 0">
<button
*ngFor="let account of 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="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 *ngIf="view.activeAccount; else noActiveAccount">
<app-avatar
[text]="view.activeAccount.name ?? view.activeAccount.email"
[id]="view.activeAccount.id"
[color]="view.activeAccount.avatarColor"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
*ngIf="view.activeAccount.email != null"
aria-hidden="true"
></app-avatar>
<div class="active-account">
<div>{{ view.activeAccount.email }}</div>
<span>{{ view.activeAccount.server }}</span>
<span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span>
</div>
</ng-container>
</div>
</ng-template>
<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]="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 { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component } from "@angular/core";
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 { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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";
type ActiveAccount = {
@ -52,12 +50,18 @@ type InactiveAccount = ActiveAccount & {
]),
],
})
export class AccountSwitcherComponent implements OnInit, OnDestroy {
activeAccount?: ActiveAccount;
inactiveAccounts: { [userId: string]: InactiveAccount } = {};
export class AccountSwitcherComponent {
activeAccount$: Observable<ActiveAccount | null>;
inactiveAccounts$: Observable<{ [userId: string]: InactiveAccount }>;
authStatus = AuthenticationStatus;
view$: Observable<{
activeAccount: ActiveAccount | null;
inactiveAccounts: { [userId: string]: InactiveAccount };
numberOfAccounts: number;
showSwitcher: boolean;
}>;
isOpen = false;
overlayPosition: ConnectedPosition[] = [
{
@ -68,21 +72,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
},
];
private destroy$ = new Subject<void>();
showSwitcher$: Observable<boolean>;
get showSwitcher() {
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;
}
numberOfAccounts$: Observable<number>;
constructor(
private stateService: StateService,
@ -90,37 +82,65 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
private avatarService: AvatarService,
private messagingService: MessagingService,
private router: Router,
private tokenService: TokenService,
private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction,
) {}
private accountService: AccountService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
if (active == null) {
return null;
}
async ngOnInit(): Promise<void> {
this.stateService.accounts$
.pipe(
concatMap(async (accounts: { [userId: string]: Account }) => {
this.inactiveAccounts = await this.createInactiveAccounts(accounts);
return {
id: active.id,
name: active.name,
email: active.email,
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.activeAccount = {
id: await this.tokenService.getUserId(),
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()),
email: await this.tokenService.getEmail(),
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (await this.environmentService.getEnvironment())?.getHostname(),
};
} catch {
this.activeAccount = undefined;
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.view$ = combineLatest([
this.activeAccount$,
this.inactiveAccounts$,
this.numberOfAccounts$,
this.showSwitcher$,
]).pipe(
map(([activeAccount, inactiveAccounts, numberOfAccounts, showSwitcher]) => ({
activeAccount,
inactiveAccounts,
numberOfAccounts,
showSwitcher,
})),
);
}
toggle() {
@ -144,11 +164,13 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
await this.loginEmailService.saveEmailSettings();
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: {
[userId: string]: Account;
[userId: string]: AccountInfo;
}): Promise<{ [userId: string]: InactiveAccount }> {
const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
@ -159,8 +181,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
inactiveAccounts[userId] = {
id: userId,
name: baseAccounts[userId].profile.name,
email: baseAccounts[userId].profile.email,
name: baseAccounts[userId].name,
email: baseAccounts[userId].email,
authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
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 { 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";
@ -18,7 +18,7 @@ export class SearchComponent implements OnInit, OnDestroy {
constructor(
private searchBarService: SearchBarService,
private stateService: StateService,
private accountService: AccountService,
) {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.state$.subscribe((state) => {
@ -33,7 +33,7 @@ export class SearchComponent implements OnInit, OnDestroy {
ngOnInit() {
// 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.searchText.patchValue("");
});

View File

@ -1,10 +1,12 @@
import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.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 { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.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 { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.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 { 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 { I18nRendererService } from "../../platform/services/i18n.renderer.service";
@ -36,7 +39,8 @@ export class InitService {
private nativeMessagingService: NativeMessagingService,
private themingService: AbstractThemingService,
private encryptService: EncryptService,
private userKeyInitService: UserKeyInitService,
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
private accountService: AccountService,
@Inject(DOCUMENT) private document: Document,
) {}
@ -44,7 +48,18 @@ export class InitService {
return async () => {
this.nativeMessagingService.init();
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.
// eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@ -15,6 +15,7 @@ import {
SafeInjectionToken,
STATE_FACTORY,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
@ -25,6 +26,7 @@ import { KdfConfigService as KdfConfigServiceAbstraction } from "@bitwarden/comm
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@ -57,7 +59,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService } from "@bitwarden/components";
import { LoginGuard } from "../../auth/guards/login.guard";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { Account } from "../../models/account";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@ -100,7 +101,6 @@ const safeProviders: SafeProvider[] = [
safeProvider(InitService),
safeProvider(NativeMessagingService),
safeProvider(SearchBarService),
safeProvider(LoginGuard),
safeProvider(DialogService),
safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => void>,
@ -190,6 +190,7 @@ const safeProviders: SafeProvider[] = [
AutofillSettingsServiceAbstraction,
VaultTimeoutSettingsService,
BiometricStateService,
AccountServiceAbstraction,
],
}),
safeProvider({
@ -275,6 +276,10 @@ const safeProviders: SafeProvider[] = [
useClass: NativeMessagingManifestService,
deps: [],
}),
safeProvider({
provide: CLIENT_TYPE,
useValue: ClientType.Desktop,
}),
];
@NgModule({

View File

@ -4,6 +4,7 @@ import { ActivatedRoute } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -59,6 +60,10 @@ describe("GeneratorComponent", () => {
provide: CipherService,
useValue: mock<CipherService>(),
},
{
provide: AccountService,
useValue: mock<AccountService>(),
},
],
schemas: [NO_ERRORS_SCHEMA],
}).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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -34,6 +35,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService: DialogService,
formBuilder: FormBuilder,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) {
super(
i18nService,
@ -49,6 +51,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService,
formBuilder,
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 { 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 { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@ -50,7 +51,7 @@ describe("LockComponent", () => {
let component: LockComponent;
let fixture: ComponentFixture<LockComponent>;
let stateServiceMock: MockProxy<StateService>;
const biometricStateService = mock<BiometricStateService>();
let biometricStateService: MockProxy<BiometricStateService>;
let messagingServiceMock: MockProxy<MessagingService>;
let broadcasterServiceMock: MockProxy<BroadcasterService>;
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
@ -62,7 +63,6 @@ describe("LockComponent", () => {
beforeEach(async () => {
stateServiceMock = mock<StateService>();
stateServiceMock.activeAccount$ = of(null);
messagingServiceMock = mock<MessagingService>();
broadcasterServiceMock = mock<BroadcasterService>();
@ -73,6 +73,7 @@ describe("LockComponent", () => {
mockMasterPasswordService = new FakeMasterPasswordService();
biometricStateService = mock();
biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false);
biometricStateService.promptAutomatically$ = of(false);
biometricStateService.promptCancelled$ = of(false);
@ -165,6 +166,10 @@ describe("LockComponent", () => {
provide: AccountService,
useValue: accountService,
},
{
provide: AuthService,
useValue: mock(),
},
{
provide: 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 { 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 { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@ -64,6 +65,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService: PinCryptoServiceAbstraction,
biometricStateService: BiometricStateService,
accountService: AccountService,
authService: AuthService,
kdfConfigService: KdfConfigService,
) {
super(
@ -89,6 +91,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService,
biometricStateService,
accountService,
authService,
kdfConfigService,
);
}

View File

@ -6,6 +6,7 @@ import { Subject, firstValueFrom } from "rxjs";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { ClientType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@ -157,7 +158,7 @@ export class Main {
activeUserStateProvider,
singleUserStateProvider,
globalStateProvider,
new DefaultDerivedStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(),
);
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);
@ -190,6 +191,7 @@ export class Main {
this.storageService,
this.logService,
new MigrationBuilderService(),
ClientType.Desktop,
);
// TODO: this state service will have access to on disk storage, but not in memory storage.

View File

@ -65,9 +65,10 @@ export class Menubar {
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 =
updateRequest?.accounts[updateRequest.activeUserId]?.hasMasterPassword ?? false;
updateRequest?.accounts?.[updateRequest.activeUserId]?.hasMasterPassword ?? false;
this.items = [
new FileMenu(

View File

@ -164,7 +164,7 @@ export class DesktopSettingsService {
/**
* Sets the setting for whether or not the application should be shown in the dock.
* @param value `true` if the application should should in the dock, `false` if it should not.
* @param value `true` if the application should show in the dock, `false` if it should not.
*/
async setAlwaysShowDock(value: boolean) {
await this.alwaysShowDockState.update(() => value);

View File

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

View File

@ -2,7 +2,7 @@ import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router } from "@angular/router";
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 { 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 { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.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 { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
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",
})
export class AppComponent implements OnDestroy, OnInit {
private lastActivity: number = null;
private lastActivity: Date = null;
private idleTimer: number = null;
private isIdle = false;
private destroy$ = new Subject<void>();
@ -86,6 +87,7 @@ export class AppComponent implements OnDestroy, OnInit {
private stateEventRunnerService: StateEventRunnerService,
private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
) {}
ngOnInit() {
@ -298,15 +300,16 @@ export class AppComponent implements OnDestroy, OnInit {
}
private async recordActivity() {
const now = new Date().getTime();
if (this.lastActivity != null && now - this.lastActivity < 250) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const now = new Date();
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return;
}
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.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.stateService.setLastActive(now);
await this.accountService.setAccountActivity(activeUserId, now);
// Idle states
if (this.isIdle) {
this.isIdle = false;

View File

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

View File

@ -13,10 +13,12 @@ import {
OBSERVABLE_DISK_LOCAL_STORAGE,
WINDOW,
SafeInjectionToken,
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -157,6 +159,10 @@ const safeProviders: SafeProvider[] = [
new DefaultThemeStateService(globalStateProvider, ThemeType.Light),
deps: [GlobalStateProvider],
}),
safeProvider({
provide: CLIENT_TYPE,
useValue: ClientType.Web,
}),
];
@NgModule({

View File

@ -1,17 +1,19 @@
import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.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 { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.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 { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
@ -28,14 +30,21 @@ export class InitService {
private cryptoService: CryptoServiceAbstraction,
private themingService: AbstractThemingService,
private encryptService: EncryptService,
private userKeyInitService: UserKeyInitService,
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
private accountService: AccountService,
@Inject(DOCUMENT) private document: Document,
) {}
init() {
return async () => {
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);
await this.vaultTimeoutService.init(true);

View File

@ -58,7 +58,7 @@
[bitMenuTriggerFor]="accountMenu"
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>
<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"
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">
<span>{{ "loggedInAs" | i18n }}</span>
<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 { 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 { 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 { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AccountProfile } from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid";
@Component({
selector: "app-header",
@ -28,7 +29,7 @@ export class WebHeaderComponent {
@Input() icon: string;
protected routeData$: Observable<{ titleId: string }>;
protected account$: Observable<AccountProfile>;
protected account$: Observable<User & { id: UserId }>;
protected canLock$: Observable<boolean>;
protected selfHosted: boolean;
protected hostname = location.hostname;
@ -38,12 +39,12 @@ export class WebHeaderComponent {
constructor(
private route: ActivatedRoute,
private stateService: StateService,
private platformUtilsService: PlatformUtilsService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private messagingService: MessagingService,
protected unassignedItemsBannerService: UnassignedItemsBannerService,
private configService: ConfigService,
private accountService: AccountService,
) {
this.routeData$ = this.route.data.pipe(
map((params) => {
@ -55,14 +56,7 @@ export class WebHeaderComponent {
this.selfHosted = this.platformUtilsService.isSelfHost();
this.account$ = combineLatest([
this.stateService.activeAccount$,
this.stateService.accounts$,
]).pipe(
map(([activeAccount, accounts]) => {
return accounts[activeAccount]?.profile;
}),
);
this.account$ = this.accountService.activeAccount$;
this.canLock$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));

View File

@ -1,3 +1,4 @@
import { ClientType } from "@bitwarden/common/enums";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -14,7 +15,7 @@ export class WebMigrationRunner extends MigrationRunner {
migrationBuilderService: MigrationBuilderService,
private diskLocalStorage: WindowStorageService,
) {
super(diskStorage, logService, migrationBuilderService);
super(diskStorage, logService, migrationBuilderService, ClientType.Web);
}
override async run(): Promise<void> {
@ -46,7 +47,7 @@ class WebMigrationHelper extends MigrationHelper {
storageService: WindowStorageService,
logService: LogService,
) {
super(currentVersion, storageService, logService, "web-disk-local");
super(currentVersion, storageService, logService, "web-disk-local", ClientType.Web);
this.diskLocalStorageService = storageService;
}

View File

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

View File

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

View File

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

@ -18,7 +18,8 @@
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-w-full after:tw-h-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
[rel]="[externalURL ? 'noopener noreferrer' : null]"
rel="noopener noreferrer"
target="_blank"
>
{{ linkText }}
</a>

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">
<p class="tw-mt-8" *ngIf="!loading">
{{ "projectPeopleDescription" | i18n }}
@ -19,3 +19,9 @@
</button>
</div>
</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 { FormControl, FormGroup } from "@angular/forms";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -37,11 +37,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
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, "projects"]);
return EMPTY;
catchError(async () => {
await this.router.navigate(["/sm", this.organizationId, "projects"]);
return [];
}),
);
@ -99,17 +97,20 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) {
return;
}
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
const showAccessRemovalWarning =
await this.accessPolicySelectorService.showAccessRemovalWarning(
this.organizationId,
this.formGroup.value.accessPolicies,
formValues,
);
if (showAccessRemovalWarning) {
const confirmed = await this.showWarning();
if (!confirmed) {
this.setSelected(this.currentAccessPolicies);
this.formGroup.enable();
return;
}
}
@ -117,7 +118,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
try {
const projectPeopleView = convertToProjectPeopleAccessPoliciesView(
this.projectId,
this.formGroup.value.accessPolicies,
formValues,
);
const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies(
this.projectId,
@ -126,9 +127,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
if (showAccessRemovalWarning) {
// 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, "projects"]);
await this.router.navigate(["sm", this.organizationId, "projects"]);
}
this.platformUtilsService.showToast(
"success",
@ -139,6 +138,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies);
}
this.formGroup.enable();
};
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">
<p class="tw-mt-8" *ngIf="!loading">
{{ "machineAccountPeopleDescription" | i18n }}
@ -20,3 +20,9 @@
</button>
</div>
</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 { FormControl, FormGroup } from "@angular/forms";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -40,12 +40,6 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
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(
@ -101,29 +95,32 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
if (this.isFormInvalid()) {
return;
}
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
const showAccessRemovalWarning =
await this.accessPolicySelectorService.showAccessRemovalWarning(
this.organizationId,
this.formGroup.value.accessPolicies,
formValues,
);
if (
await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies)
) {
this.formGroup.enable();
return;
}
try {
const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies(
this.serviceAccountId,
this.formGroup.value.accessPolicies,
formValues,
);
await this.handleAccessTokenAvailableWarning(
showAccessRemovalWarning,
this.currentAccessPolicies,
this.formGroup.value.accessPolicies,
formValues,
);
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
@ -137,6 +134,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies);
}
this.formGroup.enable();
};
private setSelected(policiesToSelect: ApItemViewType[]) {
@ -198,9 +196,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
selectedPolicies: ApItemValueType[],
): Promise<void> {
if (showAccessRemovalWarning) {
// 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"]);
await this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
} else if (
this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies)
) {

View File

@ -55,6 +55,7 @@
bitIconButton="bwi-close"
buttonType="main"
size="default"
[disabled]="disabled"
[attr.title]="'remove' | i18n"
[attr.aria-label]="'remove' | i18n"
(click)="selectionList.deselectItem(item.id); handleBlur()"
@ -84,7 +85,14 @@
</bit-form-field>
<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 }}
</button>
</div>

View File

@ -1,7 +1,7 @@
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
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 { 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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
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 { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
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 { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
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 { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components";
@ -46,6 +49,7 @@ export class LockComponent implements OnInit, OnDestroy {
supportsBiometric: boolean;
biometricLock: boolean;
private activeUserId: UserId;
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
protected onSuccessfulSubmit: () => Promise<void>;
@ -80,14 +84,16 @@ export class LockComponent implements OnInit, OnDestroy {
protected pinCryptoService: PinCryptoServiceAbstraction,
protected biometricStateService: BiometricStateService,
protected accountService: AccountService,
protected authService: AuthService,
protected kdfConfigService: KdfConfigService,
) {}
async ngOnInit() {
this.stateService.activeAccount$
this.accountService.activeAccount$
.pipe(
concatMap(async () => {
await this.load();
concatMap(async (account) => {
this.activeUserId = account?.id;
await this.load(account?.id);
}),
takeUntil(this.destroy$),
)
@ -116,7 +122,7 @@ export class LockComponent implements OnInit, OnDestroy {
});
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
// 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.
// 2. If locking IS a valid timeout action, we proceed to show the user the lock screen.
// 1. If the user is unlocked, we're here in error so we navigate to the home page
// 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:
// - 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 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(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
);
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) {
return await this.vaultTimeoutService.logOut();
return await this.vaultTimeoutService.logOut(userId);
}
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();

View File

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

View File

@ -1,6 +1,7 @@
import { InjectionToken } from "@angular/core";
import { Observable, Subject } from "rxjs";
import { ClientType } from "@bitwarden/common/enums";
import {
AbstractMemoryStorageService,
AbstractStorageService,
@ -52,3 +53,4 @@ export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<ThemeTy
export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken<Subject<Message<object>>>(
"INTRAPROCESS_MESSAGING_SUBJECT",
);
export const CLIENT_TYPE = new SafeInjectionToken<ClientType>("CLIENT_TYPE");

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 { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import {
AccountService,
AccountService as AccountServiceAbstraction,
InternalAccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
@ -162,7 +161,7 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
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 { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
@ -276,6 +275,7 @@ import {
SYSTEM_THEME_OBSERVABLE,
WINDOW,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
} from "./injection-tokens";
import { ModalService } from "./modal.service";
@ -1047,7 +1047,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DerivedStateProvider,
useClass: DefaultDerivedStateProvider,
deps: [StorageServiceProvider],
deps: [],
}),
safeProvider({
provide: StateProvider,
@ -1099,7 +1099,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: MigrationRunner,
useClass: MigrationRunner,
deps: [AbstractStorageService, LogService, MigrationBuilderService],
deps: [AbstractStorageService, LogService, MigrationBuilderService, CLIENT_TYPE],
}),
safeProvider({
provide: MigrationBuilderService,
@ -1127,9 +1127,9 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider],
}),
safeProvider({
provide: UserKeyInitService,
useClass: UserKeyInitService,
deps: [AccountService, CryptoServiceAbstraction, LogService],
provide: UserAutoUnlockKeyService,
useClass: UserAutoUnlockKeyService,
deps: [CryptoServiceAbstraction],
}),
safeProvider({
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 { 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -118,6 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected dialogService: DialogService,
protected formBuilder: FormBuilder,
protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
) {
this.typeOptions = [
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
@ -215,7 +217,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
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;
if (this.send == null) {

View File

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

View File

@ -218,7 +218,7 @@ describe("LoginStrategy", () => {
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();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
@ -228,7 +228,8 @@ describe("LoginStrategy", () => {
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
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();
});

View File

@ -169,6 +169,12 @@ export abstract class LoginStrategy {
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ 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
// User id will be derived from the access token.
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.
);
await this.accountService.switchAccount(userId);
await this.stateService.addAccount(
new Account({
profile: {

View File

@ -164,6 +164,7 @@ describe("PasswordLoginStrategy", () => {
masterPasswordService.masterKeySubject.next(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
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 () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
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 () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const token2FAResponse = new IdentityTwoFactorResponse({
TwoFactorProviders: ["0"],

View File

@ -244,7 +244,7 @@ export class SsoLoginStrategy extends LoginStrategy {
// Only try to set user key with device key if admin approval request was not successful
if (!hasUserKey) {
await this.trySetUserKeyWithDeviceKey(tokenResponse);
await this.trySetUserKeyWithDeviceKey(tokenResponse, userId);
}
} else if (
masterKeyEncryptedUserKey != null &&
@ -312,11 +312,12 @@ export class SsoLoginStrategy extends LoginStrategy {
}
}
private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise<void> {
private async trySetUserKeyWithDeviceKey(
tokenResponse: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption;
const userId = (await this.stateService.getUserId()) as UserId;
const deviceKey = await this.deviceTrustService.getDeviceKey(userId);
const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey;
const encUserKey = trustedDeviceOption?.encryptedUserKey;

View File

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

View File

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

@ -249,11 +249,11 @@ export class FakeDerivedStateProvider implements DerivedStateProvider {
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
let result = this.states.get(deriveDefinition.buildCacheKey("memory")) as DerivedState<TTo>;
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
if (result == null) {
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
this.states.set(deriveDefinition.buildCacheKey("memory"), result);
this.states.set(deriveDefinition.buildCacheKey(), result);
}
return result;
}

View File

@ -5,3 +5,4 @@ export * from "./fake-state-provider";
export * from "./fake-state";
export * from "./fake-account-service";
export * from "./fake-storage.service";
export * from "./observable-tracker";

View File

@ -16,9 +16,11 @@ export class ObservableTracker<T> {
/**
* Awaits the next emission from the observable, or throws if the timeout is exceeded
* @param msTimeout The maximum time to wait for another emission before throwing
* @returns The next emission from the observable
* @throws If the timeout is exceeded
*/
async expectEmission(msTimeout = 50) {
await firstValueFrom(
async expectEmission(msTimeout = 50): Promise<T> {
return await firstValueFrom(
this.observable.pipe(
timeout({
first: msTimeout,
@ -28,7 +30,7 @@ export class ObservableTracker<T> {
);
}
/** Awaits until the the total number of emissions observed by this tracker equals or exceeds {@link count}
/** Awaits until the total number of emissions observed by this tracker equals or exceeds {@link count}
* @param count The number of emissions to wait for
*/
async pauseUntilReceived(count: number, msTimeout = 50): Promise<T[]> {

View File

@ -8,18 +8,44 @@ import { UserId } from "../../types/guid";
*/
export type AccountInfo = {
email: string;
emailVerified: boolean;
name: string | undefined;
};
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 {
accounts$: Observable<Record<UserId, 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.
*
* @note Also sets the last active date of the account to `now`.
* @param userId
* @param accountData
*/
@ -36,11 +62,30 @@ export abstract class AccountService {
* @param email
*/
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.
* @param userId
*/
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 {

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 { firstValueFrom } from "rxjs";
@ -6,15 +11,57 @@ import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service";
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
AccountServiceImplementation,
} 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", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
@ -22,8 +69,8 @@ describe("accountService", () => {
let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>;
const userId = "userId" as UserId;
const userInfo = { email: "email", name: "name" };
const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name", emailVerified: true };
beforeEach(() => {
messagingService = mock();
@ -86,6 +133,25 @@ describe("accountService", () => {
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", () => {
@ -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", () => {
beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo });
@ -152,4 +270,83 @@ describe("accountService", () => {
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
});
});
describe("account activity", () => {
let state: FakeGlobalState<Record<UserId, Date>>;
beforeEach(() => {
state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
});
describe("accountActivity$", () => {
it("returns the account activity state", async () => {
state.stateSubject.next({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
});
});
it("returns an empty object when account activity is null", async () => {
state.stateSubject.next(null);
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({});
});
});
describe("sortedUserIds$", () => {
it("returns the sorted user ids by date 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 {
AccountInfo,
@ -7,8 +7,9 @@ import {
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import {
ACCOUNT_MEMORY,
ACCOUNT_DISK,
GlobalState,
GlobalStateProvider,
KeyDefinition,
@ -16,25 +17,36 @@ import {
import { UserId } from "../../types/guid";
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_MEMORY,
ACCOUNT_DISK,
"accounts",
{
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,
});
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 {
private lock = new Subject<UserId>();
private logout = new Subject<UserId>();
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$;
activeAccount$;
accountActivity$;
sortedUserIds$;
nextUpAccount$;
constructor(
private messagingService: MessagingService,
@ -53,14 +65,40 @@ export class AccountServiceImplementation implements InternalAccountService {
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.accountActivity$ = this.globalStateProvider
.get(ACCOUNT_ACTIVITY)
.state$.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> {
if (!Utils.isGuid(userId)) {
throw new Error("userId is required");
}
await this.accountsState.update((accounts) => {
accounts ||= {};
accounts[userId] = accountData;
return accounts;
});
await this.setAccountActivity(userId, new Date());
}
async setAccountName(userId: UserId, name: string): Promise<void> {
@ -71,6 +109,15 @@ export class AccountServiceImplementation implements InternalAccountService {
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> {
await this.activeAccountIdState.update(
(_, 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
async delete(): Promise<void> {
try {

View File

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

View File

@ -2,6 +2,7 @@ import {
Observable,
combineLatest,
distinctUntilChanged,
firstValueFrom,
map,
of,
shareReplay,
@ -12,6 +13,7 @@ import { ApiService } from "../../abstractions/api.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { AccountService } from "../abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
@ -39,13 +41,16 @@ export class AuthService implements AuthServiceAbstraction {
this.authStatuses$ = this.accountService.accounts$.pipe(
map((accounts) => Object.keys(accounts) as UserId[]),
switchMap((entries) =>
combineLatest(
switchMap((entries) => {
if (entries.length === 0) {
return of([] as { userId: UserId; status: AuthenticationStatus }[]);
}
return combineLatest(
entries.map((userId) =>
this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))),
),
),
),
);
}),
map((statuses) => {
return statuses.reduce(
(acc, { userId, status }) => {
@ -59,7 +64,7 @@ export class AuthService implements AuthServiceAbstraction {
}
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
if (userId == null) {
if (!Utils.isGuid(userId)) {
return of(AuthenticationStatus.LoggedOut);
}
@ -84,17 +89,8 @@ export class AuthService implements AuthServiceAbstraction {
}
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
// If we don't have an access token or userId, we're logged out
const isAuthenticated = await this.stateService.getIsAuthenticated({ userId: 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;
userId ??= await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
return await firstValueFrom(this.authStatusFor$(userId as UserId));
}
logOut(callback: () => void) {

View File

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

View File

@ -252,7 +252,7 @@ export class TokenService implements TokenServiceAbstraction {
if (!accessTokenKey) {
// If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet
// and we have to return null here to properly indicate the the user isn't logged in.
// and we have to return null here to properly indicate the user isn't logged in.
return null;
}

View File

@ -58,4 +58,16 @@ export class SelfHostedOrganizationSubscriptionView implements View {
get isExpiredAndOutsideGracePeriod() {
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

@ -223,7 +223,7 @@ export abstract class CryptoService {
*/
abstract makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]>;
/**
* Sets the the user's encrypted private key in storage and
* Sets the user's encrypted private key in storage and
* clears the decrypted private key from memory
* Note: does not clear the private key if null is provided
* @param encPrivateKey An encrypted private key

View File

@ -25,11 +25,10 @@ export type InitOptions = {
export abstract class StateService<T extends Account = Account> {
accounts$: Observable<{ [userId: string]: T }>;
activeAccount$: Observable<string>;
addAccount: (account: T) => Promise<void>;
setActiveUser: (userId: string) => Promise<void>;
clean: (options?: StorageOptions) => Promise<UserId>;
clearDecryptedData: (userId: UserId) => Promise<void>;
clean: (options?: StorageOptions) => 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>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
@ -147,8 +144,6 @@ export abstract class StateService<T extends Account = Account> {
*/
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
getLastActive: (options?: StorageOptions) => Promise<number>;
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
getLastSync: (options?: StorageOptions) => Promise<string>;
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
@ -180,5 +175,4 @@ export abstract class StateService<T extends Account = Account> {
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
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";
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", () => {
it("should fail for invalid urls", () => {
expect(Utils.getDomain(null)).toBeNull();

View File

@ -10,7 +10,7 @@ import { CryptoService } from "../abstractions/crypto.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { I18nService } from "../abstractions/i18n.service";
const nodeURL = typeof window === "undefined" ? require("url") : null;
const nodeURL = typeof self === "undefined" ? require("url") : null;
declare global {
/* eslint-disable-next-line no-var */

View File

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

View File

@ -100,7 +100,7 @@ export class CryptoService implements CryptoServiceAbstraction {
USER_PRIVATE_KEY,
{
encryptService: this.encryptService,
cryptoService: this,
getUserKey: (userId) => this.getUserKey(userId),
},
);
this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null
@ -738,13 +738,23 @@ export class CryptoService implements CryptoServiceAbstraction {
// Can decrypt private key
const privateKey = await USER_PRIVATE_KEY.derive([userId, encPrivateKey], {
encryptService: this.encryptService,
cryptoService: this,
getUserKey: () => Promise.resolve(key),
});
if (privateKey == null) {
// failed to decrypt
return false;
}
// Can successfully derive public key
await USER_PUBLIC_KEY.derive(privateKey, {
const publicKey = await USER_PUBLIC_KEY.derive(privateKey, {
cryptoFunctionService: this.cryptoFunctionService,
});
if (publicKey == null) {
// failed to decrypt
return false;
}
} catch (e) {
return false;
}

View File

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

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