Merge branch 'main' into SM-1192-SecretsNames

This commit is contained in:
cd-bitwarden 2024-04-30 16:04:23 -04:00 committed by GitHub
commit a55e5778f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
151 changed files with 3217 additions and 1402 deletions

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,6 +30,19 @@ 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) {
@ -258,8 +271,19 @@ function applyBetaLabels(manifest) {
manifest.short_name = "Bitwarden BETA";
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
if (process.env.GITHUB_RUN_ID) {
manifest.version_name = `${manifest.version} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
manifest.version = `${manifest.version}.${parseInt(process.env.GITHUB_RUN_ID.slice(-4))}`;
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`;
}

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

@ -4,40 +4,29 @@ import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { BrowserApi } from "../../platform/browser/browser-api";
export default class WebRequestBackground {
private pendingAuthRequests: any[] = [];
private webRequest: any;
private pendingAuthRequests: Set<string> = new Set<string>([]);
private isFirefox: boolean;
constructor(
platformUtilsService: PlatformUtilsService,
private cipherService: CipherService,
private authService: AuthService,
private readonly webRequest: typeof chrome.webRequest,
) {
if (BrowserApi.isManifestVersion(2)) {
this.webRequest = chrome.webRequest;
}
this.isFirefox = platformUtilsService.isFirefox();
}
async init() {
if (!this.webRequest || !this.webRequest.onAuthRequired) {
return;
}
startListening() {
this.webRequest.onAuthRequired.addListener(
async (details: any, callback: any) => {
if (!details.url || this.pendingAuthRequests.indexOf(details.requestId) !== -1) {
async (details, callback) => {
if (!details.url || this.pendingAuthRequests.has(details.requestId)) {
if (callback) {
callback();
callback(null);
}
return;
}
this.pendingAuthRequests.push(details.requestId);
this.pendingAuthRequests.add(details.requestId);
if (this.isFirefox) {
// eslint-disable-next-line
return new Promise(async (resolve, reject) => {
@ -51,7 +40,7 @@ export default class WebRequestBackground {
[this.isFirefox ? "blocking" : "asyncBlocking"],
);
this.webRequest.onCompleted.addListener((details: any) => this.completeAuthRequest(details), {
this.webRequest.onCompleted.addListener((details) => this.completeAuthRequest(details), {
urls: ["http://*/*"],
});
this.webRequest.onErrorOccurred.addListener(
@ -91,10 +80,7 @@ export default class WebRequestBackground {
}
}
private completeAuthRequest(details: any) {
const i = this.pendingAuthRequests.indexOf(details.requestId);
if (i > -1) {
this.pendingAuthRequests.splice(i, 1);
}
private completeAuthRequest(details: chrome.webRequest.WebResponseCacheDetails) {
this.pendingAuthRequests.delete(details.requestId);
}
}

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;
@ -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);
@ -1053,20 +1056,17 @@ export default class MainBackground {
this.cipherService,
);
if (BrowserApi.isManifestVersion(2)) {
if (chrome.webRequest != null && chrome.webRequest.onAuthRequired != null) {
this.webRequestBackground = new WebRequestBackground(
this.platformUtilsService,
this.cipherService,
this.authService,
chrome.webRequest,
);
}
}
this.userKeyInitService = new UserKeyInitService(
this.accountService,
this.cryptoService,
this.logService,
);
this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
}
async bootstrap() {
@ -1077,7 +1077,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);
@ -1096,9 +1107,7 @@ export default class MainBackground {
await this.tabsBackground.init();
this.contextMenusBackground?.init();
await this.idleBackground.init();
if (BrowserApi.isManifestVersion(2)) {
await this.webRequestBackground.init();
}
this.webRequestBackground?.startListening();
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
// Set Private Mode windows to the default icon - they do not share state with the background page
@ -1159,7 +1168,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 +1210,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 +1245,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) => {
@ -76,7 +76,8 @@ export default class RuntimeBackground {
void this.processMessageWithSender(msg, sender).catch((err) =>
this.logService.error(
`Error while processing message in RuntimeBackground '${msg?.command}'. Error: ${err?.message ?? "Unknown Error"}`,
`Error while processing message in RuntimeBackground '${msg?.command}'.`,
err,
),
);
return false;
@ -85,7 +86,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 +112,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

@ -60,7 +60,9 @@
"clipboardWrite",
"idle",
"scripting",
"offscreen"
"offscreen",
"webRequest",
"webRequestAuthProvider"
],
"optional_permissions": ["nativeMessaging", "privacy"],
"host_permissions": ["<all_urls>"],

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

@ -28,6 +28,7 @@ describe("OffscreenDocument", () => {
});
it("shows a console message if the handler throws an error", async () => {
const error = new Error("test error");
browserClipboardServiceCopySpy.mockRejectedValueOnce(new Error("test error"));
sendExtensionRuntimeMessage({ command: "offscreenCopyToClipboard", text: "test" });
@ -35,7 +36,8 @@ describe("OffscreenDocument", () => {
expect(browserClipboardServiceCopySpy).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error resolving extension message response: Error: test error",
"Error resolving extension message response",
error,
);
});

View File

@ -71,7 +71,7 @@ class OffscreenDocument implements OffscreenDocumentInterface {
Promise.resolve(messageResponse)
.then((response) => sendResponse(response))
.catch((error) =>
this.consoleLogService.error(`Error resolving extension message response: ${error}`),
this.consoleLogService.error("Error resolving extension message response", error),
);
return true;
};

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

@ -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";
@ -558,6 +560,10 @@ const safeProviders: SafeProvider[] = [
OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE,
],
}),
safeProvider({
provide: CLIENT_TYPE,
useValue: ClientType.Browser,
}),
];
@NgModule({

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() {
@ -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

@ -2,13 +2,18 @@ import { interceptConsole, restoreConsole } from "@bitwarden/common/spec";
import { ConsoleLogService } from "./console-log.service";
let caughtMessage: any = {};
describe("CLI Console log service", () => {
const error = new Error("this is an error");
const obj = { a: 1, b: 2 };
let logService: ConsoleLogService;
let consoleSpy: {
log: jest.Mock<any, any>;
warn: jest.Mock<any, any>;
error: jest.Mock<any, any>;
};
beforeEach(() => {
caughtMessage = {};
interceptConsole(caughtMessage);
consoleSpy = interceptConsole();
logService = new ConsoleLogService(true);
});
@ -19,24 +24,21 @@ describe("CLI Console log service", () => {
it("should redirect all console to error if BW_RESPONSE env is true", () => {
process.env.BW_RESPONSE = "true";
logService.debug("this is a debug message");
expect(caughtMessage).toMatchObject({
error: { 0: "this is a debug message" },
});
logService.debug("this is a debug message", error, obj);
expect(consoleSpy.error).toHaveBeenCalledWith("this is a debug message", error, obj);
});
it("should not redirect console to error if BW_RESPONSE != true", () => {
process.env.BW_RESPONSE = "false";
logService.debug("debug");
logService.info("info");
logService.warning("warning");
logService.error("error");
logService.debug("debug", error, obj);
logService.info("info", error, obj);
logService.warning("warning", error, obj);
logService.error("error", error, obj);
expect(caughtMessage).toMatchObject({
log: { 0: "info" },
warn: { 0: "warning" },
error: { 0: "error" },
});
expect(consoleSpy.log).toHaveBeenCalledWith("debug", error, obj);
expect(consoleSpy.log).toHaveBeenCalledWith("info", error, obj);
expect(consoleSpy.warn).toHaveBeenCalledWith("warning", error, obj);
expect(consoleSpy.error).toHaveBeenCalledWith("error", error, obj);
});
});

View File

@ -6,17 +6,17 @@ export class ConsoleLogService extends BaseConsoleLogService {
super(isDev, filter);
}
write(level: LogLevelType, message: string) {
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
if (process.env.BW_RESPONSE === "true") {
// eslint-disable-next-line
console.error(message);
console.error(message, ...optionalParams);
return;
}
super.write(level, message);
super.write(level, message, ...optionalParams);
}
}

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";
@ -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

@ -103,7 +103,8 @@ export default {
isMacAppStore: isMacAppStore(),
isWindowsStore: isWindowsStore(),
reloadProcess: () => ipcRenderer.send("reload-process"),
log: (level: LogLevelType, message: string) => ipcRenderer.invoke("ipc.log", { level, message }),
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
openContextMenu: (
menu: {

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

@ -25,28 +25,28 @@ export class ElectronLogMainService extends BaseLogService {
}
log.initialize();
ipcMain.handle("ipc.log", (_event, { level, message }) => {
this.write(level, message);
ipcMain.handle("ipc.log", (_event, { level, message, optionalParams }) => {
this.write(level, message, ...optionalParams);
});
}
write(level: LogLevelType, message: string) {
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
switch (level) {
case LogLevelType.Debug:
log.debug(message);
log.debug(message, ...optionalParams);
break;
case LogLevelType.Info:
log.info(message);
log.info(message, ...optionalParams);
break;
case LogLevelType.Warning:
log.warn(message);
log.warn(message, ...optionalParams);
break;
case LogLevelType.Error:
log.error(message);
log.error(message, ...optionalParams);
break;
default:
break;

View File

@ -6,27 +6,29 @@ export class ElectronLogRendererService extends BaseLogService {
super(ipc.platform.isDev, filter);
}
write(level: LogLevelType, message: string) {
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
/* eslint-disable no-console */
ipc.platform.log(level, message).catch((e) => console.log("Error logging", e));
ipc.platform
.log(level, message, ...optionalParams)
.catch((e) => console.log("Error logging", e));
/* eslint-disable no-console */
switch (level) {
case LogLevelType.Debug:
console.debug(message);
console.debug(message, ...optionalParams);
break;
case LogLevelType.Info:
console.info(message);
console.info(message, ...optionalParams);
break;
case LogLevelType.Warning:
console.warn(message);
console.warn(message, ...optionalParams);
break;
case LogLevelType.Error:
console.error(message);
console.error(message, ...optionalParams);
break;
default:
break;

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

@ -0,0 +1,25 @@
import { svgIcon } from "@bitwarden/components";
export const ManageBilling = svgIcon`
<svg width="213" height="231" viewBox="0 0 213 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.089 85.6617C129.868 85.4299 129.604 85.2456 129.31 85.1197C129.016 84.9937 128.7 84.9299 128.381 84.9317H84.5811C84.2617 84.9299 83.9441 84.9937 83.6503 85.1197C83.3565 85.2456 83.0919 85.4299 82.8729 85.6617C82.6411 85.8807 82.4568 86.1471 82.3308 86.441C82.2049 86.7348 82.141 87.0505 82.1429 87.3699V116.57C82.152 118.793 82.5827 120.994 83.4131 123.056C84.2033 125.091 85.2654 127.011 86.5703 128.761C87.9117 130.515 89.4137 132.137 91.0562 133.612C92.58 135.01 94.186 136.318 95.8632 137.528C97.3232 138.565 98.8562 139.547 100.462 140.474C102.068 141.401 103.202 142.027 103.864 142.353C104.532 142.682 105.072 142.941 105.474 143.113C105.788 143.264 106.132 143.339 106.481 143.332C106.824 143.337 107.164 143.257 107.47 143.102C107.879 142.923 108.412 142.671 109.087 142.343C109.762 142.014 110.912 141.386 112.489 140.463C114.066 139.539 115.617 138.554 117.088 137.517C118.767 136.305 120.375 134.999 121.902 133.601C123.547 132.128 125.049 130.504 126.388 128.75C127.691 126.998 128.753 125.08 129.545 123.045C130.378 120.983 130.808 118.782 130.816 116.559V87.3589C130.817 87.0414 130.754 86.7275 130.628 86.4355C130.502 86.1435 130.319 85.8807 130.089 85.6617ZM124.443 116.836C124.443 127.421 106.481 136.513 106.481 136.513V91.1878H124.443V116.836Z" fill="#212529"/>
<path d="M62.7328 163.392C62.7328 168.149 51.6616 166.263 46.761 166.263C41.8605 166.263 22.5074 161.096 20.7328 153.058C23.6946 151.005 16.0004 143.298 31.9722 142.724C33.1529 141.759 44.9083 148.712 46.761 149.039C51.6616 149.039 62.7328 158.636 62.7328 163.392Z" fill="#E5E5E5"/>
<path d="M21.3544 122.3C21.4472 123.4 22.4147 124.217 23.5153 124.125C24.616 124.032 25.433 123.064 25.3402 121.964L21.3544 122.3ZM148.234 45.7444C149.303 45.4678 149.946 44.3767 149.669 43.3073L145.162 25.8808C144.885 24.8114 143.794 24.1687 142.725 24.4453C141.655 24.7219 141.013 25.813 141.289 26.8824L145.296 42.3726L129.805 46.3792C128.736 46.6558 128.093 47.7469 128.37 48.8163C128.647 49.8857 129.738 50.5283 130.807 50.2517L148.234 45.7444ZM25.3402 121.964C23.4116 99.0873 31.1986 75.5542 48.6989 58.0539L45.8705 55.2255C27.5023 73.5937 19.331 98.2998 21.3544 122.3L25.3402 121.964ZM48.6989 58.0539C75.2732 31.4796 115.769 27.3025 146.718 45.5314L148.748 42.0848C116.267 22.9532 73.7654 27.3305 45.8705 55.2255L48.6989 58.0539Z" fill="#212529"/>
<path d="M64.2075 185.062C63.1417 185.352 62.5129 186.451 62.8029 187.517L67.5298 204.885C67.8199 205.951 68.919 206.58 69.9848 206.29C71.0507 205.999 71.6795 204.9 71.3895 203.834L67.1878 188.396L82.6262 184.194C83.692 183.904 84.3209 182.805 84.0308 181.739C83.7408 180.674 82.6416 180.045 81.5758 180.335L64.2075 185.062ZM189.211 100.283C189.018 99.1952 187.98 98.4697 186.893 98.6625C185.805 98.8552 185.08 99.8931 185.272 100.981L189.211 100.283ZM162.871 172.225C136.546 198.55 96.5599 202.897 65.726 185.255L63.7396 188.727C96.0997 207.242 138.066 202.687 165.699 175.054L162.871 172.225ZM185.272 100.981C189.718 126.07 182.249 152.847 162.871 172.225L165.699 175.054C186.04 154.713 193.875 126.603 189.211 100.283L185.272 100.981Z" fill="#212529"/>
<path d="M34.4588 108.132C36.0159 92.1931 42.8984 76.6765 55.1062 64.4686C72.0222 47.5527 95.2911 40.8618 117.233 44.396" stroke="#212529" stroke-width="2" stroke-linecap="round"/>
<path d="M177.328 119.132C176.386 136.119 169.426 152.834 156.449 165.811C141.173 181.088 120.715 188.025 100.733 186.623" stroke="#212529" stroke-width="2" stroke-linecap="round"/>
<rect x="150.233" y="56.1318" width="49" height="34" rx="2.5" stroke="#212529" stroke-width="3"/>
<path d="M150.233 63.6318V63.6318C150.233 66.9455 152.919 69.6318 156.233 69.6318H169.242M199.233 63.6318V63.6318C199.233 66.9455 196.546 69.6318 193.233 69.6318H180.224" stroke="#212529" stroke-width="3"/>
<mask id="path-9-inside-1_873_6447" fill="white">
<rect x="168.733" y="65.6318" width="12" height="9" rx="1.25"/>
</mask>
<rect x="168.733" y="65.6318" width="12" height="9" rx="1.25" stroke="#212529" stroke-width="6" mask="url(#path-9-inside-1_873_6447)"/>
<path d="M183.733 54.6318C183.733 54.6318 183.733 53.6318 183.733 52.6318C183.733 51.6318 182.785 50.6318 181.838 50.6318C180.891 50.6318 168.575 50.6318 167.628 50.6318C166.68 50.6318 165.733 51.6318 165.733 52.6318C165.733 53.6318 165.733 54.6318 165.733 54.6318" stroke="#212529" stroke-width="3"/>
<circle cx="48.7328" cy="142.632" r="10.5" fill="white" stroke="#212529" stroke-width="3"/>
<path d="M65.7263 170.132H65.6454H65.5646H65.484H65.4036H65.3233H65.2432H65.1632H65.0834H65.0037H64.9242H64.8449H64.7657H64.6866H64.6077H64.529H64.4504H64.372H64.2937H64.2155H64.1376H64.0597H63.982H63.9045H63.8271H63.7498H63.6727H63.5957H63.5189H63.4422H63.3657H63.2893H63.213H63.1369H63.0609H62.985H62.9093H62.8338H62.7583H62.683H62.6079H62.5329H62.458H62.3832H62.3086H62.2341H62.1597H62.0855H62.0114H61.9374H61.8636H61.7899H61.7163H61.6428H61.5695H61.4963H61.4232H61.3503H61.2774H61.2047H61.1321H61.0597H60.9873H60.9151H60.843H60.771H60.6992H60.6274H60.5558H60.4843H60.4129H60.3416H60.2704H60.1994H60.1284H60.0576H59.9869H59.9163H59.8458H59.7754H59.7052H59.635H59.5649H59.495H59.4252H59.3554H59.2858H59.2163H59.1469H59.0776H59.0084H58.9393H58.8703H58.8013H58.7325H58.6638H58.5952H58.5267H58.4583H58.39H58.3218H58.2537H58.1857H58.1178H58.0499H57.9822H57.9146H57.847H57.7796H57.7122H57.6449H57.5777H57.5106H57.4436H57.3767H57.3099H57.2431H57.1765H57.1099H57.0434H56.977H56.9107H56.8444H56.7783H56.7122H56.6462H56.5803H56.5145H56.4487H56.383H56.3174H56.2519H56.1865H56.1211H56.0558H55.9906H55.9254H55.8603H55.7953H55.7304H55.6655H55.6008H55.536H55.4714H55.4068H55.3423H55.2778H55.2135H55.1492H55.0849H55.0207H54.9566H54.8925H54.8286H54.7646H54.7008H54.6369H54.5732H54.5095H54.4459H54.3823H54.3188H54.2553H54.1919H54.1286H54.0653H54.0021H53.9389H53.8758H53.8127H53.7497H53.6867H53.6238H53.5609H53.4981H53.4353H53.3726H53.3099H53.2473H53.1847H53.1222H53.0597H52.9972H52.9348H52.8725H52.8102H52.7479H52.6856H52.6234H52.5613H52.4992H52.4371H52.375H52.313H52.2511H52.1891H52.1272H52.0654H52.0036H51.9418H51.88H51.8183H51.7566H51.6949H51.6333H51.5717H51.5101H51.4485H51.387H51.3255H51.264H51.2026H51.1412H51.0798H51.0184H50.9571H50.8957H50.8344H50.7731H50.7119H50.6506H50.5894H50.5282H50.467H50.4058H50.3447H50.2836H50.2224H50.1613H50.1002H50.0392H49.9781H49.917H49.856H49.795H49.7339H49.6729H49.6119H49.5509H49.4899H49.429H49.368H49.307H49.246H49.1851H49.1241H49.0632H49.0022H48.9413H48.8803H48.8194H48.7584H48.6975H48.6365H48.5756H48.5146H48.4537H48.3927H48.3318H48.2708H48.2098H48.1488H48.0878H48.0268H47.9658H47.9048H47.8438H47.7828H47.7217H47.6607H47.5996H47.5385H47.4774H47.4163H47.3552H47.294H47.2329H47.1717H47.1105H47.0493H46.9881H46.9268H46.8656H46.8043H46.743H46.6816H46.6203H46.5589H46.4975H46.4361H46.3746H46.3132H46.2517H46.1901H46.1286H46.067H46.0054H45.9437H45.8821H45.8203H45.7586H45.6968H45.635H45.5732H45.5113H45.4494H45.3875H45.3255H45.2635H45.2015H45.1394H45.0772H45.0151H44.9529H44.8906H44.8283H44.766H44.7036H44.6412H44.5788H44.5163H44.4537H44.3911H44.3285H44.2658H44.2031H44.1403H44.0775H44.0146H43.9517H43.8887H43.8256H43.7626H43.6994H43.6362H43.573H43.5097H43.4463H43.3829H43.3195H43.2559H43.1924H43.1287H43.065H43.0013H42.9374H42.8736H42.8096H42.7456H42.6815H42.6174H42.5532H42.4889H42.4246H42.3602H42.2958H42.2312H42.1666H42.102H42.0373H41.9724H41.9076H41.8426H41.7776H41.7125H41.6474H41.5821H41.5168H41.4514H41.386H41.3204H41.2548H41.1891H41.1233H41.0575H40.9916H40.9255H40.8594H40.7933H40.727H40.6607H40.5943H40.5277H40.4612H40.3945H40.3277H40.2609H40.1939H40.1269H40.0598H39.9926H39.9253H39.8579H39.7904H39.7229H39.6552H39.5874H39.5196H39.4517H39.3836H39.3155H39.2473H39.1789H39.1105H39.042H38.9734H38.9046H38.8358H38.7669H38.6979H38.6288H38.5595H38.4902H38.4208H38.3512H38.2816H38.2118H38.142H38.072H38.0019H37.9317H37.8615H37.7911H37.7205H37.6499H37.5792H37.5083H37.4374H37.3663H37.2951H37.2238H37.1524H37.0809H37.0092H36.9374H36.8655H36.7935H36.7214H36.6492H36.5768H36.5043H36.4317H36.359H36.2861H36.2131H36.14H36.0668H35.9934H35.9199H35.8463H35.7726H35.6987H35.6247H35.5506H35.4764H35.402H35.3274H35.2528H35.178H35.1031H35.028H34.9528H34.8775H34.8021H34.7265H34.6507H34.5749H34.4989H34.4227H34.3464H34.27H34.1934H34.1167H34.0398H33.9628H33.8857H33.8084H33.731H33.6534H33.5757H33.4978H33.4198H33.3416H33.2633H33.1848H33.1062H33.0274H32.9485H32.8694H32.7902H32.7108H32.6313H32.5516H32.4718H32.3918H32.3116H32.2313H32.1508H32.0702H31.9894H31.9085H31.8273H31.7461H31.6646H31.583H31.5013H31.4194H31.3373H31.255H31.1726H31.09H31.0073H30.9243C30.7817 170.132 30.7021 170.098 30.6492 170.065C30.5881 170.026 30.5107 169.954 30.4348 169.823C30.2689 169.538 30.1936 169.112 30.2525 168.743C31.6563 159.954 39.3802 153.206 48.7252 153.206C58.0703 153.206 65.7943 159.954 67.198 168.743C67.3079 169.431 67.1364 169.686 67.0452 169.781C66.9216 169.91 66.5692 170.132 65.7263 170.132Z" fill="white" stroke="#212529" stroke-width="3"/>
<circle cx="20.7328" cy="142.632" r="10.5" fill="white" stroke="#212529" stroke-width="3"/>
<path d="M37.7263 170.132H37.6454H37.5646H37.484H37.4036H37.3233H37.2432H37.1632H37.0834H37.0037H36.9242H36.8449H36.7657H36.6866H36.6077H36.529H36.4504H36.372H36.2937H36.2155H36.1376H36.0597H35.982H35.9045H35.8271H35.7498H35.6727H35.5957H35.5189H35.4422H35.3657H35.2893H35.213H35.1369H35.0609H34.985H34.9093H34.8338H34.7583H34.683H34.6079H34.5329H34.458H34.3832H34.3086H34.2341H34.1597H34.0855H34.0114H33.9374H33.8636H33.7899H33.7163H33.6428H33.5695H33.4963H33.4232H33.3503H33.2774H33.2047H33.1321H33.0597H32.9873H32.9151H32.843H32.771H32.6992H32.6274H32.5558H32.4843H32.4129H32.3416H32.2704H32.1994H32.1284H32.0576H31.9869H31.9163H31.8458H31.7754H31.7052H31.635H31.5649H31.495H31.4252H31.3554H31.2858H31.2163H31.1469H31.0776H31.0084H30.9393H30.8703H30.8013H30.7325H30.6638H30.5952H30.5267H30.4583H30.39H30.3218H30.2537H30.1857H30.1178H30.0499H29.9822H29.9146H29.847H29.7796H29.7122H29.6449H29.5777H29.5106H29.4436H29.3767H29.3099H29.2431H29.1765H29.1099H29.0434H28.977H28.9107H28.8444H28.7783H28.7122H28.6462H28.5803H28.5145H28.4487H28.383H28.3174H28.2519H28.1865H28.1211H28.0558H27.9906H27.9254H27.8603H27.7953H27.7304H27.6655H27.6008H27.536H27.4714H27.4068H27.3423H27.2778H27.2135H27.1492H27.0849H27.0207H26.9566H26.8925H26.8286H26.7646H26.7008H26.6369H26.5732H26.5095H26.4459H26.3823H26.3188H26.2553H26.1919H26.1286H26.0653H26.0021H25.9389H25.8758H25.8127H25.7497H25.6867H25.6238H25.5609H25.4981H25.4353H25.3726H25.3099H25.2473H25.1847H25.1222H25.0597H24.9972H24.9348H24.8725H24.8102H24.7479H24.6856H24.6234H24.5613H24.4992H24.4371H24.375H24.313H24.2511H24.1891H24.1272H24.0654H24.0036H23.9418H23.88H23.8183H23.7566H23.6949H23.6333H23.5717H23.5101H23.4485H23.387H23.3255H23.264H23.2026H23.1412H23.0798H23.0184H22.9571H22.8957H22.8344H22.7731H22.7119H22.6506H22.5894H22.5282H22.467H22.4058H22.3447H22.2836H22.2224H22.1613H22.1002H22.0392H21.9781H21.917H21.856H21.795H21.7339H21.6729H21.6119H21.5509H21.4899H21.429H21.368H21.307H21.246H21.1851H21.1241H21.0632H21.0022H20.9413H20.8803H20.8194H20.7584H20.6975H20.6365H20.5756H20.5146H20.4537H20.3927H20.3318H20.2708H20.2098H20.1488H20.0878H20.0268H19.9658H19.9048H19.8438H19.7828H19.7217H19.6607H19.5996H19.5385H19.4774H19.4163H19.3552H19.294H19.2329H19.1717H19.1105H19.0493H18.9881H18.9268H18.8656H18.8043H18.743H18.6816H18.6203H18.5589H18.4975H18.4361H18.3746H18.3132H18.2517H18.1901H18.1286H18.067H18.0054H17.9437H17.8821H17.8203H17.7586H17.6968H17.635H17.5732H17.5113H17.4494H17.3875H17.3255H17.2635H17.2015H17.1394H17.0772H17.0151H16.9529H16.8906H16.8283H16.766H16.7036H16.6412H16.5788H16.5163H16.4537H16.3911H16.3285H16.2658H16.2031H16.1403H16.0775H16.0146H15.9517H15.8887H15.8256H15.7626H15.6994H15.6362H15.573H15.5097H15.4463H15.3829H15.3195H15.2559H15.1924H15.1287H15.065H15.0013H14.9374H14.8736H14.8096H14.7456H14.6815H14.6174H14.5532H14.4889H14.4246H14.3602H14.2958H14.2312H14.1666H14.102H14.0373H13.9724H13.9076H13.8426H13.7776H13.7125H13.6474H13.5821H13.5168H13.4514H13.386H13.3204H13.2548H13.1891H13.1233H13.0575H12.9916H12.9255H12.8594H12.7933H12.727H12.6607H12.5943H12.5277H12.4612H12.3945H12.3277H12.2609H12.1939H12.1269H12.0598H11.9926H11.9253H11.8579H11.7904H11.7229H11.6552H11.5874H11.5196H11.4517H11.3836H11.3155H11.2473H11.1789H11.1105H11.042H10.9734H10.9046H10.8358H10.7669H10.6979H10.6288H10.5595H10.4902H10.4208H10.3512H10.2816H10.2118H10.142H10.072H10.0019H9.93175H9.86145H9.79105H9.72054H9.64992H9.57918H9.50834H9.43738H9.3663H9.29511H9.22381H9.15239H9.08085H9.0092H8.93743H8.86554H8.79354H8.72141H8.64916H8.5768H8.50431H8.4317H8.35896H8.28611H8.21312H8.14002H8.06679H7.99343H7.91995H7.84634H7.7726H7.69873H7.62473H7.55061H7.47635H7.40196H7.32744H7.25279H7.17801H7.10309H7.02804H6.95285H6.87753H6.80207H6.72647H6.65074H6.57487H6.49886H6.42271H6.34642H6.26998H6.19341H6.1167H6.03984H5.96284H5.8857H5.80841H5.73098H5.6534H5.57567H5.4978H5.41978H5.34161H5.26329H5.18482H5.1062H5.02743H4.94851H4.86944H4.79021H4.71083H4.6313H4.55161H4.47177H4.39177H4.31161H4.2313H4.15082H4.07019H3.9894H3.90845H3.82734H3.74607H3.66464H3.58304H3.50128H3.41936H3.33727H3.25501H3.1726H3.09001H3.00726H2.92434C2.78171 170.132 2.70206 170.098 2.64924 170.065C2.5881 170.026 2.51071 169.954 2.43479 169.823C2.26892 169.538 2.19357 169.112 2.25253 168.743C3.65626 159.954 11.3802 153.206 20.7252 153.206C30.0703 153.206 37.7943 159.954 39.198 168.743C39.3079 169.431 39.1364 169.686 39.0452 169.781C38.9216 169.91 38.5692 170.132 37.7263 170.132Z" fill="white" stroke="#212529" stroke-width="3"/>
<circle cx="34.7328" cy="155.632" r="10.5" fill="white" stroke="#212529" stroke-width="3"/>
<path d="M51.7263 183.132H51.6454H51.5646H51.484H51.4036H51.3233H51.2432H51.1632H51.0834H51.0037H50.9242H50.8449H50.7657H50.6866H50.6077H50.529H50.4504H50.372H50.2937H50.2155H50.1376H50.0597H49.982H49.9045H49.8271H49.7498H49.6727H49.5957H49.5189H49.4422H49.3657H49.2893H49.213H49.1369H49.0609H48.985H48.9093H48.8338H48.7583H48.683H48.6079H48.5329H48.458H48.3832H48.3086H48.2341H48.1597H48.0855H48.0114H47.9374H47.8636H47.7899H47.7163H47.6428H47.5695H47.4963H47.4232H47.3503H47.2774H47.2047H47.1321H47.0597H46.9873H46.9151H46.843H46.771H46.6992H46.6274H46.5558H46.4843H46.4129H46.3416H46.2704H46.1994H46.1284H46.0576H45.9869H45.9163H45.8458H45.7754H45.7052H45.635H45.5649H45.495H45.4252H45.3554H45.2858H45.2163H45.1469H45.0776H45.0084H44.9393H44.8703H44.8013H44.7325H44.6638H44.5952H44.5267H44.4583H44.39H44.3218H44.2537H44.1857H44.1178H44.0499H43.9822H43.9146H43.847H43.7796H43.7122H43.6449H43.5777H43.5106H43.4436H43.3767H43.3099H43.2431H43.1765H43.1099H43.0434H42.977H42.9107H42.8444H42.7783H42.7122H42.6462H42.5803H42.5145H42.4487H42.383H42.3174H42.2519H42.1865H42.1211H42.0558H41.9906H41.9254H41.8603H41.7953H41.7304H41.6655H41.6008H41.536H41.4714H41.4068H41.3423H41.2778H41.2135H41.1492H41.0849H41.0207H40.9566H40.8925H40.8286H40.7646H40.7008H40.6369H40.5732H40.5095H40.4459H40.3823H40.3188H40.2553H40.1919H40.1286H40.0653H40.0021H39.9389H39.8758H39.8127H39.7497H39.6867H39.6238H39.5609H39.4981H39.4353H39.3726H39.3099H39.2473H39.1847H39.1222H39.0597H38.9972H38.9348H38.8725H38.8102H38.7479H38.6856H38.6234H38.5613H38.4992H38.4371H38.375H38.313H38.2511H38.1891H38.1272H38.0654H38.0036H37.9418H37.88H37.8183H37.7566H37.6949H37.6333H37.5717H37.5101H37.4485H37.387H37.3255H37.264H37.2026H37.1412H37.0798H37.0184H36.9571H36.8957H36.8344H36.7731H36.7119H36.6506H36.5894H36.5282H36.467H36.4058H36.3447H36.2836H36.2224H36.1613H36.1002H36.0392H35.9781H35.917H35.856H35.795H35.7339H35.6729H35.6119H35.5509H35.4899H35.429H35.368H35.307H35.246H35.1851H35.1241H35.0632H35.0022H34.9413H34.8803H34.8194H34.7584H34.6975H34.6365H34.5756H34.5146H34.4537H34.3927H34.3318H34.2708H34.2098H34.1488H34.0878H34.0268H33.9658H33.9048H33.8438H33.7828H33.7217H33.6607H33.5996H33.5385H33.4774H33.4163H33.3552H33.294H33.2329H33.1717H33.1105H33.0493H32.9881H32.9268H32.8656H32.8043H32.743H32.6816H32.6203H32.5589H32.4975H32.4361H32.3746H32.3132H32.2517H32.1901H32.1286H32.067H32.0054H31.9437H31.8821H31.8203H31.7586H31.6968H31.635H31.5732H31.5113H31.4494H31.3875H31.3255H31.2635H31.2015H31.1394H31.0772H31.0151H30.9529H30.8906H30.8283H30.766H30.7036H30.6412H30.5788H30.5163H30.4537H30.3911H30.3285H30.2658H30.2031H30.1403H30.0775H30.0146H29.9517H29.8887H29.8256H29.7626H29.6994H29.6362H29.573H29.5097H29.4463H29.3829H29.3195H29.2559H29.1924H29.1287H29.065H29.0013H28.9374H28.8736H28.8096H28.7456H28.6815H28.6174H28.5532H28.4889H28.4246H28.3602H28.2958H28.2312H28.1666H28.102H28.0373H27.9724H27.9076H27.8426H27.7776H27.7125H27.6474H27.5821H27.5168H27.4514H27.386H27.3204H27.2548H27.1891H27.1233H27.0575H26.9916H26.9255H26.8594H26.7933H26.727H26.6607H26.5943H26.5277H26.4612H26.3945H26.3277H26.2609H26.1939H26.1269H26.0598H25.9926H25.9253H25.8579H25.7904H25.7229H25.6552H25.5874H25.5196H25.4517H25.3836H25.3155H25.2473H25.1789H25.1105H25.042H24.9734H24.9046H24.8358H24.7669H24.6979H24.6288H24.5595H24.4902H24.4208H24.3512H24.2816H24.2118H24.142H24.072H24.0019H23.9317H23.8615H23.7911H23.7205H23.6499H23.5792H23.5083H23.4374H23.3663H23.2951H23.2238H23.1524H23.0809H23.0092H22.9374H22.8655H22.7935H22.7214H22.6492H22.5768H22.5043H22.4317H22.359H22.2861H22.2131H22.14H22.0668H21.9934H21.9199H21.8463H21.7726H21.6987H21.6247H21.5506H21.4764H21.402H21.3274H21.2528H21.178H21.1031H21.028H20.9528H20.8775H20.8021H20.7265H20.6507H20.5749H20.4989H20.4227H20.3464H20.27H20.1934H20.1167H20.0398H19.9628H19.8857H19.8084H19.731H19.6534H19.5757H19.4978H19.4198H19.3416H19.2633H19.1848H19.1062H19.0274H18.9485H18.8694H18.7902H18.7108H18.6313H18.5516H18.4718H18.3918H18.3116H18.2313H18.1508H18.0702H17.9894H17.9085H17.8273H17.7461H17.6646H17.583H17.5013H17.4194H17.3373H17.255H17.1726H17.09H17.0073H16.9243C16.7778 183.132 16.6956 183.097 16.642 183.064C16.5807 183.026 16.5047 182.955 16.4306 182.829C16.2682 182.553 16.1944 182.141 16.2521 181.785C17.6523 173.127 25.3653 166.455 34.7252 166.455C44.0852 166.455 51.7982 173.127 53.1984 181.785C53.3068 182.454 53.138 182.695 53.0518 182.784C52.929 182.91 52.5741 183.132 51.7263 183.132Z" fill="white" stroke="#212529" stroke-width="3"/>
</svg>
`;

View File

@ -1,6 +1,6 @@
<app-header></app-header>
<bit-container>
<bit-container *ngIf="!IsProviderManaged">
<ng-container *ngIf="!firstLoaded && loading">
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
@ -256,3 +256,13 @@
</ng-container>
</ng-container>
</bit-container>
<bit-container *ngIf="IsProviderManaged">
<div
class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-24 tw-text-center tw-font-bold"
>
<bit-icon [icon]="manageBillingFromProviderPortal"></bit-icon>
<ng-container slot="description">{{
"manageBillingFromProviderPortalMessage" | i18n
}}</ng-container>
</div>
</bit-container>

View File

@ -5,7 +5,7 @@ import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUnti
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums";
import { OrganizationApiKeyType, ProviderType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PlanType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
@ -28,6 +28,7 @@ import {
} from "../shared/offboarding-survey.component";
import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component";
import { ManageBilling } from "./icons/manage-billing.icon";
import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component";
@Component({
@ -47,11 +48,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
loading: boolean;
locale: string;
showUpdatedSubscriptionStatusSection$: Observable<boolean>;
manageBillingFromProviderPortal = ManageBilling;
IsProviderManaged = false;
protected readonly teamsStarter = ProductType.TeamsStarter;
private destroy$ = new Subject<void>();
protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$(
FeatureFlag.EnableConsolidatedBilling,
);
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
@ -99,6 +106,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
this.loading = true;
this.locale = await firstValueFrom(this.i18nService.locale$);
this.userOrg = await this.organizationService.get(this.organizationId);
const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$);
this.IsProviderManaged =
this.userOrg.hasProvider &&
this.userOrg.providerType == ProviderType.Msp &&
enableConsolidatedBilling
? true
: false;
if (this.userOrg.canViewSubscription) {
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
this.lineItems = this.sub?.subscription?.items;

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

@ -8049,5 +8049,8 @@
},
"collectionItemSelect": {
"message": "Select collection item"
},
"manageBillingFromProviderPortalMessage": {
"message": "Manage billing from the Provider Portal"
}
}

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

@ -17,7 +17,7 @@ import { ServiceAccountEventLogApiService } from "./service-account-event-log-ap
templateUrl: "./service-accounts-events.component.html",
})
export class ServiceAccountEventsComponent extends BaseEventsComponent implements OnDestroy {
exportFileName = "service-account-events";
exportFileName = "machine-account-events";
private destroy$ = new Subject<void>();
private serviceAccountId: string;

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

@ -14,7 +14,7 @@ export class LoggingErrorHandler extends ErrorHandler {
override handleError(error: any): void {
try {
const logService = this.injector.get(LogService, null);
logService.error(error);
logService.error("Unhandled error in angular", error);
} catch {
super.handleError(error);
}

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";
@ -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

@ -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

@ -2,22 +2,17 @@ const originalConsole = console;
declare let console: any;
export function interceptConsole(interceptions: any): object {
export function interceptConsole(): {
log: jest.Mock<any, any>;
warn: jest.Mock<any, any>;
error: jest.Mock<any, any>;
} {
console = {
log: function () {
// eslint-disable-next-line
interceptions.log = arguments;
},
warn: function () {
// eslint-disable-next-line
interceptions.warn = arguments;
},
error: function () {
// eslint-disable-next-line
interceptions.error = arguments;
},
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
return interceptions;
return console;
}
export function restoreConsole() {

View File

@ -30,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

@ -1,9 +1,9 @@
import { LogLevelType } from "../enums/log-level-type.enum";
export abstract class LogService {
abstract debug(message: string): void;
abstract info(message: string): void;
abstract warning(message: string): void;
abstract error(message: string): void;
abstract write(level: LogLevelType, message: string): void;
abstract debug(message?: any, ...optionalParams: any[]): void;
abstract info(message?: any, ...optionalParams: any[]): void;
abstract warning(message?: any, ...optionalParams: any[]): void;
abstract error(message?: any, ...optionalParams: any[]): void;
abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void;
}

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

@ -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

@ -2,13 +2,18 @@ import { interceptConsole, restoreConsole } from "../../../spec";
import { ConsoleLogService } from "./console-log.service";
let caughtMessage: any;
describe("ConsoleLogService", () => {
const error = new Error("this is an error");
const obj = { a: 1, b: 2 };
let consoleSpy: {
log: jest.Mock<any, any>;
warn: jest.Mock<any, any>;
error: jest.Mock<any, any>;
};
let logService: ConsoleLogService;
beforeEach(() => {
caughtMessage = {};
interceptConsole(caughtMessage);
consoleSpy = interceptConsole();
logService = new ConsoleLogService(true);
});
@ -18,41 +23,41 @@ describe("ConsoleLogService", () => {
it("filters messages below the set threshold", () => {
logService = new ConsoleLogService(true, () => true);
logService.debug("debug");
logService.info("info");
logService.warning("warning");
logService.error("error");
logService.debug("debug", error, obj);
logService.info("info", error, obj);
logService.warning("warning", error, obj);
logService.error("error", error, obj);
expect(caughtMessage).toEqual({});
expect(consoleSpy.log).not.toHaveBeenCalled();
expect(consoleSpy.warn).not.toHaveBeenCalled();
expect(consoleSpy.error).not.toHaveBeenCalled();
});
it("only writes debug messages in dev mode", () => {
logService = new ConsoleLogService(false);
logService.debug("debug message");
expect(caughtMessage.log).toBeUndefined();
expect(consoleSpy.log).not.toHaveBeenCalled();
});
it("writes debug/info messages to console.log", () => {
logService.debug("this is a debug message");
expect(caughtMessage).toMatchObject({
log: { "0": "this is a debug message" },
});
logService.debug("this is a debug message", error, obj);
logService.info("this is an info message", error, obj);
logService.info("this is an info message");
expect(caughtMessage).toMatchObject({
log: { "0": "this is an info message" },
});
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
expect(consoleSpy.log).toHaveBeenCalledWith("this is a debug message", error, obj);
expect(consoleSpy.log).toHaveBeenCalledWith("this is an info message", error, obj);
});
it("writes warning messages to console.warn", () => {
logService.warning("this is a warning message");
expect(caughtMessage).toMatchObject({
warn: { 0: "this is a warning message" },
});
logService.warning("this is a warning message", error, obj);
expect(consoleSpy.warn).toHaveBeenCalledWith("this is a warning message", error, obj);
});
it("writes error messages to console.error", () => {
logService.error("this is an error message");
expect(caughtMessage).toMatchObject({
error: { 0: "this is an error message" },
});
logService.error("this is an error message", error, obj);
expect(consoleSpy.error).toHaveBeenCalledWith("this is an error message", error, obj);
});
});

View File

@ -9,26 +9,26 @@ export class ConsoleLogService implements LogServiceAbstraction {
protected filter: (level: LogLevelType) => boolean = null,
) {}
debug(message: string) {
debug(message?: any, ...optionalParams: any[]) {
if (!this.isDev) {
return;
}
this.write(LogLevelType.Debug, message);
this.write(LogLevelType.Debug, message, ...optionalParams);
}
info(message: string) {
this.write(LogLevelType.Info, message);
info(message?: any, ...optionalParams: any[]) {
this.write(LogLevelType.Info, message, ...optionalParams);
}
warning(message: string) {
this.write(LogLevelType.Warning, message);
warning(message?: any, ...optionalParams: any[]) {
this.write(LogLevelType.Warning, message, ...optionalParams);
}
error(message: string) {
this.write(LogLevelType.Error, message);
error(message?: any, ...optionalParams: any[]) {
this.write(LogLevelType.Error, message, ...optionalParams);
}
write(level: LogLevelType, message: string) {
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
@ -36,19 +36,19 @@ export class ConsoleLogService implements LogServiceAbstraction {
switch (level) {
case LogLevelType.Debug:
// eslint-disable-next-line
console.log(message);
console.log(message, ...optionalParams);
break;
case LogLevelType.Info:
// eslint-disable-next-line
console.log(message);
console.log(message, ...optionalParams);
break;
case LogLevelType.Warning:
// eslint-disable-next-line
console.warn(message);
console.warn(message, ...optionalParams);
break;
case LogLevelType.Error:
// eslint-disable-next-line
console.error(message);
console.error(message, ...optionalParams);
break;
default:
break;

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;
}

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