mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-13 01:58:44 +02:00
[PM-6426] Merging main into branch and fixing merge conflicts
This commit is contained in:
commit
86d273df46
@ -30,6 +30,19 @@ const filters = {
|
|||||||
safari: ["!build/safari/**/*"],
|
safari: ["!build/safari/**/*"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a number to a tuple containing two Uint16's
|
||||||
|
* @param num {number} This number is expected to be a integer style number with no decimals
|
||||||
|
*
|
||||||
|
* @returns {number[]} A tuple containing two elements that are both numbers.
|
||||||
|
*/
|
||||||
|
function numToUint16s(num) {
|
||||||
|
var arr = new ArrayBuffer(4);
|
||||||
|
var view = new DataView(arr);
|
||||||
|
view.setUint32(0, num, false);
|
||||||
|
return [view.getUint16(0), view.getUint16(2)];
|
||||||
|
}
|
||||||
|
|
||||||
function buildString() {
|
function buildString() {
|
||||||
var build = "";
|
var build = "";
|
||||||
if (process.env.MANIFEST_VERSION) {
|
if (process.env.MANIFEST_VERSION) {
|
||||||
@ -258,8 +271,19 @@ function applyBetaLabels(manifest) {
|
|||||||
manifest.short_name = "Bitwarden BETA";
|
manifest.short_name = "Bitwarden BETA";
|
||||||
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
|
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
|
||||||
if (process.env.GITHUB_RUN_ID) {
|
if (process.env.GITHUB_RUN_ID) {
|
||||||
manifest.version_name = `${manifest.version} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
|
const existingVersionParts = manifest.version.split("."); // 3 parts expected 2024.4.0
|
||||||
manifest.version = `${manifest.version}.${parseInt(process.env.GITHUB_RUN_ID.slice(-4))}`;
|
|
||||||
|
// GITHUB_RUN_ID is a number like: 8853654662
|
||||||
|
// which will convert to [ 4024, 3206 ]
|
||||||
|
// and a single incremented id of 8853654663 will become [ 4024, 3207 ]
|
||||||
|
const runIdParts = numToUint16s(parseInt(process.env.GITHUB_RUN_ID));
|
||||||
|
|
||||||
|
// Only use the first 2 parts from the given version number and base the other 2 numbers from the GITHUB_RUN_ID
|
||||||
|
// Example: 2024.4.4024.3206
|
||||||
|
const betaVersion = `${existingVersionParts[0]}.${existingVersionParts[1]}.${runIdParts[0]}.${runIdParts[1]}`;
|
||||||
|
|
||||||
|
manifest.version_name = `${betaVersion} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
|
||||||
|
manifest.version = betaVersion;
|
||||||
} else {
|
} else {
|
||||||
manifest.version = `${manifest.version}.0`;
|
manifest.version = `${manifest.version}.0`;
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
|
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
|
||||||
(click)="lock()"
|
(click)="lock(currentAccount.id)"
|
||||||
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
|
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
|
||||||
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
|
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
|
||||||
>
|
>
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3"
|
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3"
|
||||||
(click)="logOut()"
|
(click)="logOut(currentAccount.id)"
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
|
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
|
||||||
{{ "logOut" | i18n }}
|
{{ "logOut" | i18n }}
|
||||||
|
@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
|||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
import { AccountSwitcherService } from "./services/account-switcher.service";
|
import { AccountSwitcherService } from "./services/account-switcher.service";
|
||||||
@ -64,9 +65,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
this.location.back();
|
this.location.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
async lock(userId?: string) {
|
async lock(userId: string) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
await this.vaultTimeoutService.lock(userId ? userId : null);
|
await this.vaultTimeoutService.lock(userId);
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.router.navigate(["lock"]);
|
this.router.navigate(["lock"]);
|
||||||
@ -96,7 +97,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe(() => this.router.navigate(["lock"]));
|
.subscribe(() => this.router.navigate(["lock"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async logOut() {
|
async logOut(userId: UserId) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
const confirmed = await this.dialogService.openSimpleDialog({
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
title: { key: "logOut" },
|
title: { key: "logOut" },
|
||||||
@ -105,7 +106,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.messagingService.send("logout");
|
this.messagingService.send("logout", { userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
@ -58,6 +58,7 @@ describe("AccountSwitcherService", () => {
|
|||||||
const accountInfo: AccountInfo = {
|
const accountInfo: AccountInfo = {
|
||||||
name: "Test User 1",
|
name: "Test User 1",
|
||||||
email: "test1@email.com",
|
email: "test1@email.com",
|
||||||
|
emailVerified: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc"));
|
avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc"));
|
||||||
@ -89,6 +90,7 @@ describe("AccountSwitcherService", () => {
|
|||||||
for (let i = 0; i < numberOfAccounts; i++) {
|
for (let i = 0; i < numberOfAccounts; i++) {
|
||||||
seedAccounts[`${i}` as UserId] = {
|
seedAccounts[`${i}` as UserId] = {
|
||||||
email: `test${i}@email.com`,
|
email: `test${i}@email.com`,
|
||||||
|
emailVerified: true,
|
||||||
name: "Test User ${i}",
|
name: "Test User ${i}",
|
||||||
};
|
};
|
||||||
seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked;
|
seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked;
|
||||||
@ -113,6 +115,7 @@ describe("AccountSwitcherService", () => {
|
|||||||
const user1AccountInfo: AccountInfo = {
|
const user1AccountInfo: AccountInfo = {
|
||||||
name: "Test User 1",
|
name: "Test User 1",
|
||||||
email: "",
|
email: "",
|
||||||
|
emailVerified: true,
|
||||||
};
|
};
|
||||||
accountsSubject.next({ ["1" as UserId]: user1AccountInfo });
|
accountsSubject.next({ ["1" as UserId]: user1AccountInfo });
|
||||||
authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut });
|
authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut });
|
||||||
|
@ -59,7 +59,7 @@ export class LockComponent extends BaseLockComponent {
|
|||||||
policyApiService: PolicyApiServiceAbstraction,
|
policyApiService: PolicyApiServiceAbstraction,
|
||||||
policyService: InternalPolicyService,
|
policyService: InternalPolicyService,
|
||||||
passwordStrengthService: PasswordStrengthServiceAbstraction,
|
passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
private authService: AuthService,
|
authService: AuthService,
|
||||||
dialogService: DialogService,
|
dialogService: DialogService,
|
||||||
deviceTrustService: DeviceTrustServiceAbstraction,
|
deviceTrustService: DeviceTrustServiceAbstraction,
|
||||||
userVerificationService: UserVerificationService,
|
userVerificationService: UserVerificationService,
|
||||||
@ -92,6 +92,7 @@ export class LockComponent extends BaseLockComponent {
|
|||||||
pinCryptoService,
|
pinCryptoService,
|
||||||
biometricStateService,
|
biometricStateService,
|
||||||
accountService,
|
accountService,
|
||||||
|
authService,
|
||||||
kdfConfigService,
|
kdfConfigService,
|
||||||
);
|
);
|
||||||
this.successRoute = "/tabs/current";
|
this.successRoute = "/tabs/current";
|
||||||
|
@ -11,7 +11,8 @@ import {
|
|||||||
GENERATE_PASSWORD_ID,
|
GENERATE_PASSWORD_ID,
|
||||||
NOOP_COMMAND_SUFFIX,
|
NOOP_COMMAND_SUFFIX,
|
||||||
} from "@bitwarden/common/autofill/constants";
|
} from "@bitwarden/common/autofill/constants";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
@ -65,7 +66,7 @@ describe("ContextMenuClickedHandler", () => {
|
|||||||
let autofill: AutofillAction;
|
let autofill: AutofillAction;
|
||||||
let authService: MockProxy<AuthService>;
|
let authService: MockProxy<AuthService>;
|
||||||
let cipherService: MockProxy<CipherService>;
|
let cipherService: MockProxy<CipherService>;
|
||||||
let stateService: MockProxy<StateService>;
|
let accountService: FakeAccountService;
|
||||||
let totpService: MockProxy<TotpService>;
|
let totpService: MockProxy<TotpService>;
|
||||||
let eventCollectionService: MockProxy<EventCollectionService>;
|
let eventCollectionService: MockProxy<EventCollectionService>;
|
||||||
let userVerificationService: MockProxy<UserVerificationService>;
|
let userVerificationService: MockProxy<UserVerificationService>;
|
||||||
@ -78,7 +79,7 @@ describe("ContextMenuClickedHandler", () => {
|
|||||||
autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>();
|
autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>();
|
||||||
authService = mock();
|
authService = mock();
|
||||||
cipherService = mock();
|
cipherService = mock();
|
||||||
stateService = mock();
|
accountService = mockAccountServiceWith("userId" as UserId);
|
||||||
totpService = mock();
|
totpService = mock();
|
||||||
eventCollectionService = mock();
|
eventCollectionService = mock();
|
||||||
|
|
||||||
@ -88,10 +89,10 @@ describe("ContextMenuClickedHandler", () => {
|
|||||||
autofill,
|
autofill,
|
||||||
authService,
|
authService,
|
||||||
cipherService,
|
cipherService,
|
||||||
stateService,
|
|
||||||
totpService,
|
totpService,
|
||||||
eventCollectionService,
|
eventCollectionService,
|
||||||
userVerificationService,
|
userVerificationService,
|
||||||
|
accountService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { firstValueFrom, map } from "rxjs";
|
||||||
|
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
@ -17,7 +20,6 @@ import {
|
|||||||
NOOP_COMMAND_SUFFIX,
|
NOOP_COMMAND_SUFFIX,
|
||||||
} from "@bitwarden/common/autofill/constants";
|
} from "@bitwarden/common/autofill/constants";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
|
||||||
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
@ -26,6 +28,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
|
|||||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
|
import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory";
|
||||||
import {
|
import {
|
||||||
authServiceFactory,
|
authServiceFactory,
|
||||||
AuthServiceInitOptions,
|
AuthServiceInitOptions,
|
||||||
@ -37,7 +40,6 @@ import { autofillSettingsServiceFactory } from "../../autofill/background/servic
|
|||||||
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
|
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
|
||||||
import { Account } from "../../models/account";
|
import { Account } from "../../models/account";
|
||||||
import { CachedServices } from "../../platform/background/service-factories/factory-options";
|
import { CachedServices } from "../../platform/background/service-factories/factory-options";
|
||||||
import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory";
|
|
||||||
import { taskSchedulerServiceFactory } from "../../platform/background/service-factories/task-scheduler-service.factory";
|
import { taskSchedulerServiceFactory } from "../../platform/background/service-factories/task-scheduler-service.factory";
|
||||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory";
|
import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory";
|
||||||
@ -72,10 +74,10 @@ export class ContextMenuClickedHandler {
|
|||||||
private autofillAction: AutofillAction,
|
private autofillAction: AutofillAction,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private stateService: StateService,
|
|
||||||
private totpService: TotpService,
|
private totpService: TotpService,
|
||||||
private eventCollectionService: EventCollectionService,
|
private eventCollectionService: EventCollectionService,
|
||||||
private userVerificationService: UserVerificationService,
|
private userVerificationService: UserVerificationService,
|
||||||
|
private accountService: AccountService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static async mv3Create(cachedServices: CachedServices) {
|
static async mv3Create(cachedServices: CachedServices) {
|
||||||
@ -130,10 +132,10 @@ export class ContextMenuClickedHandler {
|
|||||||
(tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher),
|
(tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher),
|
||||||
await authServiceFactory(cachedServices, serviceOptions),
|
await authServiceFactory(cachedServices, serviceOptions),
|
||||||
await cipherServiceFactory(cachedServices, serviceOptions),
|
await cipherServiceFactory(cachedServices, serviceOptions),
|
||||||
await stateServiceFactory(cachedServices, serviceOptions),
|
|
||||||
await totpServiceFactory(cachedServices, serviceOptions),
|
await totpServiceFactory(cachedServices, serviceOptions),
|
||||||
await eventCollectionServiceFactory(cachedServices, serviceOptions),
|
await eventCollectionServiceFactory(cachedServices, serviceOptions),
|
||||||
await userVerificationServiceFactory(cachedServices, serviceOptions),
|
await userVerificationServiceFactory(cachedServices, serviceOptions),
|
||||||
|
await accountServiceFactory(cachedServices, serviceOptions),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,9 +243,10 @@ export class ContextMenuClickedHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
const activeUserId = await firstValueFrom(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||||
this.stateService.setLastActive(new Date().getTime());
|
);
|
||||||
|
await this.accountService.setAccountActivity(activeUserId, new Date());
|
||||||
switch (info.parentMenuItemId) {
|
switch (info.parentMenuItemId) {
|
||||||
case AUTOFILL_ID:
|
case AUTOFILL_ID:
|
||||||
case AUTOFILL_IDENTITY_ID:
|
case AUTOFILL_IDENTITY_ID:
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Subject, firstValueFrom, merge, timeout } from "rxjs";
|
import { Subject, firstValueFrom, map, merge, timeout } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PinCryptoServiceAbstraction,
|
PinCryptoServiceAbstraction,
|
||||||
@ -114,7 +114,7 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory
|
|||||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||||
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
||||||
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
|
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||||
import {
|
import {
|
||||||
ActiveUserStateProvider,
|
ActiveUserStateProvider,
|
||||||
@ -337,7 +337,7 @@ export default class MainBackground {
|
|||||||
billingAccountProfileStateService: BillingAccountProfileStateService;
|
billingAccountProfileStateService: BillingAccountProfileStateService;
|
||||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module
|
// eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module
|
||||||
intraprocessMessagingSubject: Subject<Message<object>>;
|
intraprocessMessagingSubject: Subject<Message<object>>;
|
||||||
userKeyInitService: UserKeyInitService;
|
userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||||
scriptInjectorService: BrowserScriptInjectorService;
|
scriptInjectorService: BrowserScriptInjectorService;
|
||||||
kdfConfigService: kdfConfigServiceAbstraction;
|
kdfConfigService: kdfConfigServiceAbstraction;
|
||||||
|
|
||||||
@ -922,6 +922,7 @@ export default class MainBackground {
|
|||||||
this.autofillSettingsService,
|
this.autofillSettingsService,
|
||||||
this.vaultTimeoutSettingsService,
|
this.vaultTimeoutSettingsService,
|
||||||
this.biometricStateService,
|
this.biometricStateService,
|
||||||
|
this.accountService,
|
||||||
this.taskSchedulerService,
|
this.taskSchedulerService,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -941,7 +942,6 @@ export default class MainBackground {
|
|||||||
this.autofillService,
|
this.autofillService,
|
||||||
this.platformUtilsService as BrowserPlatformUtilsService,
|
this.platformUtilsService as BrowserPlatformUtilsService,
|
||||||
this.notificationsService,
|
this.notificationsService,
|
||||||
this.stateService,
|
|
||||||
this.autofillSettingsService,
|
this.autofillSettingsService,
|
||||||
this.systemService,
|
this.systemService,
|
||||||
this.environmentService,
|
this.environmentService,
|
||||||
@ -950,6 +950,7 @@ export default class MainBackground {
|
|||||||
this.configService,
|
this.configService,
|
||||||
this.fido2Background,
|
this.fido2Background,
|
||||||
messageListener,
|
messageListener,
|
||||||
|
this.accountService,
|
||||||
);
|
);
|
||||||
this.nativeMessagingBackground = new NativeMessagingBackground(
|
this.nativeMessagingBackground = new NativeMessagingBackground(
|
||||||
this.accountService,
|
this.accountService,
|
||||||
@ -1039,10 +1040,10 @@ export default class MainBackground {
|
|||||||
},
|
},
|
||||||
this.authService,
|
this.authService,
|
||||||
this.cipherService,
|
this.cipherService,
|
||||||
this.stateService,
|
|
||||||
this.totpService,
|
this.totpService,
|
||||||
this.eventCollectionService,
|
this.eventCollectionService,
|
||||||
this.userVerificationService,
|
this.userVerificationService,
|
||||||
|
this.accountService,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
|
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
|
||||||
@ -1085,11 +1086,7 @@ export default class MainBackground {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userKeyInitService = new UserKeyInitService(
|
this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
|
||||||
this.accountService,
|
|
||||||
this.cryptoService,
|
|
||||||
this.logService,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bootstrap() {
|
async bootstrap() {
|
||||||
@ -1100,7 +1097,18 @@ export default class MainBackground {
|
|||||||
|
|
||||||
// This is here instead of in in the InitService b/c we don't plan for
|
// This is here instead of in in the InitService b/c we don't plan for
|
||||||
// side effects to run in the Browser InitService.
|
// side effects to run in the Browser InitService.
|
||||||
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||||
|
|
||||||
|
const setUserKeyInMemoryPromises = [];
|
||||||
|
for (const userId of Object.keys(accounts) as UserId[]) {
|
||||||
|
// For each acct, we must await the process of setting the user key in memory
|
||||||
|
// if the auto user key is set to avoid race conditions of any code trying to access
|
||||||
|
// the user key from mem.
|
||||||
|
setUserKeyInMemoryPromises.push(
|
||||||
|
this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(setUserKeyInMemoryPromises);
|
||||||
|
|
||||||
await (this.i18nService as I18nService).init();
|
await (this.i18nService as I18nService).init();
|
||||||
(this.eventUploadService as EventUploadService).init(true);
|
(this.eventUploadService as EventUploadService).init(true);
|
||||||
@ -1188,7 +1196,12 @@ export default class MainBackground {
|
|||||||
*/
|
*/
|
||||||
async switchAccount(userId: UserId) {
|
async switchAccount(userId: UserId) {
|
||||||
try {
|
try {
|
||||||
await this.stateService.setActiveUser(userId);
|
const currentlyActiveAccount = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
// can be removed once password generation history is migrated to state providers
|
||||||
|
await this.stateService.clearDecryptedData(currentlyActiveAccount);
|
||||||
|
await this.accountService.switchAccount(userId);
|
||||||
|
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
this.loginEmailService.setRememberEmail(false);
|
this.loginEmailService.setRememberEmail(false);
|
||||||
@ -1260,7 +1273,11 @@ export default class MainBackground {
|
|||||||
//Needs to be checked before state is cleaned
|
//Needs to be checked before state is cleaned
|
||||||
const needStorageReseed = await this.needsStorageReseed();
|
const needStorageReseed = await this.needsStorageReseed();
|
||||||
|
|
||||||
const newActiveUser = await this.stateService.clean({ userId: userId });
|
const newActiveUser = await firstValueFrom(
|
||||||
|
this.accountService.nextUpAccount$.pipe(map((a) => a?.id)),
|
||||||
|
);
|
||||||
|
await this.stateService.clean({ userId: userId });
|
||||||
|
await this.accountService.clean(userId);
|
||||||
|
|
||||||
await this.stateEventRunnerService.handleEvent("logout", userId);
|
await this.stateEventRunnerService.handleEvent("logout", userId);
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { firstValueFrom, mergeMap } from "rxjs";
|
import { firstValueFrom, map, mergeMap } from "rxjs";
|
||||||
|
|
||||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
@ -19,7 +20,6 @@ import {
|
|||||||
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
|
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
|
||||||
import { AutofillService } from "../autofill/services/abstractions/autofill.service";
|
import { AutofillService } from "../autofill/services/abstractions/autofill.service";
|
||||||
import { BrowserApi } from "../platform/browser/browser-api";
|
import { BrowserApi } from "../platform/browser/browser-api";
|
||||||
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
|
|
||||||
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||||
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
|
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
|
||||||
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
|
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
|
||||||
@ -37,7 +37,6 @@ export default class RuntimeBackground {
|
|||||||
private autofillService: AutofillService,
|
private autofillService: AutofillService,
|
||||||
private platformUtilsService: BrowserPlatformUtilsService,
|
private platformUtilsService: BrowserPlatformUtilsService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private stateService: BrowserStateService,
|
|
||||||
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||||
private systemService: SystemService,
|
private systemService: SystemService,
|
||||||
private environmentService: BrowserEnvironmentService,
|
private environmentService: BrowserEnvironmentService,
|
||||||
@ -46,6 +45,7 @@ export default class RuntimeBackground {
|
|||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private fido2Background: Fido2Background,
|
private fido2Background: Fido2Background,
|
||||||
private messageListener: MessageListener,
|
private messageListener: MessageListener,
|
||||||
|
private accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
// onInstalled listener must be wired up before anything else, so we do it in the ctor
|
// onInstalled listener must be wired up before anything else, so we do it in the ctor
|
||||||
chrome.runtime.onInstalled.addListener((details: any) => {
|
chrome.runtime.onInstalled.addListener((details: any) => {
|
||||||
@ -111,9 +111,10 @@ export default class RuntimeBackground {
|
|||||||
switch (msg.sender) {
|
switch (msg.sender) {
|
||||||
case "autofiller":
|
case "autofiller":
|
||||||
case "autofill_cmd": {
|
case "autofill_cmd": {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
const activeUserId = await firstValueFrom(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||||
this.stateService.setLastActive(new Date().getTime());
|
);
|
||||||
|
await this.accountService.setAccountActivity(activeUserId, new Date());
|
||||||
const totpCode = await this.autofillService.doAutoFillActiveTab(
|
const totpCode = await this.autofillService.doAutoFillActiveTab(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { Component, Input } from "@angular/core";
|
import { Component, Input } from "@angular/core";
|
||||||
import { Observable, combineLatest, map, of, switchMap } from "rxjs";
|
import { Observable, map, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
|
|
||||||
import { enableAccountSwitching } from "../flags";
|
import { enableAccountSwitching } from "../flags";
|
||||||
|
|
||||||
@ -16,18 +14,15 @@ export class HeaderComponent {
|
|||||||
@Input() noTheme = false;
|
@Input() noTheme = false;
|
||||||
@Input() hideAccountSwitcher = false;
|
@Input() hideAccountSwitcher = false;
|
||||||
authedAccounts$: Observable<boolean>;
|
authedAccounts$: Observable<boolean>;
|
||||||
constructor(accountService: AccountService, authService: AuthService) {
|
constructor(authService: AuthService) {
|
||||||
this.authedAccounts$ = accountService.accounts$.pipe(
|
this.authedAccounts$ = authService.authStatuses$.pipe(
|
||||||
switchMap((accounts) => {
|
map((record) => Object.values(record)),
|
||||||
|
switchMap((statuses) => {
|
||||||
if (!enableAccountSwitching()) {
|
if (!enableAccountSwitching()) {
|
||||||
return of(false);
|
return of(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return combineLatest(
|
return of(statuses.some((status) => status !== AuthenticationStatus.LoggedOut));
|
||||||
Object.keys(accounts).map((id) => authService.authStatusFor$(id as UserId)),
|
|
||||||
).pipe(
|
|
||||||
map((statuses) => statuses.some((status) => status !== AuthenticationStatus.LoggedOut)),
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,11 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
|
|||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import {
|
import {
|
||||||
AvatarModule,
|
AvatarModule,
|
||||||
|
BadgeModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
I18nMockService,
|
I18nMockService,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
|
ItemModule,
|
||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { PopupFooterComponent } from "./popup-footer.component";
|
import { PopupFooterComponent } from "./popup-footer.component";
|
||||||
@ -30,23 +32,34 @@ class ExtensionContainerComponent {}
|
|||||||
@Component({
|
@Component({
|
||||||
selector: "vault-placeholder",
|
selector: "vault-placeholder",
|
||||||
template: `
|
template: `
|
||||||
<div class="tw-mb-8 tw-text-main">vault item</div>
|
<bit-item-group aria-label="Mock Vault Items">
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<bit-item *ngFor="let item of data; index as i">
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<button bit-item-content>
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
{{ i }} of {{ data.length - 1 }}
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<span slot="secondary">Bar</span>
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
</button>
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<ng-container slot="end">
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<bit-item-action>
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||||
<div class="tw-my-8 tw-text-main">vault item</div>
|
</bit-item-action>
|
||||||
<div class="tw-my-8 tw-text-main">vault item last item</div>
|
<bit-item-action>
|
||||||
|
<button type="button" bitIconButton="bwi-clone" aria-label="Copy item"></button>
|
||||||
|
</bit-item-action>
|
||||||
|
<bit-item-action>
|
||||||
|
<button type="button" bitIconButton="bwi-ellipsis-v" aria-label="More options"></button>
|
||||||
|
</bit-item-action>
|
||||||
|
</ng-container>
|
||||||
|
</bit-item>
|
||||||
|
</bit-item-group>
|
||||||
`,
|
`,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
imports: [CommonModule, ItemModule, BadgeModule, IconButtonModule],
|
||||||
})
|
})
|
||||||
class VaultComponent {}
|
class VaultComponent {
|
||||||
|
protected data = Array.from(Array(20).keys());
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "generator-placeholder",
|
selector: "generator-placeholder",
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
@ -50,7 +49,6 @@ describe("Browser State Service", () => {
|
|||||||
state.accounts[userId] = new Account({
|
state.accounts[userId] = new Account({
|
||||||
profile: { userId: userId },
|
profile: { userId: userId },
|
||||||
});
|
});
|
||||||
state.activeUserId = userId;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -78,18 +76,8 @@ describe("Browser State Service", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("add Account", () => {
|
it("exists", () => {
|
||||||
it("should add account", async () => {
|
expect(sut).toBeDefined();
|
||||||
const newUserId = "newUserId" as UserId;
|
|
||||||
const newAcct = new Account({
|
|
||||||
profile: { userId: newUserId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.addAccount(newAcct);
|
|
||||||
|
|
||||||
const accts = await firstValueFrom(sut.accounts$);
|
|
||||||
expect(accts[newUserId]).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -29,8 +29,6 @@ export class DefaultBrowserStateService
|
|||||||
initializeAs: "record",
|
initializeAs: "record",
|
||||||
})
|
})
|
||||||
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
|
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
|
||||||
@sessionSync({ initializer: (s: string) => s })
|
|
||||||
protected activeAccountSubject: BehaviorSubject<string>;
|
|
||||||
|
|
||||||
protected accountDeserializer = Account.fromJSON;
|
protected accountDeserializer = Account.fromJSON;
|
||||||
|
|
||||||
|
@ -200,26 +200,29 @@ export class LocalBackedSessionStorageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private compareValues<T>(value1: T, value2: T): boolean {
|
private compareValues<T>(value1: T, value2: T): boolean {
|
||||||
if (value1 == null && value2 == null) {
|
try {
|
||||||
|
if (value1 == null && value2 == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value1 && value2 == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value1 == null && value2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value1 !== "object" || typeof value2 !== "object") {
|
||||||
|
return value1 === value2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value1) === JSON.stringify(value2);
|
||||||
|
} catch (e) {
|
||||||
|
this.logService.error(
|
||||||
|
`error comparing values\n${JSON.stringify(value1)}\n${JSON.stringify(value2)}`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value1 && value2 == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value1 == null && value2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value1 !== "object" || typeof value2 !== "object") {
|
|
||||||
return value1 === value2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (JSON.stringify(value1) === JSON.stringify(value2)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(value1).sort().toString() === Object.entries(value2).sort().toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
|
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
|
||||||
import { filter, concatMap, Subject, takeUntil, firstValueFrom, tap, map } from "rxjs";
|
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
|
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
@ -27,8 +29,9 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
|
|||||||
</div>`,
|
</div>`,
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
private lastActivity: number = null;
|
private lastActivity: Date;
|
||||||
private activeUserId: string;
|
private activeUserId: UserId;
|
||||||
|
private recordActivitySubject = new Subject<void>();
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@ -46,6 +49,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private messageListener: MessageListener,
|
private messageListener: MessageListener,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
|
private accountService: AccountService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@ -53,14 +57,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
// Clear them aggressively to make sure this doesn't occur
|
// Clear them aggressively to make sure this doesn't occur
|
||||||
await this.clearComponentStates();
|
await this.clearComponentStates();
|
||||||
|
|
||||||
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => {
|
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
|
||||||
this.activeUserId = userId;
|
this.activeUserId = account?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.authService.activeAccountStatus$
|
this.authService.activeAccountStatus$
|
||||||
.pipe(
|
.pipe(
|
||||||
map((status) => status === AuthenticationStatus.Unlocked),
|
filter((status) => status === AuthenticationStatus.Unlocked),
|
||||||
filter((unlocked) => unlocked),
|
|
||||||
concatMap(async () => {
|
concatMap(async () => {
|
||||||
await this.recordActivity();
|
await this.recordActivity();
|
||||||
}),
|
}),
|
||||||
@ -200,13 +203,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().getTime();
|
const now = new Date();
|
||||||
if (this.lastActivity != null && now - this.lastActivity < 250) {
|
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastActivity = now;
|
this.lastActivity = now;
|
||||||
await this.stateService.setLastActive(now, { userId: this.activeUserId });
|
await this.accountService.setAccountActivity(this.activeUserId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showToast(msg: any) {
|
private showToast(msg: any) {
|
||||||
|
@ -6,6 +6,7 @@ import { first } from "rxjs/operators";
|
|||||||
|
|
||||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@ -51,6 +52,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
|
|||||||
formBuilder: FormBuilder,
|
formBuilder: FormBuilder,
|
||||||
private filePopoutUtilsService: FilePopoutUtilsService,
|
private filePopoutUtilsService: FilePopoutUtilsService,
|
||||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
|
accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
i18nService,
|
i18nService,
|
||||||
@ -66,6 +68,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
|
|||||||
dialogService,
|
dialogService,
|
||||||
formBuilder,
|
formBuilder,
|
||||||
billingAccountProfileStateService,
|
billingAccountProfileStateService,
|
||||||
|
accountService,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import * as path from "path";
|
|||||||
|
|
||||||
import { program } from "commander";
|
import { program } from "commander";
|
||||||
import * as jsdom from "jsdom";
|
import * as jsdom from "jsdom";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InternalUserDecryptionOptionsServiceAbstraction,
|
InternalUserDecryptionOptionsServiceAbstraction,
|
||||||
@ -79,7 +80,7 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig
|
|||||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||||
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
|
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||||
import {
|
import {
|
||||||
ActiveUserStateProvider,
|
ActiveUserStateProvider,
|
||||||
DerivedStateProvider,
|
DerivedStateProvider,
|
||||||
@ -236,7 +237,7 @@ export class Main {
|
|||||||
biometricStateService: BiometricStateService;
|
biometricStateService: BiometricStateService;
|
||||||
billingAccountProfileStateService: BillingAccountProfileStateService;
|
billingAccountProfileStateService: BillingAccountProfileStateService;
|
||||||
providerApiService: ProviderApiServiceAbstraction;
|
providerApiService: ProviderApiServiceAbstraction;
|
||||||
userKeyInitService: UserKeyInitService;
|
userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||||
kdfConfigService: KdfConfigServiceAbstraction;
|
kdfConfigService: KdfConfigServiceAbstraction;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -709,11 +710,7 @@ export class Main {
|
|||||||
|
|
||||||
this.providerApiService = new ProviderApiService(this.apiService);
|
this.providerApiService = new ProviderApiService(this.apiService);
|
||||||
|
|
||||||
this.userKeyInitService = new UserKeyInitService(
|
this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService);
|
||||||
this.accountService,
|
|
||||||
this.cryptoService,
|
|
||||||
this.logService,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
@ -734,7 +731,7 @@ export class Main {
|
|||||||
this.authService.logOut(() => {
|
this.authService.logOut(() => {
|
||||||
/* Do nothing */
|
/* Do nothing */
|
||||||
});
|
});
|
||||||
const userId = await this.stateService.getUserId();
|
const userId = (await this.stateService.getUserId()) as UserId;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.eventUploadService.uploadEvents(userId as UserId),
|
this.eventUploadService.uploadEvents(userId as UserId),
|
||||||
this.syncService.setLastSync(new Date(0)),
|
this.syncService.setLastSync(new Date(0)),
|
||||||
@ -745,9 +742,10 @@ export class Main {
|
|||||||
this.passwordGenerationService.clear(),
|
this.passwordGenerationService.clear(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await this.stateEventRunnerService.handleEvent("logout", userId as UserId);
|
await this.stateEventRunnerService.handleEvent("logout", userId);
|
||||||
|
|
||||||
await this.stateService.clean();
|
await this.stateService.clean();
|
||||||
|
await this.accountService.clean(userId);
|
||||||
process.env.BW_SESSION = null;
|
process.env.BW_SESSION = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -757,7 +755,11 @@ export class Main {
|
|||||||
this.containerService.attachToGlobal(global);
|
this.containerService.attachToGlobal(global);
|
||||||
await this.i18nService.init();
|
await this.i18nService.init();
|
||||||
this.twoFactorService.init();
|
this.twoFactorService.init();
|
||||||
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
|
||||||
|
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||||
|
if (activeAccount) {
|
||||||
|
await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
} from "@bitwarden/angular/auth/guards";
|
} from "@bitwarden/angular/auth/guards";
|
||||||
|
|
||||||
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
|
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
|
||||||
import { LoginGuard } from "../auth/guards/login.guard";
|
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||||
import { HintComponent } from "../auth/hint.component";
|
import { HintComponent } from "../auth/hint.component";
|
||||||
import { LockComponent } from "../auth/lock.component";
|
import { LockComponent } from "../auth/lock.component";
|
||||||
import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component";
|
import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component";
|
||||||
@ -40,7 +40,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "login",
|
path: "login",
|
||||||
component: LoginComponent,
|
component: LoginComponent,
|
||||||
canActivate: [LoginGuard],
|
canActivate: [maxAccountsGuardFn()],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "login-with-device",
|
path: "login-with-device",
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { firstValueFrom, Subject, takeUntil } from "rxjs";
|
import { firstValueFrom, map, Subject, takeUntil } from "rxjs";
|
||||||
|
|
||||||
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
|
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
|
||||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
@ -18,9 +18,9 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
|
|||||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
|
||||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||||
@ -107,11 +107,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
private lastActivity: number = null;
|
private lastActivity: Date = null;
|
||||||
private modal: ModalRef = null;
|
private modal: ModalRef = null;
|
||||||
private idleTimer: number = null;
|
private idleTimer: number = null;
|
||||||
private isIdle = false;
|
private isIdle = false;
|
||||||
private activeUserId: string = null;
|
private activeUserId: UserId = null;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@ -150,12 +150,12 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
private biometricStateService: BiometricStateService,
|
private biometricStateService: BiometricStateService,
|
||||||
private stateEventRunnerService: StateEventRunnerService,
|
private stateEventRunnerService: StateEventRunnerService,
|
||||||
private providerService: ProviderService,
|
private providerService: ProviderService,
|
||||||
private organizationService: InternalOrganizationServiceAbstraction,
|
private accountService: AccountService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => {
|
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
|
||||||
this.activeUserId = userId;
|
this.activeUserId = account?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ngZone.runOutsideAngular(() => {
|
this.ngZone.runOutsideAngular(() => {
|
||||||
@ -400,7 +400,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
break;
|
break;
|
||||||
case "switchAccount": {
|
case "switchAccount": {
|
||||||
if (message.userId != null) {
|
if (message.userId != null) {
|
||||||
await this.stateService.setActiveUser(message.userId);
|
await this.stateService.clearDecryptedData(message.userId);
|
||||||
|
await this.accountService.switchAccount(message.userId);
|
||||||
}
|
}
|
||||||
const locked =
|
const locked =
|
||||||
(await this.authService.getAuthStatus(message.userId)) ===
|
(await this.authService.getAuthStatus(message.userId)) ===
|
||||||
@ -522,7 +523,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private async updateAppMenu() {
|
private async updateAppMenu() {
|
||||||
let updateRequest: MenuUpdateRequest;
|
let updateRequest: MenuUpdateRequest;
|
||||||
const stateAccounts = await firstValueFrom(this.stateService.accounts$);
|
const stateAccounts = await firstValueFrom(this.accountService.accounts$);
|
||||||
if (stateAccounts == null || Object.keys(stateAccounts).length < 1) {
|
if (stateAccounts == null || Object.keys(stateAccounts).length < 1) {
|
||||||
updateRequest = {
|
updateRequest = {
|
||||||
accounts: null,
|
accounts: null,
|
||||||
@ -531,32 +532,32 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
} else {
|
} else {
|
||||||
const accounts: { [userId: string]: MenuAccount } = {};
|
const accounts: { [userId: string]: MenuAccount } = {};
|
||||||
for (const i in stateAccounts) {
|
for (const i in stateAccounts) {
|
||||||
|
const userId = i as UserId;
|
||||||
if (
|
if (
|
||||||
i != null &&
|
i != null &&
|
||||||
stateAccounts[i]?.profile?.userId != null &&
|
userId != null &&
|
||||||
!this.isAccountCleanUpInProgress(stateAccounts[i].profile.userId) // skip accounts that are being cleaned up
|
!this.isAccountCleanUpInProgress(userId) // skip accounts that are being cleaned up
|
||||||
) {
|
) {
|
||||||
const userId = stateAccounts[i].profile.userId;
|
|
||||||
const availableTimeoutActions = await firstValueFrom(
|
const availableTimeoutActions = await firstValueFrom(
|
||||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
|
||||||
accounts[userId] = {
|
accounts[userId] = {
|
||||||
isAuthenticated: await this.stateService.getIsAuthenticated({
|
isAuthenticated: authStatus >= AuthenticationStatus.Locked,
|
||||||
userId: userId,
|
isLocked: authStatus === AuthenticationStatus.Locked,
|
||||||
}),
|
|
||||||
isLocked:
|
|
||||||
(await this.authService.getAuthStatus(userId)) === AuthenticationStatus.Locked,
|
|
||||||
isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock),
|
isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock),
|
||||||
email: stateAccounts[i].profile.email,
|
email: stateAccounts[userId].email,
|
||||||
userId: stateAccounts[i].profile.userId,
|
userId: userId,
|
||||||
hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId),
|
hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateRequest = {
|
updateRequest = {
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
activeUserId: await this.stateService.getUserId(),
|
activeUserId: await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,7 +565,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async logOut(expired: boolean, userId?: string) {
|
private async logOut(expired: boolean, userId?: string) {
|
||||||
const userBeingLoggedOut = await this.stateService.getUserId({ userId: userId });
|
const userBeingLoggedOut =
|
||||||
|
(userId as UserId) ??
|
||||||
|
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
|
||||||
|
|
||||||
// Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted)
|
// Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted)
|
||||||
// doesn't attempt to update a user that is being logged out as we will manually
|
// doesn't attempt to update a user that is being logged out as we will manually
|
||||||
@ -572,9 +575,10 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
this.startAccountCleanUp(userBeingLoggedOut);
|
this.startAccountCleanUp(userBeingLoggedOut);
|
||||||
|
|
||||||
let preLogoutActiveUserId;
|
let preLogoutActiveUserId;
|
||||||
|
const nextUpAccount = await firstValueFrom(this.accountService.nextUpAccount$);
|
||||||
try {
|
try {
|
||||||
// Provide the userId of the user to upload events for
|
// Provide the userId of the user to upload events for
|
||||||
await this.eventUploadService.uploadEvents(userBeingLoggedOut as UserId);
|
await this.eventUploadService.uploadEvents(userBeingLoggedOut);
|
||||||
await this.syncService.setLastSync(new Date(0), userBeingLoggedOut);
|
await this.syncService.setLastSync(new Date(0), userBeingLoggedOut);
|
||||||
await this.cryptoService.clearKeys(userBeingLoggedOut);
|
await this.cryptoService.clearKeys(userBeingLoggedOut);
|
||||||
await this.cipherService.clear(userBeingLoggedOut);
|
await this.cipherService.clear(userBeingLoggedOut);
|
||||||
@ -582,22 +586,23 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
await this.collectionService.clear(userBeingLoggedOut);
|
await this.collectionService.clear(userBeingLoggedOut);
|
||||||
await this.passwordGenerationService.clear(userBeingLoggedOut);
|
await this.passwordGenerationService.clear(userBeingLoggedOut);
|
||||||
await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut);
|
await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut);
|
||||||
await this.biometricStateService.logout(userBeingLoggedOut as UserId);
|
await this.biometricStateService.logout(userBeingLoggedOut);
|
||||||
|
|
||||||
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut as UserId);
|
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut);
|
||||||
|
|
||||||
preLogoutActiveUserId = this.activeUserId;
|
preLogoutActiveUserId = this.activeUserId;
|
||||||
await this.stateService.clean({ userId: userBeingLoggedOut });
|
await this.stateService.clean({ userId: userBeingLoggedOut });
|
||||||
|
await this.accountService.clean(userBeingLoggedOut);
|
||||||
} finally {
|
} finally {
|
||||||
this.finishAccountCleanUp(userBeingLoggedOut);
|
this.finishAccountCleanUp(userBeingLoggedOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeUserId == null) {
|
if (nextUpAccount == null) {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.router.navigate(["login"]);
|
this.router.navigate(["login"]);
|
||||||
} else if (preLogoutActiveUserId !== this.activeUserId) {
|
} else if (preLogoutActiveUserId !== nextUpAccount.id) {
|
||||||
this.messagingService.send("switchAccount");
|
this.messagingService.send("switchAccount", { userId: nextUpAccount.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateAppMenu();
|
await this.updateAppMenu();
|
||||||
@ -622,13 +627,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().getTime();
|
const now = new Date();
|
||||||
if (this.lastActivity != null && now - this.lastActivity < 250) {
|
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastActivity = now;
|
this.lastActivity = now;
|
||||||
await this.stateService.setLastActive(now, { userId: this.activeUserId });
|
await this.accountService.setAccountActivity(this.activeUserId, now);
|
||||||
|
|
||||||
// Idle states
|
// Idle states
|
||||||
if (this.isIdle) {
|
if (this.isIdle) {
|
||||||
|
@ -1,110 +1,112 @@
|
|||||||
<!-- Please remove this disable statement when editing this file! -->
|
<!-- Please remove this disable statement when editing this file! -->
|
||||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||||
<button
|
<ng-container *ngIf="view$ | async as view">
|
||||||
class="account-switcher"
|
<button
|
||||||
(click)="toggle()"
|
class="account-switcher"
|
||||||
cdkOverlayOrigin
|
(click)="toggle()"
|
||||||
#trigger="cdkOverlayOrigin"
|
cdkOverlayOrigin
|
||||||
[hidden]="!showSwitcher"
|
#trigger="cdkOverlayOrigin"
|
||||||
aria-haspopup="dialog"
|
[hidden]="!view.showSwitcher"
|
||||||
>
|
aria-haspopup="dialog"
|
||||||
<ng-container *ngIf="activeAccount?.email != null; else noActiveAccount">
|
|
||||||
<app-avatar
|
|
||||||
[text]="activeAccount.name"
|
|
||||||
[id]="activeAccount.id"
|
|
||||||
[color]="activeAccount.avatarColor"
|
|
||||||
[size]="25"
|
|
||||||
[circle]="true"
|
|
||||||
[fontSize]="14"
|
|
||||||
[dynamic]="true"
|
|
||||||
*ngIf="activeAccount.email != null"
|
|
||||||
aria-hidden="true"
|
|
||||||
></app-avatar>
|
|
||||||
<div class="active-account">
|
|
||||||
<div>{{ activeAccount.email }}</div>
|
|
||||||
<span>{{ activeAccount.server }}</span>
|
|
||||||
<span class="sr-only"> ({{ "switchAccount" | i18n }})</span>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #noActiveAccount>
|
|
||||||
<span>{{ "switchAccount" | i18n }}</span>
|
|
||||||
</ng-template>
|
|
||||||
<i
|
|
||||||
class="bwi"
|
|
||||||
aria-hidden="true"
|
|
||||||
[ngClass]="{ 'bwi-angle-down': !isOpen, 'bwi-angle-up': isOpen }"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ng-template
|
|
||||||
cdkConnectedOverlay
|
|
||||||
[cdkConnectedOverlayOrigin]="trigger"
|
|
||||||
[cdkConnectedOverlayHasBackdrop]="true"
|
|
||||||
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
|
|
||||||
(backdropClick)="close()"
|
|
||||||
(detach)="close()"
|
|
||||||
[cdkConnectedOverlayOpen]="showSwitcher && isOpen"
|
|
||||||
[cdkConnectedOverlayPositions]="overlayPosition"
|
|
||||||
cdkConnectedOverlayMinWidth="250px"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="account-switcher-dropdown"
|
|
||||||
[@transformPanel]="'open'"
|
|
||||||
cdkTrapFocus
|
|
||||||
cdkTrapFocusAutoCapture
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
>
|
||||||
<div class="accounts" *ngIf="numberOfAccounts > 0">
|
<ng-container *ngIf="view.activeAccount; else noActiveAccount">
|
||||||
<button
|
<app-avatar
|
||||||
*ngFor="let account of inactiveAccounts | keyvalue"
|
[text]="view.activeAccount.name ?? view.activeAccount.email"
|
||||||
class="account"
|
[id]="view.activeAccount.id"
|
||||||
(click)="switch(account.key)"
|
[color]="view.activeAccount.avatarColor"
|
||||||
>
|
[size]="25"
|
||||||
<app-avatar
|
[circle]="true"
|
||||||
[text]="account.value.name ?? account.value.email"
|
[fontSize]="14"
|
||||||
[id]="account.value.id"
|
[dynamic]="true"
|
||||||
[size]="25"
|
*ngIf="view.activeAccount.email != null"
|
||||||
[circle]="true"
|
aria-hidden="true"
|
||||||
[fontSize]="14"
|
></app-avatar>
|
||||||
[dynamic]="true"
|
<div class="active-account">
|
||||||
[color]="account.value.avatarColor"
|
<div>{{ view.activeAccount.email }}</div>
|
||||||
*ngIf="account.value.email != null"
|
<span>{{ view.activeAccount.server }}</span>
|
||||||
aria-hidden="true"
|
<span class="sr-only"> ({{ "switchAccount" | i18n }})</span>
|
||||||
></app-avatar>
|
</div>
|
||||||
<div class="accountInfo">
|
|
||||||
<span class="sr-only">{{ "switchAccount" | i18n }}: </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"> (</span
|
|
||||||
>{{
|
|
||||||
(account.value.authenticationStatus === authStatus.Unlocked ? "unlocked" : "locked")
|
|
||||||
| i18n
|
|
||||||
}}<span class="sr-only">)</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<i
|
|
||||||
class="bwi bwi-2x text-muted"
|
|
||||||
[ngClass]="
|
|
||||||
account.value.authenticationStatus === authStatus.Unlocked ? 'bwi-unlock' : 'bwi-lock'
|
|
||||||
"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ng-container *ngIf="activeAccount?.email != null">
|
|
||||||
<div class="border" *ngIf="numberOfAccounts > 0"></div>
|
|
||||||
<ng-container *ngIf="numberOfAccounts < 4">
|
|
||||||
<button type="button" class="add" (click)="addAccount()">
|
|
||||||
<i class="bwi bwi-plus" aria-hidden="true"></i> {{ "addAccount" | i18n }}
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="numberOfAccounts === 4">
|
|
||||||
<span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span>
|
|
||||||
</ng-container>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
<ng-template #noActiveAccount>
|
||||||
</ng-template>
|
<span>{{ "switchAccount" | i18n }}</span>
|
||||||
|
</ng-template>
|
||||||
|
<i
|
||||||
|
class="bwi"
|
||||||
|
aria-hidden="true"
|
||||||
|
[ngClass]="{ 'bwi-angle-down': !isOpen, 'bwi-angle-up': isOpen }"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-template
|
||||||
|
cdkConnectedOverlay
|
||||||
|
[cdkConnectedOverlayOrigin]="trigger"
|
||||||
|
[cdkConnectedOverlayHasBackdrop]="true"
|
||||||
|
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
|
||||||
|
(backdropClick)="close()"
|
||||||
|
(detach)="close()"
|
||||||
|
[cdkConnectedOverlayOpen]="view.showSwitcher && isOpen"
|
||||||
|
[cdkConnectedOverlayPositions]="overlayPosition"
|
||||||
|
cdkConnectedOverlayMinWidth="250px"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="account-switcher-dropdown"
|
||||||
|
[@transformPanel]="'open'"
|
||||||
|
cdkTrapFocus
|
||||||
|
cdkTrapFocusAutoCapture
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="accounts" *ngIf="view.numberOfAccounts > 0">
|
||||||
|
<button
|
||||||
|
*ngFor="let account of view.inactiveAccounts | keyvalue"
|
||||||
|
class="account"
|
||||||
|
(click)="switch(account.key)"
|
||||||
|
>
|
||||||
|
<app-avatar
|
||||||
|
[text]="account.value.name ?? account.value.email"
|
||||||
|
[id]="account.value.id"
|
||||||
|
[size]="25"
|
||||||
|
[circle]="true"
|
||||||
|
[fontSize]="14"
|
||||||
|
[dynamic]="true"
|
||||||
|
[color]="account.value.avatarColor"
|
||||||
|
*ngIf="account.value.email != null"
|
||||||
|
aria-hidden="true"
|
||||||
|
></app-avatar>
|
||||||
|
<div class="accountInfo">
|
||||||
|
<span class="sr-only">{{ "switchAccount" | i18n }}: </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"> (</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>
|
||||||
|
@ -1,19 +1,17 @@
|
|||||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||||
import { ConnectedPosition } from "@angular/cdk/overlay";
|
import { ConnectedPosition } from "@angular/cdk/overlay";
|
||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component } from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs";
|
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
|
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
|
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
||||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
type ActiveAccount = {
|
type ActiveAccount = {
|
||||||
@ -52,12 +50,18 @@ type InactiveAccount = ActiveAccount & {
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
export class AccountSwitcherComponent {
|
||||||
activeAccount?: ActiveAccount;
|
activeAccount$: Observable<ActiveAccount | null>;
|
||||||
inactiveAccounts: { [userId: string]: InactiveAccount } = {};
|
inactiveAccounts$: Observable<{ [userId: string]: InactiveAccount }>;
|
||||||
|
|
||||||
authStatus = AuthenticationStatus;
|
authStatus = AuthenticationStatus;
|
||||||
|
|
||||||
|
view$: Observable<{
|
||||||
|
activeAccount: ActiveAccount | null;
|
||||||
|
inactiveAccounts: { [userId: string]: InactiveAccount };
|
||||||
|
numberOfAccounts: number;
|
||||||
|
showSwitcher: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
overlayPosition: ConnectedPosition[] = [
|
overlayPosition: ConnectedPosition[] = [
|
||||||
{
|
{
|
||||||
@ -68,21 +72,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
showSwitcher$: Observable<boolean>;
|
||||||
|
|
||||||
get showSwitcher() {
|
numberOfAccounts$: Observable<number>;
|
||||||
const userIsInAVault = !Utils.isNullOrWhitespace(this.activeAccount?.email);
|
|
||||||
const userIsAddingAnAdditionalAccount = Object.keys(this.inactiveAccounts).length > 0;
|
|
||||||
return userIsInAVault || userIsAddingAnAdditionalAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get numberOfAccounts() {
|
|
||||||
if (this.inactiveAccounts == null) {
|
|
||||||
this.isOpen = false;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Object.keys(this.inactiveAccounts).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
@ -90,37 +82,65 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
private avatarService: AvatarService,
|
private avatarService: AvatarService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenService: TokenService,
|
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private loginEmailService: LoginEmailServiceAbstraction,
|
private loginEmailService: LoginEmailServiceAbstraction,
|
||||||
) {}
|
private accountService: AccountService,
|
||||||
|
) {
|
||||||
|
this.activeAccount$ = this.accountService.activeAccount$.pipe(
|
||||||
|
switchMap(async (active) => {
|
||||||
|
if (active == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
return {
|
||||||
this.stateService.accounts$
|
id: active.id,
|
||||||
.pipe(
|
name: active.name,
|
||||||
concatMap(async (accounts: { [userId: string]: Account }) => {
|
email: active.email,
|
||||||
this.inactiveAccounts = await this.createInactiveAccounts(accounts);
|
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
|
||||||
|
server: (await this.environmentService.getEnvironment())?.getHostname(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.inactiveAccounts$ = combineLatest([
|
||||||
|
this.activeAccount$,
|
||||||
|
this.accountService.accounts$,
|
||||||
|
this.authService.authStatuses$,
|
||||||
|
]).pipe(
|
||||||
|
switchMap(async ([activeAccount, accounts, accountStatuses]) => {
|
||||||
|
// Filter out logged out accounts and active account
|
||||||
|
accounts = Object.fromEntries(
|
||||||
|
Object.entries(accounts).filter(
|
||||||
|
([id]: [UserId, AccountInfo]) =>
|
||||||
|
accountStatuses[id] !== AuthenticationStatus.LoggedOut || id === activeAccount?.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return this.createInactiveAccounts(accounts);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.showSwitcher$ = combineLatest([this.activeAccount$, this.inactiveAccounts$]).pipe(
|
||||||
|
map(([activeAccount, inactiveAccounts]) => {
|
||||||
|
const hasActiveUser = activeAccount != null;
|
||||||
|
const userIsAddingAnAdditionalAccount = Object.keys(inactiveAccounts).length > 0;
|
||||||
|
return hasActiveUser || userIsAddingAnAdditionalAccount;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.numberOfAccounts$ = this.inactiveAccounts$.pipe(
|
||||||
|
map((accounts) => Object.keys(accounts).length),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
this.view$ = combineLatest([
|
||||||
this.activeAccount = {
|
this.activeAccount$,
|
||||||
id: await this.tokenService.getUserId(),
|
this.inactiveAccounts$,
|
||||||
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()),
|
this.numberOfAccounts$,
|
||||||
email: await this.tokenService.getEmail(),
|
this.showSwitcher$,
|
||||||
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
|
]).pipe(
|
||||||
server: (await this.environmentService.getEnvironment())?.getHostname(),
|
map(([activeAccount, inactiveAccounts, numberOfAccounts, showSwitcher]) => ({
|
||||||
};
|
activeAccount,
|
||||||
} catch {
|
inactiveAccounts,
|
||||||
this.activeAccount = undefined;
|
numberOfAccounts,
|
||||||
}
|
showSwitcher,
|
||||||
}),
|
})),
|
||||||
takeUntil(this.destroy$),
|
);
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
@ -144,11 +164,13 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
await this.loginEmailService.saveEmailSettings();
|
await this.loginEmailService.saveEmailSettings();
|
||||||
|
|
||||||
await this.router.navigate(["/login"]);
|
await this.router.navigate(["/login"]);
|
||||||
await this.stateService.setActiveUser(null);
|
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||||
|
await this.stateService.clearDecryptedData(activeAccount?.id as UserId);
|
||||||
|
await this.accountService.switchAccount(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createInactiveAccounts(baseAccounts: {
|
private async createInactiveAccounts(baseAccounts: {
|
||||||
[userId: string]: Account;
|
[userId: string]: AccountInfo;
|
||||||
}): Promise<{ [userId: string]: InactiveAccount }> {
|
}): Promise<{ [userId: string]: InactiveAccount }> {
|
||||||
const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
|
const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
|
||||||
|
|
||||||
@ -159,8 +181,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
inactiveAccounts[userId] = {
|
inactiveAccounts[userId] = {
|
||||||
id: userId,
|
id: userId,
|
||||||
name: baseAccounts[userId].profile.name,
|
name: baseAccounts[userId].name,
|
||||||
email: baseAccounts[userId].profile.email,
|
email: baseAccounts[userId].email,
|
||||||
authenticationStatus: await this.authService.getAuthStatus(userId),
|
authenticationStatus: await this.authService.getAuthStatus(userId),
|
||||||
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
|
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
|
||||||
server: (await this.environmentService.getEnvironment(userId))?.getHostname(),
|
server: (await this.environmentService.getEnvironment(userId))?.getHostname(),
|
||||||
|
@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
|
|||||||
import { UntypedFormControl } from "@angular/forms";
|
import { UntypedFormControl } from "@angular/forms";
|
||||||
import { Subscription } from "rxjs";
|
import { Subscription } from "rxjs";
|
||||||
|
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
|
||||||
import { SearchBarService, SearchBarState } from "./search-bar.service";
|
import { SearchBarService, SearchBarState } from "./search-bar.service";
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private searchBarService: SearchBarService,
|
private searchBarService: SearchBarService,
|
||||||
private stateService: StateService,
|
private accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
this.searchBarService.state$.subscribe((state) => {
|
this.searchBarService.state$.subscribe((state) => {
|
||||||
@ -33,7 +33,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
this.activeAccountSubscription = this.stateService.activeAccount$.subscribe((value) => {
|
this.activeAccountSubscription = this.accountService.activeAccount$.subscribe((_) => {
|
||||||
this.searchBarService.setSearchText("");
|
this.searchBarService.setSearchText("");
|
||||||
this.searchText.patchValue("");
|
this.searchText.patchValue("");
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { DOCUMENT } from "@angular/common";
|
import { DOCUMENT } from "@angular/common";
|
||||||
import { Inject, Injectable } from "@angular/core";
|
import { Inject, Injectable } from "@angular/core";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||||
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
@ -12,9 +14,10 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
|||||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||||
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
|
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||||
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
|
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
|
|
||||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||||
@ -36,7 +39,8 @@ export class InitService {
|
|||||||
private nativeMessagingService: NativeMessagingService,
|
private nativeMessagingService: NativeMessagingService,
|
||||||
private themingService: AbstractThemingService,
|
private themingService: AbstractThemingService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private userKeyInitService: UserKeyInitService,
|
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
|
||||||
|
private accountService: AccountService,
|
||||||
@Inject(DOCUMENT) private document: Document,
|
@Inject(DOCUMENT) private document: Document,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -44,7 +48,18 @@ export class InitService {
|
|||||||
return async () => {
|
return async () => {
|
||||||
this.nativeMessagingService.init();
|
this.nativeMessagingService.init();
|
||||||
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
|
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
|
||||||
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
|
||||||
|
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||||
|
const setUserKeyInMemoryPromises = [];
|
||||||
|
for (const userId of Object.keys(accounts) as UserId[]) {
|
||||||
|
// For each acct, we must await the process of setting the user key in memory
|
||||||
|
// if the auto user key is set to avoid race conditions of any code trying to access
|
||||||
|
// the user key from mem.
|
||||||
|
setUserKeyInMemoryPromises.push(
|
||||||
|
this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(setUserKeyInMemoryPromises);
|
||||||
|
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
@ -61,7 +61,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge
|
|||||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
import { LoginGuard } from "../../auth/guards/login.guard";
|
|
||||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||||
import { Account } from "../../models/account";
|
import { Account } from "../../models/account";
|
||||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||||
@ -104,7 +103,6 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider(InitService),
|
safeProvider(InitService),
|
||||||
safeProvider(NativeMessagingService),
|
safeProvider(NativeMessagingService),
|
||||||
safeProvider(SearchBarService),
|
safeProvider(SearchBarService),
|
||||||
safeProvider(LoginGuard),
|
|
||||||
safeProvider(DialogService),
|
safeProvider(DialogService),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: APP_INITIALIZER as SafeInjectionToken<() => void>,
|
provide: APP_INITIALIZER as SafeInjectionToken<() => void>,
|
||||||
@ -194,6 +192,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
AutofillSettingsServiceAbstraction,
|
AutofillSettingsServiceAbstraction,
|
||||||
VaultTimeoutSettingsService,
|
VaultTimeoutSettingsService,
|
||||||
BiometricStateService,
|
BiometricStateService,
|
||||||
|
AccountServiceAbstraction,
|
||||||
TaskSchedulerService,
|
TaskSchedulerService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -4,6 +4,7 @@ import { ActivatedRoute } from "@angular/router";
|
|||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
@ -59,6 +60,10 @@ describe("GeneratorComponent", () => {
|
|||||||
provide: CipherService,
|
provide: CipherService,
|
||||||
useValue: mock<CipherService>(),
|
useValue: mock<CipherService>(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AccountService,
|
||||||
|
useValue: mock<AccountService>(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA],
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
@ -4,6 +4,7 @@ import { FormBuilder } from "@angular/forms";
|
|||||||
|
|
||||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@ -34,6 +35,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
|||||||
dialogService: DialogService,
|
dialogService: DialogService,
|
||||||
formBuilder: FormBuilder,
|
formBuilder: FormBuilder,
|
||||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
|
accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
i18nService,
|
i18nService,
|
||||||
@ -49,6 +51,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
|||||||
dialogService,
|
dialogService,
|
||||||
formBuilder,
|
formBuilder,
|
||||||
billingAccountProfileStateService,
|
billingAccountProfileStateService,
|
||||||
|
accountService,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
38
apps/desktop/src/auth/guards/max-accounts.guard.ts
Normal file
38
apps/desktop/src/auth/guards/max-accounts.guard.ts
Normal 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();
|
||||||
|
}
|
@ -13,6 +13,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
|
|||||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||||
@ -50,7 +51,7 @@ describe("LockComponent", () => {
|
|||||||
let component: LockComponent;
|
let component: LockComponent;
|
||||||
let fixture: ComponentFixture<LockComponent>;
|
let fixture: ComponentFixture<LockComponent>;
|
||||||
let stateServiceMock: MockProxy<StateService>;
|
let stateServiceMock: MockProxy<StateService>;
|
||||||
const biometricStateService = mock<BiometricStateService>();
|
let biometricStateService: MockProxy<BiometricStateService>;
|
||||||
let messagingServiceMock: MockProxy<MessagingService>;
|
let messagingServiceMock: MockProxy<MessagingService>;
|
||||||
let broadcasterServiceMock: MockProxy<BroadcasterService>;
|
let broadcasterServiceMock: MockProxy<BroadcasterService>;
|
||||||
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
|
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
|
||||||
@ -62,7 +63,6 @@ describe("LockComponent", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
stateServiceMock = mock<StateService>();
|
stateServiceMock = mock<StateService>();
|
||||||
stateServiceMock.activeAccount$ = of(null);
|
|
||||||
|
|
||||||
messagingServiceMock = mock<MessagingService>();
|
messagingServiceMock = mock<MessagingService>();
|
||||||
broadcasterServiceMock = mock<BroadcasterService>();
|
broadcasterServiceMock = mock<BroadcasterService>();
|
||||||
@ -73,6 +73,7 @@ describe("LockComponent", () => {
|
|||||||
|
|
||||||
mockMasterPasswordService = new FakeMasterPasswordService();
|
mockMasterPasswordService = new FakeMasterPasswordService();
|
||||||
|
|
||||||
|
biometricStateService = mock();
|
||||||
biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false);
|
biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false);
|
||||||
biometricStateService.promptAutomatically$ = of(false);
|
biometricStateService.promptAutomatically$ = of(false);
|
||||||
biometricStateService.promptCancelled$ = of(false);
|
biometricStateService.promptCancelled$ = of(false);
|
||||||
@ -165,6 +166,10 @@ describe("LockComponent", () => {
|
|||||||
provide: AccountService,
|
provide: AccountService,
|
||||||
useValue: accountService,
|
useValue: accountService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: mock(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: KdfConfigService,
|
provide: KdfConfigService,
|
||||||
useValue: mock<KdfConfigService>(),
|
useValue: mock<KdfConfigService>(),
|
||||||
|
@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
|
|||||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||||
@ -64,6 +65,7 @@ export class LockComponent extends BaseLockComponent {
|
|||||||
pinCryptoService: PinCryptoServiceAbstraction,
|
pinCryptoService: PinCryptoServiceAbstraction,
|
||||||
biometricStateService: BiometricStateService,
|
biometricStateService: BiometricStateService,
|
||||||
accountService: AccountService,
|
accountService: AccountService,
|
||||||
|
authService: AuthService,
|
||||||
kdfConfigService: KdfConfigService,
|
kdfConfigService: KdfConfigService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
@ -89,6 +91,7 @@ export class LockComponent extends BaseLockComponent {
|
|||||||
pinCryptoService,
|
pinCryptoService,
|
||||||
biometricStateService,
|
biometricStateService,
|
||||||
accountService,
|
accountService,
|
||||||
|
authService,
|
||||||
kdfConfigService,
|
kdfConfigService,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -65,9 +65,10 @@ export class Menubar {
|
|||||||
isLocked = updateRequest.accounts[updateRequest.activeUserId]?.isLocked ?? true;
|
isLocked = updateRequest.accounts[updateRequest.activeUserId]?.isLocked ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLockable = !isLocked && updateRequest?.accounts[updateRequest.activeUserId]?.isLockable;
|
const isLockable =
|
||||||
|
!isLocked && updateRequest?.accounts?.[updateRequest.activeUserId]?.isLockable;
|
||||||
const hasMasterPassword =
|
const hasMasterPassword =
|
||||||
updateRequest?.accounts[updateRequest.activeUserId]?.hasMasterPassword ?? false;
|
updateRequest?.accounts?.[updateRequest.activeUserId]?.hasMasterPassword ?? false;
|
||||||
|
|
||||||
this.items = [
|
this.items = [
|
||||||
new FileMenu(
|
new FileMenu(
|
||||||
|
@ -619,7 +619,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapCollectionToAccessItemView(
|
function mapCollectionToAccessItemView(
|
||||||
collection: CollectionView,
|
collection: CollectionAdminView,
|
||||||
organization: Organization,
|
organization: Organization,
|
||||||
flexibleCollectionsV1Enabled: boolean,
|
flexibleCollectionsV1Enabled: boolean,
|
||||||
accessSelection?: CollectionAccessSelectionView,
|
accessSelection?: CollectionAccessSelectionView,
|
||||||
@ -631,7 +631,8 @@ function mapCollectionToAccessItemView(
|
|||||||
labelName: collection.name,
|
labelName: collection.name,
|
||||||
listName: collection.name,
|
listName: collection.name,
|
||||||
readonly:
|
readonly:
|
||||||
group !== undefined || !collection.canEdit(organization, flexibleCollectionsV1Enabled),
|
group !== undefined ||
|
||||||
|
!collection.canEditUserAccess(organization, flexibleCollectionsV1Enabled),
|
||||||
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
|
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
|
||||||
viaGroupName: group?.name,
|
viaGroupName: group?.name,
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,7 @@ import { DOCUMENT } from "@angular/common";
|
|||||||
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
|
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { NavigationEnd, Router } from "@angular/router";
|
import { NavigationEnd, Router } from "@angular/router";
|
||||||
import * as jq from "jquery";
|
import * as jq from "jquery";
|
||||||
import { Subject, switchMap, takeUntil, timer } from "rxjs";
|
import { Subject, firstValueFrom, map, switchMap, takeUntil, timer } from "rxjs";
|
||||||
|
|
||||||
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
|
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||||
@ -10,6 +10,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
|||||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||||
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
|
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
|
||||||
@ -51,7 +52,7 @@ const PaymentMethodWarningsRefresh = 60000; // 1 Minute
|
|||||||
templateUrl: "app.component.html",
|
templateUrl: "app.component.html",
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnDestroy, OnInit {
|
export class AppComponent implements OnDestroy, OnInit {
|
||||||
private lastActivity: number = null;
|
private lastActivity: Date = null;
|
||||||
private idleTimer: number = null;
|
private idleTimer: number = null;
|
||||||
private isIdle = false;
|
private isIdle = false;
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
@ -86,6 +87,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
private stateEventRunnerService: StateEventRunnerService,
|
private stateEventRunnerService: StateEventRunnerService,
|
||||||
private paymentMethodWarningService: PaymentMethodWarningService,
|
private paymentMethodWarningService: PaymentMethodWarningService,
|
||||||
private organizationService: InternalOrganizationServiceAbstraction,
|
private organizationService: InternalOrganizationServiceAbstraction,
|
||||||
|
private accountService: AccountService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -298,15 +300,16 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recordActivity() {
|
private async recordActivity() {
|
||||||
const now = new Date().getTime();
|
const activeUserId = await firstValueFrom(
|
||||||
if (this.lastActivity != null && now - this.lastActivity < 250) {
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastActivity = now;
|
this.lastActivity = now;
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.accountService.setAccountActivity(activeUserId, now);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.stateService.setLastActive(now);
|
|
||||||
// Idle states
|
// Idle states
|
||||||
if (this.isIdle) {
|
if (this.isIdle) {
|
||||||
this.isIdle = false;
|
this.isIdle = false;
|
||||||
|
@ -42,7 +42,10 @@
|
|||||||
: subscription.expirationWithGracePeriod
|
: subscription.expirationWithGracePeriod
|
||||||
) | date: "mediumDate"
|
) | date: "mediumDate"
|
||||||
}}
|
}}
|
||||||
<div *ngIf="subscription.hasSeparateGracePeriod" class="tw-text-muted">
|
<div
|
||||||
|
*ngIf="subscription.hasSeparateGracePeriod && !subscription.isInTrial"
|
||||||
|
class="tw-text-muted"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
"selfHostGracePeriodHelp"
|
"selfHostGracePeriodHelp"
|
||||||
| i18n: (subscription.expirationWithGracePeriod | date: "mediumDate")
|
| i18n: (subscription.expirationWithGracePeriod | date: "mediumDate")
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
import { DOCUMENT } from "@angular/common";
|
import { DOCUMENT } from "@angular/common";
|
||||||
import { Inject, Injectable } from "@angular/core";
|
import { Inject, Injectable } from "@angular/core";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||||
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||||
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
|
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||||
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
|
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
|
||||||
|
|
||||||
@ -28,14 +30,21 @@ export class InitService {
|
|||||||
private cryptoService: CryptoServiceAbstraction,
|
private cryptoService: CryptoServiceAbstraction,
|
||||||
private themingService: AbstractThemingService,
|
private themingService: AbstractThemingService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private userKeyInitService: UserKeyInitService,
|
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
|
||||||
|
private accountService: AccountService,
|
||||||
@Inject(DOCUMENT) private document: Document,
|
@Inject(DOCUMENT) private document: Document,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
return async () => {
|
return async () => {
|
||||||
await this.stateService.init();
|
await this.stateService.init();
|
||||||
this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
|
||||||
|
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||||
|
if (activeAccount) {
|
||||||
|
// If there is an active account, we must await the process of setting the user key in memory
|
||||||
|
// if the auto user key is set to avoid race conditions of any code trying to access the user key from mem.
|
||||||
|
await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id);
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => this.notificationsService.init(), 3000);
|
setTimeout(() => this.notificationsService.init(), 3000);
|
||||||
await this.vaultTimeoutService.init(true);
|
await this.vaultTimeoutService.init(true);
|
||||||
|
@ -58,7 +58,7 @@
|
|||||||
[bitMenuTriggerFor]="accountMenu"
|
[bitMenuTriggerFor]="accountMenu"
|
||||||
class="tw-border-0 tw-bg-transparent tw-p-0"
|
class="tw-border-0 tw-bg-transparent tw-p-0"
|
||||||
>
|
>
|
||||||
<dynamic-avatar [id]="account.userId" [text]="account | userName"></dynamic-avatar>
|
<dynamic-avatar [id]="account.id" [text]="account | userName"></dynamic-avatar>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<bit-menu #accountMenu>
|
<bit-menu #accountMenu>
|
||||||
@ -67,7 +67,7 @@
|
|||||||
class="tw-flex tw-items-center tw-px-4 tw-py-1 tw-leading-tight tw-text-info"
|
class="tw-flex tw-items-center tw-px-4 tw-py-1 tw-leading-tight tw-text-info"
|
||||||
appStopProp
|
appStopProp
|
||||||
>
|
>
|
||||||
<dynamic-avatar [id]="account.userId" [text]="account | userName"></dynamic-avatar>
|
<dynamic-avatar [id]="account.id" [text]="account | userName"></dynamic-avatar>
|
||||||
<div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap">
|
<div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap">
|
||||||
<span>{{ "loggedInAs" | i18n }}</span>
|
<span>{{ "loggedInAs" | i18n }}</span>
|
||||||
<small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted">
|
<small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted">
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import { Component, Input } from "@angular/core";
|
import { Component, Input } from "@angular/core";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { combineLatest, map, Observable } from "rxjs";
|
import { map, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { User } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||||
import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service";
|
import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service";
|
||||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { AccountProfile } from "@bitwarden/common/platform/models/domain/account";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-header",
|
selector: "app-header",
|
||||||
@ -28,7 +29,7 @@ export class WebHeaderComponent {
|
|||||||
@Input() icon: string;
|
@Input() icon: string;
|
||||||
|
|
||||||
protected routeData$: Observable<{ titleId: string }>;
|
protected routeData$: Observable<{ titleId: string }>;
|
||||||
protected account$: Observable<AccountProfile>;
|
protected account$: Observable<User & { id: UserId }>;
|
||||||
protected canLock$: Observable<boolean>;
|
protected canLock$: Observable<boolean>;
|
||||||
protected selfHosted: boolean;
|
protected selfHosted: boolean;
|
||||||
protected hostname = location.hostname;
|
protected hostname = location.hostname;
|
||||||
@ -38,12 +39,12 @@ export class WebHeaderComponent {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private stateService: StateService,
|
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
protected unassignedItemsBannerService: UnassignedItemsBannerService,
|
protected unassignedItemsBannerService: UnassignedItemsBannerService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
this.routeData$ = this.route.data.pipe(
|
this.routeData$ = this.route.data.pipe(
|
||||||
map((params) => {
|
map((params) => {
|
||||||
@ -55,14 +56,7 @@ export class WebHeaderComponent {
|
|||||||
|
|
||||||
this.selfHosted = this.platformUtilsService.isSelfHost();
|
this.selfHosted = this.platformUtilsService.isSelfHost();
|
||||||
|
|
||||||
this.account$ = combineLatest([
|
this.account$ = this.accountService.activeAccount$;
|
||||||
this.stateService.activeAccount$,
|
|
||||||
this.stateService.accounts$,
|
|
||||||
]).pipe(
|
|
||||||
map(([activeAccount, accounts]) => {
|
|
||||||
return accounts[activeAccount]?.profile;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
this.canLock$ = this.vaultTimeoutSettingsService
|
this.canLock$ = this.vaultTimeoutSettingsService
|
||||||
.availableVaultTimeoutActions$()
|
.availableVaultTimeoutActions$()
|
||||||
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
|
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
|
||||||
|
@ -5,6 +5,7 @@ import { FormBuilder } from "@angular/forms";
|
|||||||
|
|
||||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@ -40,6 +41,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
|||||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
protected dialogRef: DialogRef,
|
protected dialogRef: DialogRef,
|
||||||
@Inject(DIALOG_DATA) params: { sendId: string },
|
@Inject(DIALOG_DATA) params: { sendId: string },
|
||||||
|
accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
i18nService,
|
i18nService,
|
||||||
@ -55,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent {
|
|||||||
dialogService,
|
dialogService,
|
||||||
formBuilder,
|
formBuilder,
|
||||||
billingAccountProfileStateService,
|
billingAccountProfileStateService,
|
||||||
|
accountService,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sendId = params.sendId;
|
this.sendId = params.sendId;
|
||||||
|
@ -55,7 +55,6 @@ export default {
|
|||||||
{
|
{
|
||||||
provide: StateService,
|
provide: StateService,
|
||||||
useValue: {
|
useValue: {
|
||||||
activeAccount$: new BehaviorSubject("1").asObservable(),
|
|
||||||
accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(),
|
accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(),
|
||||||
async getShowFavicon() {
|
async getShowFavicon() {
|
||||||
return true;
|
return true;
|
||||||
|
@ -31,6 +31,9 @@ export class CollectionAdminView extends CollectionView {
|
|||||||
this.assigned = response.assigned;
|
this.assigned = response.assigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current user can edit the collection, including user and group access
|
||||||
|
*/
|
||||||
override canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
|
override canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
|
||||||
return org?.flexibleCollections
|
return org?.flexibleCollections
|
||||||
? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage
|
? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage
|
||||||
@ -43,4 +46,11 @@ export class CollectionAdminView extends CollectionView {
|
|||||||
? org?.canDeleteAnyCollection || (!org?.limitCollectionCreationDeletion && this.manage)
|
? org?.canDeleteAnyCollection || (!org?.limitCollectionCreationDeletion && this.manage)
|
||||||
: org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned);
|
: org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user can modify user access to this collection
|
||||||
|
*/
|
||||||
|
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
|
||||||
|
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.canManageUsers;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
<form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
|
||||||
<div class="tw-w-2/5">
|
<div class="tw-w-2/5">
|
||||||
<p class="tw-mt-8" *ngIf="!loading">
|
<p class="tw-mt-8" *ngIf="!loading">
|
||||||
{{ "projectPeopleDescription" | i18n }}
|
{{ "projectPeopleDescription" | i18n }}
|
||||||
@ -19,3 +19,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ng-template #spinner>
|
||||||
|
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
|
||||||
|
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { FormControl, FormGroup } from "@angular/forms";
|
import { FormControl, FormGroup } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { combineLatest, Subject, switchMap, takeUntil, catchError, EMPTY } from "rxjs";
|
import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
@ -37,11 +37,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
|||||||
return convertToAccessPolicyItemViews(policies);
|
return convertToAccessPolicyItemViews(policies);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
catchError(() => {
|
catchError(async () => {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.router.navigate(["/sm", this.organizationId, "projects"]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
return [];
|
||||||
this.router.navigate(["/sm", this.organizationId, "projects"]);
|
|
||||||
return EMPTY;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -99,17 +97,20 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
|||||||
if (this.formGroup.invalid) {
|
if (this.formGroup.invalid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const formValues = this.formGroup.value.accessPolicies;
|
||||||
|
this.formGroup.disable();
|
||||||
|
|
||||||
const showAccessRemovalWarning =
|
const showAccessRemovalWarning =
|
||||||
await this.accessPolicySelectorService.showAccessRemovalWarning(
|
await this.accessPolicySelectorService.showAccessRemovalWarning(
|
||||||
this.organizationId,
|
this.organizationId,
|
||||||
this.formGroup.value.accessPolicies,
|
formValues,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (showAccessRemovalWarning) {
|
if (showAccessRemovalWarning) {
|
||||||
const confirmed = await this.showWarning();
|
const confirmed = await this.showWarning();
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
this.setSelected(this.currentAccessPolicies);
|
this.setSelected(this.currentAccessPolicies);
|
||||||
|
this.formGroup.enable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,7 +118,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
const projectPeopleView = convertToProjectPeopleAccessPoliciesView(
|
const projectPeopleView = convertToProjectPeopleAccessPoliciesView(
|
||||||
this.projectId,
|
this.projectId,
|
||||||
this.formGroup.value.accessPolicies,
|
formValues,
|
||||||
);
|
);
|
||||||
const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies(
|
const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies(
|
||||||
this.projectId,
|
this.projectId,
|
||||||
@ -126,9 +127,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
|||||||
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
|
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
|
||||||
|
|
||||||
if (showAccessRemovalWarning) {
|
if (showAccessRemovalWarning) {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.router.navigate(["sm", this.organizationId, "projects"]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.router.navigate(["sm", this.organizationId, "projects"]);
|
|
||||||
}
|
}
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"success",
|
"success",
|
||||||
@ -139,6 +138,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
|
|||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
this.setSelected(this.currentAccessPolicies);
|
this.setSelected(this.currentAccessPolicies);
|
||||||
}
|
}
|
||||||
|
this.formGroup.enable();
|
||||||
};
|
};
|
||||||
|
|
||||||
private setSelected(policiesToSelect: ApItemViewType[]) {
|
private setSelected(policiesToSelect: ApItemViewType[]) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
<form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
|
||||||
<div class="tw-w-2/5">
|
<div class="tw-w-2/5">
|
||||||
<p class="tw-mt-8" *ngIf="!loading">
|
<p class="tw-mt-8" *ngIf="!loading">
|
||||||
{{ "machineAccountPeopleDescription" | i18n }}
|
{{ "machineAccountPeopleDescription" | i18n }}
|
||||||
@ -20,3 +20,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ng-template #spinner>
|
||||||
|
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
|
||||||
|
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { FormControl, FormGroup } from "@angular/forms";
|
import { FormControl, FormGroup } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { catchError, combineLatest, EMPTY, Subject, switchMap, takeUntil } from "rxjs";
|
import { combineLatest, Subject, switchMap, takeUntil } from "rxjs";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
@ -40,12 +40,6 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
|
|||||||
return convertToAccessPolicyItemViews(policies);
|
return convertToAccessPolicyItemViews(policies);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
catchError(() => {
|
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.router.navigate(["/sm", this.organizationId, "machine-accounts"]);
|
|
||||||
return EMPTY;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private potentialGrantees$ = combineLatest([this.route.params]).pipe(
|
private potentialGrantees$ = combineLatest([this.route.params]).pipe(
|
||||||
@ -101,29 +95,32 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
|
|||||||
if (this.isFormInvalid()) {
|
if (this.isFormInvalid()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const formValues = this.formGroup.value.accessPolicies;
|
||||||
|
this.formGroup.disable();
|
||||||
|
|
||||||
const showAccessRemovalWarning =
|
const showAccessRemovalWarning =
|
||||||
await this.accessPolicySelectorService.showAccessRemovalWarning(
|
await this.accessPolicySelectorService.showAccessRemovalWarning(
|
||||||
this.organizationId,
|
this.organizationId,
|
||||||
this.formGroup.value.accessPolicies,
|
formValues,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies)
|
await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies)
|
||||||
) {
|
) {
|
||||||
|
this.formGroup.enable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies(
|
const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies(
|
||||||
this.serviceAccountId,
|
this.serviceAccountId,
|
||||||
this.formGroup.value.accessPolicies,
|
formValues,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.handleAccessTokenAvailableWarning(
|
await this.handleAccessTokenAvailableWarning(
|
||||||
showAccessRemovalWarning,
|
showAccessRemovalWarning,
|
||||||
this.currentAccessPolicies,
|
this.currentAccessPolicies,
|
||||||
this.formGroup.value.accessPolicies,
|
formValues,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
|
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
|
||||||
@ -137,6 +134,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
|
|||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
this.setSelected(this.currentAccessPolicies);
|
this.setSelected(this.currentAccessPolicies);
|
||||||
}
|
}
|
||||||
|
this.formGroup.enable();
|
||||||
};
|
};
|
||||||
|
|
||||||
private setSelected(policiesToSelect: ApItemViewType[]) {
|
private setSelected(policiesToSelect: ApItemViewType[]) {
|
||||||
@ -198,9 +196,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
|
|||||||
selectedPolicies: ApItemValueType[],
|
selectedPolicies: ApItemValueType[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (showAccessRemovalWarning) {
|
if (showAccessRemovalWarning) {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
|
|
||||||
} else if (
|
} else if (
|
||||||
this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies)
|
this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies)
|
||||||
) {
|
) {
|
||||||
|
@ -55,6 +55,7 @@
|
|||||||
bitIconButton="bwi-close"
|
bitIconButton="bwi-close"
|
||||||
buttonType="main"
|
buttonType="main"
|
||||||
size="default"
|
size="default"
|
||||||
|
[disabled]="disabled"
|
||||||
[attr.title]="'remove' | i18n"
|
[attr.title]="'remove' | i18n"
|
||||||
[attr.aria-label]="'remove' | i18n"
|
[attr.aria-label]="'remove' | i18n"
|
||||||
(click)="selectionList.deselectItem(item.id); handleBlur()"
|
(click)="selectionList.deselectItem(item.id); handleBlur()"
|
||||||
@ -84,7 +85,14 @@
|
|||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
|
|
||||||
<div class="tw-ml-3 tw-mt-7 tw-shrink-0">
|
<div class="tw-ml-3 tw-mt-7 tw-shrink-0">
|
||||||
<button type="button" bitButton buttonType="secondary" (click)="addButton()">
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="secondary"
|
||||||
|
[loading]="loading"
|
||||||
|
[disabled]="disabled"
|
||||||
|
(click)="addButton()"
|
||||||
|
>
|
||||||
{{ "add" | i18n }}
|
{{ "add" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
|
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { firstValueFrom, Subject } from "rxjs";
|
import { firstValueFrom, Subject } from "rxjs";
|
||||||
import { concatMap, take, takeUntil } from "rxjs/operators";
|
import { concatMap, map, take, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
|
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
@ -11,10 +11,12 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
|||||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||||
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
||||||
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
|
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
|
||||||
@ -30,6 +32,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
|
|||||||
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||||
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
|
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
@ -46,6 +49,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
supportsBiometric: boolean;
|
supportsBiometric: boolean;
|
||||||
biometricLock: boolean;
|
biometricLock: boolean;
|
||||||
|
|
||||||
|
private activeUserId: UserId;
|
||||||
protected successRoute = "vault";
|
protected successRoute = "vault";
|
||||||
protected forcePasswordResetRoute = "update-temp-password";
|
protected forcePasswordResetRoute = "update-temp-password";
|
||||||
protected onSuccessfulSubmit: () => Promise<void>;
|
protected onSuccessfulSubmit: () => Promise<void>;
|
||||||
@ -80,14 +84,16 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
protected pinCryptoService: PinCryptoServiceAbstraction,
|
protected pinCryptoService: PinCryptoServiceAbstraction,
|
||||||
protected biometricStateService: BiometricStateService,
|
protected biometricStateService: BiometricStateService,
|
||||||
protected accountService: AccountService,
|
protected accountService: AccountService,
|
||||||
|
protected authService: AuthService,
|
||||||
protected kdfConfigService: KdfConfigService,
|
protected kdfConfigService: KdfConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.stateService.activeAccount$
|
this.accountService.activeAccount$
|
||||||
.pipe(
|
.pipe(
|
||||||
concatMap(async () => {
|
concatMap(async (account) => {
|
||||||
await this.load();
|
this.activeUserId = account?.id;
|
||||||
|
await this.load(account?.id);
|
||||||
}),
|
}),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
)
|
)
|
||||||
@ -116,7 +122,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.messagingService.send("logout");
|
this.messagingService.send("logout", { userId: this.activeUserId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,23 +327,35 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load() {
|
private async load(userId: UserId) {
|
||||||
// TODO: Investigate PM-3515
|
// TODO: Investigate PM-3515
|
||||||
|
|
||||||
// The loading of the lock component works as follows:
|
// The loading of the lock component works as follows:
|
||||||
// 1. First, is locking a valid timeout action? If not, we will log the user out.
|
// 1. If the user is unlocked, we're here in error so we navigate to the home page
|
||||||
// 2. If locking IS a valid timeout action, we proceed to show the user the lock screen.
|
// 2. First, is locking a valid timeout action? If not, we will log the user out.
|
||||||
|
// 3. If locking IS a valid timeout action, we proceed to show the user the lock screen.
|
||||||
// The user will be able to unlock as follows:
|
// The user will be able to unlock as follows:
|
||||||
// - If they have a PIN set, they will be presented with the PIN input
|
// - If they have a PIN set, they will be presented with the PIN input
|
||||||
// - If they have a master password and no PIN, they will be presented with the master password input
|
// - If they have a master password and no PIN, they will be presented with the master password input
|
||||||
// - If they have biometrics enabled, they will be presented with the biometric prompt
|
// - If they have biometrics enabled, they will be presented with the biometric prompt
|
||||||
|
|
||||||
|
const isUnlocked = await firstValueFrom(
|
||||||
|
this.authService
|
||||||
|
.authStatusFor$(userId)
|
||||||
|
.pipe(map((status) => status === AuthenticationStatus.Unlocked)),
|
||||||
|
);
|
||||||
|
if (isUnlocked) {
|
||||||
|
// navigate to home
|
||||||
|
await this.router.navigate(["/"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const availableVaultTimeoutActions = await firstValueFrom(
|
const availableVaultTimeoutActions = await firstValueFrom(
|
||||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
||||||
);
|
);
|
||||||
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
|
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
|
||||||
if (!supportsLock) {
|
if (!supportsLock) {
|
||||||
return await this.vaultTimeoutService.logOut();
|
return await this.vaultTimeoutService.logOut(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();
|
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Pipe, PipeTransform } from "@angular/core";
|
import { Pipe, PipeTransform } from "@angular/core";
|
||||||
|
|
||||||
interface User {
|
export interface User {
|
||||||
name?: string;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
}
|
}
|
||||||
|
@ -53,7 +53,6 @@ import { ProviderApiService } from "@bitwarden/common/admin-console/services/pro
|
|||||||
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
|
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
|
||||||
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
|
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||||
import {
|
import {
|
||||||
AccountService,
|
|
||||||
AccountService as AccountServiceAbstraction,
|
AccountService as AccountServiceAbstraction,
|
||||||
InternalAccountService,
|
InternalAccountService,
|
||||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
@ -164,7 +163,7 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r
|
|||||||
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
|
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||||
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
|
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||||
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
||||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||||
import {
|
import {
|
||||||
@ -1138,9 +1137,9 @@ const safeProviders: SafeProvider[] = [
|
|||||||
deps: [StateProvider],
|
deps: [StateProvider],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: UserKeyInitService,
|
provide: UserAutoUnlockKeyService,
|
||||||
useClass: UserKeyInitService,
|
useClass: UserAutoUnlockKeyService,
|
||||||
deps: [AccountService, CryptoServiceAbstraction, LogService],
|
deps: [CryptoServiceAbstraction],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: ErrorHandler,
|
provide: ErrorHandler,
|
||||||
|
@ -5,6 +5,7 @@ import { Subject, firstValueFrom, takeUntil, map, BehaviorSubject, concatMap } f
|
|||||||
|
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@ -118,6 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
protected dialogService: DialogService,
|
protected dialogService: DialogService,
|
||||||
protected formBuilder: FormBuilder,
|
protected formBuilder: FormBuilder,
|
||||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
|
protected accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
this.typeOptions = [
|
this.typeOptions = [
|
||||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||||
@ -215,7 +217,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.emailVerified = await this.stateService.getEmailVerified();
|
this.emailVerified = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((a) => a?.emailVerified ?? false)),
|
||||||
|
);
|
||||||
|
|
||||||
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
|
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
|
||||||
if (this.send == null) {
|
if (this.send == null) {
|
||||||
|
@ -128,6 +128,7 @@ describe("AuthRequestLoginStrategy", () => {
|
|||||||
|
|
||||||
masterPasswordService.masterKeySubject.next(masterKey);
|
masterPasswordService.masterKeySubject.next(masterKey);
|
||||||
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||||
|
tokenService.decodeAccessToken.mockResolvedValue({ sub: mockUserId });
|
||||||
|
|
||||||
await authRequestLoginStrategy.logIn(credentials);
|
await authRequestLoginStrategy.logIn(credentials);
|
||||||
|
|
||||||
|
@ -218,7 +218,7 @@ describe("LoginStrategy", () => {
|
|||||||
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
|
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws if active account isn't found after being initialized", async () => {
|
it("throws if new account isn't active after being initialized", async () => {
|
||||||
const idTokenResponse = identityTokenResponseFactory();
|
const idTokenResponse = identityTokenResponseFactory();
|
||||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||||
|
|
||||||
@ -228,7 +228,8 @@ describe("LoginStrategy", () => {
|
|||||||
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
|
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
|
||||||
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
|
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
|
||||||
|
|
||||||
accountService.activeAccountSubject.next(null);
|
accountService.switchAccount = jest.fn(); // block internal switch to new account
|
||||||
|
accountService.activeAccountSubject.next(null); // simulate no active account
|
||||||
|
|
||||||
await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow();
|
await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
@ -169,6 +169,12 @@ export abstract class LoginStrategy {
|
|||||||
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
|
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
|
||||||
const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
|
const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
|
||||||
|
|
||||||
|
await this.accountService.addAccount(userId, {
|
||||||
|
name: accountInformation.name,
|
||||||
|
email: accountInformation.email,
|
||||||
|
emailVerified: accountInformation.email_verified,
|
||||||
|
});
|
||||||
|
|
||||||
// set access token and refresh token before account initialization so authN status can be accurate
|
// set access token and refresh token before account initialization so authN status can be accurate
|
||||||
// User id will be derived from the access token.
|
// User id will be derived from the access token.
|
||||||
await this.tokenService.setTokens(
|
await this.tokenService.setTokens(
|
||||||
@ -178,6 +184,8 @@ export abstract class LoginStrategy {
|
|||||||
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
|
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.accountService.switchAccount(userId);
|
||||||
|
|
||||||
await this.stateService.addAccount(
|
await this.stateService.addAccount(
|
||||||
new Account({
|
new Account({
|
||||||
profile: {
|
profile: {
|
||||||
|
@ -164,6 +164,7 @@ describe("PasswordLoginStrategy", () => {
|
|||||||
|
|
||||||
masterPasswordService.masterKeySubject.next(masterKey);
|
masterPasswordService.masterKeySubject.next(masterKey);
|
||||||
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||||
|
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
|
||||||
|
|
||||||
await passwordLoginStrategy.logIn(credentials);
|
await passwordLoginStrategy.logIn(credentials);
|
||||||
|
|
||||||
@ -199,6 +200,7 @@ describe("PasswordLoginStrategy", () => {
|
|||||||
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
|
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
|
||||||
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
|
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
|
||||||
policyService.evaluateMasterPassword.mockReturnValue(false);
|
policyService.evaluateMasterPassword.mockReturnValue(false);
|
||||||
|
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
|
||||||
|
|
||||||
const result = await passwordLoginStrategy.logIn(credentials);
|
const result = await passwordLoginStrategy.logIn(credentials);
|
||||||
|
|
||||||
@ -213,6 +215,7 @@ describe("PasswordLoginStrategy", () => {
|
|||||||
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
|
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
|
||||||
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
|
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
|
||||||
policyService.evaluateMasterPassword.mockReturnValue(false);
|
policyService.evaluateMasterPassword.mockReturnValue(false);
|
||||||
|
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
|
||||||
|
|
||||||
const token2FAResponse = new IdentityTwoFactorResponse({
|
const token2FAResponse = new IdentityTwoFactorResponse({
|
||||||
TwoFactorProviders: ["0"],
|
TwoFactorProviders: ["0"],
|
||||||
|
@ -65,6 +65,7 @@ describe("UserDecryptionOptionsService", () => {
|
|||||||
await fakeAccountService.addAccount(givenUser, {
|
await fakeAccountService.addAccount(givenUser, {
|
||||||
name: "Test User 1",
|
name: "Test User 1",
|
||||||
email: "test1@email.com",
|
email: "test1@email.com",
|
||||||
|
emailVerified: false,
|
||||||
});
|
});
|
||||||
await fakeStateProvider.setUserState(
|
await fakeStateProvider.setUserState(
|
||||||
USER_DECRYPTION_OPTIONS,
|
USER_DECRYPTION_OPTIONS,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { ReplaySubject } from "rxjs";
|
import { ReplaySubject, combineLatest, map } from "rxjs";
|
||||||
|
|
||||||
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
||||||
import { UserId } from "../src/types/guid";
|
import { UserId } from "../src/types/guid";
|
||||||
@ -7,15 +7,20 @@ import { UserId } from "../src/types/guid";
|
|||||||
export function mockAccountServiceWith(
|
export function mockAccountServiceWith(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
info: Partial<AccountInfo> = {},
|
info: Partial<AccountInfo> = {},
|
||||||
|
activity: Record<UserId, Date> = {},
|
||||||
): FakeAccountService {
|
): FakeAccountService {
|
||||||
const fullInfo: AccountInfo = {
|
const fullInfo: AccountInfo = {
|
||||||
...info,
|
...info,
|
||||||
...{
|
...{
|
||||||
name: "name",
|
name: "name",
|
||||||
email: "email",
|
email: "email",
|
||||||
|
emailVerified: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new FakeAccountService({ [userId]: fullInfo });
|
|
||||||
|
const fullActivity = { [userId]: new Date(), ...activity };
|
||||||
|
|
||||||
|
const service = new FakeAccountService({ [userId]: fullInfo }, fullActivity);
|
||||||
service.activeAccountSubject.next({ id: userId, ...fullInfo });
|
service.activeAccountSubject.next({ id: userId, ...fullInfo });
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
@ -26,17 +31,46 @@ export class FakeAccountService implements AccountService {
|
|||||||
accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1);
|
accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1);
|
||||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||||
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
|
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
|
||||||
|
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||||
|
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
|
||||||
private _activeUserId: UserId;
|
private _activeUserId: UserId;
|
||||||
get activeUserId() {
|
get activeUserId() {
|
||||||
return this._activeUserId;
|
return this._activeUserId;
|
||||||
}
|
}
|
||||||
accounts$ = this.accountsSubject.asObservable();
|
accounts$ = this.accountsSubject.asObservable();
|
||||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
activeAccount$ = this.activeAccountSubject.asObservable();
|
||||||
|
accountActivity$ = this.accountActivitySubject.asObservable();
|
||||||
|
get sortedUserIds$() {
|
||||||
|
return this.accountActivity$.pipe(
|
||||||
|
map((activity) => {
|
||||||
|
return Object.entries(activity)
|
||||||
|
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
|
||||||
|
.sort((a, b) => a.lastActive.getTime() - b.lastActive.getTime())
|
||||||
|
.map((a) => a.userId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get nextUpAccount$() {
|
||||||
|
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
|
||||||
|
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||||
|
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
|
||||||
|
return nextId ? { id: nextId, ...accounts[nextId] } : null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(initialData: Record<UserId, AccountInfo>) {
|
constructor(initialData: Record<UserId, AccountInfo>, accountActivity?: Record<UserId, Date>) {
|
||||||
this.accountsSubject.next(initialData);
|
this.accountsSubject.next(initialData);
|
||||||
this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id));
|
this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id));
|
||||||
this.activeAccountSubject.next(null);
|
this.activeAccountSubject.next(null);
|
||||||
|
this.accountActivitySubject.next(accountActivity);
|
||||||
|
}
|
||||||
|
setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
|
||||||
|
this.accountActivitySubject.next({
|
||||||
|
...this.accountActivitySubject["_buffer"][0],
|
||||||
|
[userId]: lastActivity,
|
||||||
|
});
|
||||||
|
return this.mock.setAccountActivity(userId, lastActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
||||||
@ -53,10 +87,27 @@ export class FakeAccountService implements AccountService {
|
|||||||
await this.mock.setAccountEmail(userId, email);
|
await this.mock.setAccountEmail(userId, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
|
||||||
|
await this.mock.setAccountEmailVerified(userId, emailVerified);
|
||||||
|
}
|
||||||
|
|
||||||
async switchAccount(userId: UserId): Promise<void> {
|
async switchAccount(userId: UserId): Promise<void> {
|
||||||
const next =
|
const next =
|
||||||
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||||
this.activeAccountSubject.next(next);
|
this.activeAccountSubject.next(next);
|
||||||
await this.mock.switchAccount(userId);
|
await this.mock.switchAccount(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clean(userId: UserId): Promise<void> {
|
||||||
|
const current = this.accountsSubject["_buffer"][0] ?? {};
|
||||||
|
const updated = { ...current, [userId]: loggedOutInfo };
|
||||||
|
this.accountsSubject.next(updated);
|
||||||
|
await this.mock.clean(userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loggedOutInfo: AccountInfo = {
|
||||||
|
name: undefined,
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
};
|
||||||
|
@ -8,18 +8,44 @@ import { UserId } from "../../types/guid";
|
|||||||
*/
|
*/
|
||||||
export type AccountInfo = {
|
export type AccountInfo = {
|
||||||
email: string;
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||||
return a?.email === b?.email && a?.name === b?.name;
|
if (a == null && b == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a == null || b == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (a[key] !== b[key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AccountService {
|
export abstract class AccountService {
|
||||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||||
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
|
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable of the last activity time for each account.
|
||||||
|
*/
|
||||||
|
accountActivity$: Observable<Record<UserId, Date>>;
|
||||||
|
/** Account list in order of descending recency */
|
||||||
|
sortedUserIds$: Observable<UserId[]>;
|
||||||
|
/** Next account that is not the current active account */
|
||||||
|
nextUpAccount$: Observable<{ id: UserId } & AccountInfo>;
|
||||||
/**
|
/**
|
||||||
* Updates the `accounts$` observable with the new account data.
|
* Updates the `accounts$` observable with the new account data.
|
||||||
|
*
|
||||||
|
* @note Also sets the last active date of the account to `now`.
|
||||||
* @param userId
|
* @param userId
|
||||||
* @param accountData
|
* @param accountData
|
||||||
*/
|
*/
|
||||||
@ -36,11 +62,30 @@ export abstract class AccountService {
|
|||||||
* @param email
|
* @param email
|
||||||
*/
|
*/
|
||||||
abstract setAccountEmail(userId: UserId, email: string): Promise<void>;
|
abstract setAccountEmail(userId: UserId, email: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* updates the `accounts$` observable with the new email verification status for the account.
|
||||||
|
* @param userId
|
||||||
|
* @param emailVerified
|
||||||
|
*/
|
||||||
|
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Updates the `activeAccount$` observable with the new active account.
|
* Updates the `activeAccount$` observable with the new active account.
|
||||||
* @param userId
|
* @param userId
|
||||||
*/
|
*/
|
||||||
abstract switchAccount(userId: UserId): Promise<void>;
|
abstract switchAccount(userId: UserId): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Cleans personal information for the given account from the `accounts$` observable. Does not remove the userId from the observable.
|
||||||
|
*
|
||||||
|
* @note Also sets the last active date of the account to `null`.
|
||||||
|
* @param userId
|
||||||
|
*/
|
||||||
|
abstract clean(userId: UserId): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Updates the given user's last activity time.
|
||||||
|
* @param userId
|
||||||
|
* @param lastActivity
|
||||||
|
*/
|
||||||
|
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class InternalAccountService extends AccountService {
|
export abstract class InternalAccountService extends AccountService {
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* need to update test environment so structuredClone works appropriately
|
||||||
|
* @jest-environment ../../libs/shared/test.environment.ts
|
||||||
|
*/
|
||||||
|
|
||||||
import { MockProxy, mock } from "jest-mock-extended";
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
@ -6,15 +11,57 @@ import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
|
|||||||
import { trackEmissions } from "../../../spec/utils";
|
import { trackEmissions } from "../../../spec/utils";
|
||||||
import { LogService } from "../../platform/abstractions/log.service";
|
import { LogService } from "../../platform/abstractions/log.service";
|
||||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||||
|
import { Utils } from "../../platform/misc/utils";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { AccountInfo } from "../abstractions/account.service";
|
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ACCOUNT_ACCOUNTS,
|
ACCOUNT_ACCOUNTS,
|
||||||
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||||
|
ACCOUNT_ACTIVITY,
|
||||||
AccountServiceImplementation,
|
AccountServiceImplementation,
|
||||||
} from "./account.service";
|
} from "./account.service";
|
||||||
|
|
||||||
|
describe("accountInfoEqual", () => {
|
||||||
|
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
|
||||||
|
|
||||||
|
it("compares nulls", () => {
|
||||||
|
expect(accountInfoEqual(null, null)).toBe(true);
|
||||||
|
expect(accountInfoEqual(null, accountInfo)).toBe(false);
|
||||||
|
expect(accountInfoEqual(accountInfo, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares all keys, not just those defined in AccountInfo", () => {
|
||||||
|
const different = { ...accountInfo, extra: "extra" };
|
||||||
|
|
||||||
|
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares name", () => {
|
||||||
|
const same = { ...accountInfo };
|
||||||
|
const different = { ...accountInfo, name: "name2" };
|
||||||
|
|
||||||
|
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||||
|
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares email", () => {
|
||||||
|
const same = { ...accountInfo };
|
||||||
|
const different = { ...accountInfo, email: "email2" };
|
||||||
|
|
||||||
|
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||||
|
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares emailVerified", () => {
|
||||||
|
const same = { ...accountInfo };
|
||||||
|
const different = { ...accountInfo, emailVerified: false };
|
||||||
|
|
||||||
|
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||||
|
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("accountService", () => {
|
describe("accountService", () => {
|
||||||
let messagingService: MockProxy<MessagingService>;
|
let messagingService: MockProxy<MessagingService>;
|
||||||
let logService: MockProxy<LogService>;
|
let logService: MockProxy<LogService>;
|
||||||
@ -22,8 +69,8 @@ describe("accountService", () => {
|
|||||||
let sut: AccountServiceImplementation;
|
let sut: AccountServiceImplementation;
|
||||||
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
|
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
|
||||||
let activeAccountIdState: FakeGlobalState<UserId>;
|
let activeAccountIdState: FakeGlobalState<UserId>;
|
||||||
const userId = "userId" as UserId;
|
const userId = Utils.newGuid() as UserId;
|
||||||
const userInfo = { email: "email", name: "name" };
|
const userInfo = { email: "email", name: "name", emailVerified: true };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
messagingService = mock();
|
messagingService = mock();
|
||||||
@ -86,6 +133,25 @@ describe("accountService", () => {
|
|||||||
|
|
||||||
expect(currentValue).toEqual({ [userId]: userInfo });
|
expect(currentValue).toEqual({ [userId]: userInfo });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets the last active date of the account to now", async () => {
|
||||||
|
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||||
|
state.stateSubject.next({});
|
||||||
|
await sut.addAccount(userId, userInfo);
|
||||||
|
|
||||||
|
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: expect.any(Date) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, undefined, 123, "not a guid"])(
|
||||||
|
"does not set last active if the userId is not a valid guid",
|
||||||
|
async (userId) => {
|
||||||
|
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||||
|
state.stateSubject.next({});
|
||||||
|
await expect(sut.addAccount(userId as UserId, userInfo)).rejects.toThrow(
|
||||||
|
"userId is required",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setAccountName", () => {
|
describe("setAccountName", () => {
|
||||||
@ -134,6 +200,58 @@ describe("accountService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setAccountEmailVerified", () => {
|
||||||
|
const initialState = { [userId]: userInfo };
|
||||||
|
initialState[userId].emailVerified = false;
|
||||||
|
beforeEach(() => {
|
||||||
|
accountsState.stateSubject.next(initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update the account", async () => {
|
||||||
|
await sut.setAccountEmailVerified(userId, true);
|
||||||
|
const currentState = await firstValueFrom(accountsState.state$);
|
||||||
|
|
||||||
|
expect(currentState).toEqual({
|
||||||
|
[userId]: { ...userInfo, emailVerified: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update if the email is the same", async () => {
|
||||||
|
await sut.setAccountEmailVerified(userId, false);
|
||||||
|
const currentState = await firstValueFrom(accountsState.state$);
|
||||||
|
|
||||||
|
expect(currentState).toEqual(initialState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clean", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
accountsState.stateSubject.next({ [userId]: userInfo });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes account info of the given user", async () => {
|
||||||
|
await sut.clean(userId);
|
||||||
|
const currentState = await firstValueFrom(accountsState.state$);
|
||||||
|
|
||||||
|
expect(currentState).toEqual({
|
||||||
|
[userId]: {
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes account activity of the given user", async () => {
|
||||||
|
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||||
|
state.stateSubject.next({ [userId]: new Date() });
|
||||||
|
|
||||||
|
await sut.clean(userId);
|
||||||
|
|
||||||
|
expect(state.nextMock).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("switchAccount", () => {
|
describe("switchAccount", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
accountsState.stateSubject.next({ [userId]: userInfo });
|
accountsState.stateSubject.next({ [userId]: userInfo });
|
||||||
@ -152,4 +270,83 @@ describe("accountService", () => {
|
|||||||
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
|
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("account activity", () => {
|
||||||
|
let state: FakeGlobalState<Record<UserId, Date>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||||
|
});
|
||||||
|
describe("accountActivity$", () => {
|
||||||
|
it("returns the account activity state", async () => {
|
||||||
|
state.stateSubject.next({
|
||||||
|
[toId("user1")]: new Date(1),
|
||||||
|
[toId("user2")]: new Date(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
|
||||||
|
[toId("user1")]: new Date(1),
|
||||||
|
[toId("user2")]: new Date(2),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty object when account activity is null", async () => {
|
||||||
|
state.stateSubject.next(null);
|
||||||
|
|
||||||
|
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortedUserIds$", () => {
|
||||||
|
it("returns the sorted user ids by date with most recent first", async () => {
|
||||||
|
state.stateSubject.next({
|
||||||
|
[toId("user1")]: new Date(3),
|
||||||
|
[toId("user2")]: new Date(2),
|
||||||
|
[toId("user3")]: new Date(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([
|
||||||
|
"user1" as UserId,
|
||||||
|
"user2" as UserId,
|
||||||
|
"user3" as UserId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when account activity is null", async () => {
|
||||||
|
state.stateSubject.next(null);
|
||||||
|
|
||||||
|
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setAccountActivity", () => {
|
||||||
|
const userId = Utils.newGuid() as UserId;
|
||||||
|
it("sets the account activity", async () => {
|
||||||
|
await sut.setAccountActivity(userId, new Date(1));
|
||||||
|
|
||||||
|
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: new Date(1) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not update if the activity is the same", async () => {
|
||||||
|
state.stateSubject.next({ [userId]: new Date(1) });
|
||||||
|
|
||||||
|
await sut.setAccountActivity(userId, new Date(1));
|
||||||
|
|
||||||
|
expect(state.nextMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, undefined, 123, "not a guid"])(
|
||||||
|
"does not set last active if the userId is not a valid guid",
|
||||||
|
async (userId) => {
|
||||||
|
await sut.setAccountActivity(userId as UserId, new Date(1));
|
||||||
|
|
||||||
|
expect(state.nextMock).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toId(userId: string) {
|
||||||
|
return userId as UserId;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
|
import { combineLatestWith, map, distinctUntilChanged, shareReplay, combineLatest } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccountInfo,
|
AccountInfo,
|
||||||
@ -7,8 +7,9 @@ import {
|
|||||||
} from "../../auth/abstractions/account.service";
|
} from "../../auth/abstractions/account.service";
|
||||||
import { LogService } from "../../platform/abstractions/log.service";
|
import { LogService } from "../../platform/abstractions/log.service";
|
||||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||||
|
import { Utils } from "../../platform/misc/utils";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_MEMORY,
|
ACCOUNT_DISK,
|
||||||
GlobalState,
|
GlobalState,
|
||||||
GlobalStateProvider,
|
GlobalStateProvider,
|
||||||
KeyDefinition,
|
KeyDefinition,
|
||||||
@ -16,25 +17,36 @@ import {
|
|||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
|
|
||||||
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
|
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
|
||||||
ACCOUNT_MEMORY,
|
ACCOUNT_DISK,
|
||||||
"accounts",
|
"accounts",
|
||||||
{
|
{
|
||||||
deserializer: (accountInfo) => accountInfo,
|
deserializer: (accountInfo) => accountInfo,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", {
|
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_DISK, "activeAccountId", {
|
||||||
deserializer: (id: UserId) => id,
|
deserializer: (id: UserId) => id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK, "activity", {
|
||||||
|
deserializer: (activity) => new Date(activity),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LOGGED_OUT_INFO: AccountInfo = {
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
name: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export class AccountServiceImplementation implements InternalAccountService {
|
export class AccountServiceImplementation implements InternalAccountService {
|
||||||
private lock = new Subject<UserId>();
|
|
||||||
private logout = new Subject<UserId>();
|
|
||||||
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
||||||
private activeAccountIdState: GlobalState<UserId | undefined>;
|
private activeAccountIdState: GlobalState<UserId | undefined>;
|
||||||
|
|
||||||
accounts$;
|
accounts$;
|
||||||
activeAccount$;
|
activeAccount$;
|
||||||
|
accountActivity$;
|
||||||
|
sortedUserIds$;
|
||||||
|
nextUpAccount$;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
@ -53,14 +65,40 @@ export class AccountServiceImplementation implements InternalAccountService {
|
|||||||
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
|
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
);
|
);
|
||||||
|
this.accountActivity$ = this.globalStateProvider
|
||||||
|
.get(ACCOUNT_ACTIVITY)
|
||||||
|
.state$.pipe(map((activity) => activity ?? {}));
|
||||||
|
this.sortedUserIds$ = this.accountActivity$.pipe(
|
||||||
|
map((activity) => {
|
||||||
|
return Object.entries(activity)
|
||||||
|
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
|
||||||
|
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()) // later dates first
|
||||||
|
.map((a) => a.userId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.nextUpAccount$ = combineLatest([
|
||||||
|
this.accounts$,
|
||||||
|
this.activeAccount$,
|
||||||
|
this.sortedUserIds$,
|
||||||
|
]).pipe(
|
||||||
|
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||||
|
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
|
||||||
|
return nextId ? { id: nextId, ...accounts[nextId] } : null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
||||||
|
if (!Utils.isGuid(userId)) {
|
||||||
|
throw new Error("userId is required");
|
||||||
|
}
|
||||||
|
|
||||||
await this.accountsState.update((accounts) => {
|
await this.accountsState.update((accounts) => {
|
||||||
accounts ||= {};
|
accounts ||= {};
|
||||||
accounts[userId] = accountData;
|
accounts[userId] = accountData;
|
||||||
return accounts;
|
return accounts;
|
||||||
});
|
});
|
||||||
|
await this.setAccountActivity(userId, new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAccountName(userId: UserId, name: string): Promise<void> {
|
async setAccountName(userId: UserId, name: string): Promise<void> {
|
||||||
@ -71,6 +109,15 @@ export class AccountServiceImplementation implements InternalAccountService {
|
|||||||
await this.setAccountInfo(userId, { email });
|
await this.setAccountInfo(userId, { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
|
||||||
|
await this.setAccountInfo(userId, { emailVerified });
|
||||||
|
}
|
||||||
|
|
||||||
|
async clean(userId: UserId) {
|
||||||
|
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
|
||||||
|
await this.removeAccountActivity(userId);
|
||||||
|
}
|
||||||
|
|
||||||
async switchAccount(userId: UserId): Promise<void> {
|
async switchAccount(userId: UserId): Promise<void> {
|
||||||
await this.activeAccountIdState.update(
|
await this.activeAccountIdState.update(
|
||||||
(_, accounts) => {
|
(_, accounts) => {
|
||||||
@ -94,6 +141,37 @@ export class AccountServiceImplementation implements InternalAccountService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
|
||||||
|
if (!Utils.isGuid(userId)) {
|
||||||
|
// only store for valid userIds
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
|
||||||
|
(activity) => {
|
||||||
|
activity ||= {};
|
||||||
|
activity[userId] = lastActivity;
|
||||||
|
return activity;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldUpdate: (oldActivity) => oldActivity?.[userId]?.getTime() !== lastActivity?.getTime(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAccountActivity(userId: UserId): Promise<void> {
|
||||||
|
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
|
||||||
|
(activity) => {
|
||||||
|
if (activity == null) {
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
delete activity[userId];
|
||||||
|
return activity;
|
||||||
|
},
|
||||||
|
{ shouldUpdate: (oldActivity) => oldActivity?.[userId] != null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
|
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
|
||||||
async delete(): Promise<void> {
|
async delete(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
@ -56,6 +56,7 @@ describe("AuthService", () => {
|
|||||||
status: AuthenticationStatus.Unlocked,
|
status: AuthenticationStatus.Unlocked,
|
||||||
id: userId,
|
id: userId,
|
||||||
email: "email",
|
email: "email",
|
||||||
|
emailVerified: false,
|
||||||
name: "name",
|
name: "name",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -109,6 +110,7 @@ describe("AuthService", () => {
|
|||||||
status: AuthenticationStatus.Unlocked,
|
status: AuthenticationStatus.Unlocked,
|
||||||
id: Utils.newGuid() as UserId,
|
id: Utils.newGuid() as UserId,
|
||||||
email: "email2",
|
email: "email2",
|
||||||
|
emailVerified: false,
|
||||||
name: "name2",
|
name: "name2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -126,7 +128,11 @@ describe("AuthService", () => {
|
|||||||
it("requests auth status for all known users", async () => {
|
it("requests auth status for all known users", async () => {
|
||||||
const userId2 = Utils.newGuid() as UserId;
|
const userId2 = Utils.newGuid() as UserId;
|
||||||
|
|
||||||
await accountService.addAccount(userId2, { email: "email2", name: "name2" });
|
await accountService.addAccount(userId2, {
|
||||||
|
email: "email2",
|
||||||
|
emailVerified: false,
|
||||||
|
name: "name2",
|
||||||
|
});
|
||||||
|
|
||||||
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
|
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
|
||||||
sut.authStatusFor$ = mockFn;
|
sut.authStatusFor$ = mockFn;
|
||||||
@ -147,11 +153,14 @@ describe("AuthService", () => {
|
|||||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits LoggedOut when userId is null", async () => {
|
it.each([null, undefined, "not a userId"])(
|
||||||
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
|
"emits LoggedOut when userId is invalid (%s)",
|
||||||
AuthenticationStatus.LoggedOut,
|
async () => {
|
||||||
);
|
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
|
||||||
});
|
AuthenticationStatus.LoggedOut,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("emits LoggedOut when there is no access token", async () => {
|
it("emits LoggedOut when there is no access token", async () => {
|
||||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
Observable,
|
Observable,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
|
firstValueFrom,
|
||||||
map,
|
map,
|
||||||
of,
|
of,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
@ -12,6 +13,7 @@ import { ApiService } from "../../abstractions/api.service";
|
|||||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||||
import { StateService } from "../../platform/abstractions/state.service";
|
import { StateService } from "../../platform/abstractions/state.service";
|
||||||
|
import { Utils } from "../../platform/misc/utils";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { AccountService } from "../abstractions/account.service";
|
import { AccountService } from "../abstractions/account.service";
|
||||||
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
||||||
@ -39,13 +41,16 @@ export class AuthService implements AuthServiceAbstraction {
|
|||||||
|
|
||||||
this.authStatuses$ = this.accountService.accounts$.pipe(
|
this.authStatuses$ = this.accountService.accounts$.pipe(
|
||||||
map((accounts) => Object.keys(accounts) as UserId[]),
|
map((accounts) => Object.keys(accounts) as UserId[]),
|
||||||
switchMap((entries) =>
|
switchMap((entries) => {
|
||||||
combineLatest(
|
if (entries.length === 0) {
|
||||||
|
return of([] as { userId: UserId; status: AuthenticationStatus }[]);
|
||||||
|
}
|
||||||
|
return combineLatest(
|
||||||
entries.map((userId) =>
|
entries.map((userId) =>
|
||||||
this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))),
|
this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
map((statuses) => {
|
map((statuses) => {
|
||||||
return statuses.reduce(
|
return statuses.reduce(
|
||||||
(acc, { userId, status }) => {
|
(acc, { userId, status }) => {
|
||||||
@ -59,7 +64,7 @@ export class AuthService implements AuthServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
|
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
|
||||||
if (userId == null) {
|
if (!Utils.isGuid(userId)) {
|
||||||
return of(AuthenticationStatus.LoggedOut);
|
return of(AuthenticationStatus.LoggedOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,17 +89,8 @@ export class AuthService implements AuthServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
|
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
|
||||||
// If we don't have an access token or userId, we're logged out
|
userId ??= await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
|
||||||
const isAuthenticated = await this.stateService.getIsAuthenticated({ userId: userId });
|
return await firstValueFrom(this.authStatusFor$(userId as UserId));
|
||||||
if (!isAuthenticated) {
|
|
||||||
return AuthenticationStatus.LoggedOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: since we aggresively set the auto user key to memory if it exists on app init (see InitService)
|
|
||||||
// we only need to check if the user key is in memory.
|
|
||||||
const hasUserKey = await this.cryptoService.hasUserKeyInMemory(userId as UserId);
|
|
||||||
|
|
||||||
return hasUserKey ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logOut(callback: () => void) {
|
logOut(callback: () => void) {
|
||||||
|
@ -90,6 +90,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
|||||||
const user1AccountInfo: AccountInfo = {
|
const user1AccountInfo: AccountInfo = {
|
||||||
name: "Test User 1",
|
name: "Test User 1",
|
||||||
email: "test1@email.com",
|
email: "test1@email.com",
|
||||||
|
emailVerified: true,
|
||||||
};
|
};
|
||||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
||||||
|
|
||||||
|
@ -58,4 +58,16 @@ export class SelfHostedOrganizationSubscriptionView implements View {
|
|||||||
get isExpiredAndOutsideGracePeriod() {
|
get isExpiredAndOutsideGracePeriod() {
|
||||||
return this.hasExpiration && this.expirationWithGracePeriod < new Date();
|
return this.hasExpiration && this.expirationWithGracePeriod < new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the case of a trial, where there is no grace period, the expirationWithGracePeriod and expirationWithoutGracePeriod will
|
||||||
|
* be exactly the same. This can be used to hide the grace period note.
|
||||||
|
*/
|
||||||
|
get isInTrial() {
|
||||||
|
return (
|
||||||
|
this.expirationWithGracePeriod &&
|
||||||
|
this.expirationWithoutGracePeriod &&
|
||||||
|
this.expirationWithGracePeriod.getTime() === this.expirationWithoutGracePeriod.getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,11 +25,10 @@ export type InitOptions = {
|
|||||||
|
|
||||||
export abstract class StateService<T extends Account = Account> {
|
export abstract class StateService<T extends Account = Account> {
|
||||||
accounts$: Observable<{ [userId: string]: T }>;
|
accounts$: Observable<{ [userId: string]: T }>;
|
||||||
activeAccount$: Observable<string>;
|
|
||||||
|
|
||||||
addAccount: (account: T) => Promise<void>;
|
addAccount: (account: T) => Promise<void>;
|
||||||
setActiveUser: (userId: string) => Promise<void>;
|
clearDecryptedData: (userId: UserId) => Promise<void>;
|
||||||
clean: (options?: StorageOptions) => Promise<UserId>;
|
clean: (options?: StorageOptions) => Promise<void>;
|
||||||
init: (initOptions?: InitOptions) => Promise<void>;
|
init: (initOptions?: InitOptions) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -122,8 +121,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getEmail: (options?: StorageOptions) => Promise<string>;
|
getEmail: (options?: StorageOptions) => Promise<string>;
|
||||||
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||||
@ -147,8 +144,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
*/
|
*/
|
||||||
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
|
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
|
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
|
||||||
getLastActive: (options?: StorageOptions) => Promise<number>;
|
|
||||||
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
|
|
||||||
getLastSync: (options?: StorageOptions) => Promise<string>;
|
getLastSync: (options?: StorageOptions) => Promise<string>;
|
||||||
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
|
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
||||||
@ -180,5 +175,4 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
||||||
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
||||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
nextUpActiveUser: () => Promise<UserId>;
|
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,33 @@ import * as path from "path";
|
|||||||
import { Utils } from "./utils";
|
import { Utils } from "./utils";
|
||||||
|
|
||||||
describe("Utils Service", () => {
|
describe("Utils Service", () => {
|
||||||
|
describe("isGuid", () => {
|
||||||
|
it("is false when null", () => {
|
||||||
|
expect(Utils.isGuid(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when undefined", () => {
|
||||||
|
expect(Utils.isGuid(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when empty", () => {
|
||||||
|
expect(Utils.isGuid("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when not a string", () => {
|
||||||
|
expect(Utils.isGuid(123 as any)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when not a guid", () => {
|
||||||
|
expect(Utils.isGuid("not a guid")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when a guid", () => {
|
||||||
|
// we use a limited guid scope in which all zeroes is invalid
|
||||||
|
expect(Utils.isGuid("00000000-0000-1000-8000-000000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getDomain", () => {
|
describe("getDomain", () => {
|
||||||
it("should fail for invalid urls", () => {
|
it("should fail for invalid urls", () => {
|
||||||
expect(Utils.getDomain(null)).toBeNull();
|
expect(Utils.getDomain(null)).toBeNull();
|
||||||
|
@ -9,9 +9,6 @@ export class State<
|
|||||||
> {
|
> {
|
||||||
accounts: { [userId: string]: TAccount } = {};
|
accounts: { [userId: string]: TAccount } = {};
|
||||||
globals: TGlobalState;
|
globals: TGlobalState;
|
||||||
activeUserId: string;
|
|
||||||
authenticatedAccounts: string[] = [];
|
|
||||||
accountActivity: { [userId: string]: number } = {};
|
|
||||||
|
|
||||||
constructor(globals: TGlobalState) {
|
constructor(globals: TGlobalState) {
|
||||||
this.globals = globals;
|
this.globals = globals;
|
||||||
|
@ -31,10 +31,12 @@ describe("EnvironmentService", () => {
|
|||||||
[testUser]: {
|
[testUser]: {
|
||||||
name: "name",
|
name: "name",
|
||||||
email: "email",
|
email: "email",
|
||||||
|
emailVerified: false,
|
||||||
},
|
},
|
||||||
[alternateTestUser]: {
|
[alternateTestUser]: {
|
||||||
name: "name",
|
name: "name",
|
||||||
email: "email",
|
email: "email",
|
||||||
|
emailVerified: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
stateProvider = new FakeStateProvider(accountService);
|
stateProvider = new FakeStateProvider(accountService);
|
||||||
@ -47,6 +49,7 @@ describe("EnvironmentService", () => {
|
|||||||
id: userId,
|
id: userId,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
name: `Test Name ${userId}`,
|
name: `Test Name ${userId}`,
|
||||||
|
emailVerified: false,
|
||||||
});
|
});
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject, firstValueFrom, map } from "rxjs";
|
||||||
import { Jsonify, JsonValue } from "type-fest";
|
import { Jsonify, JsonValue } from "type-fest";
|
||||||
|
|
||||||
import { AccountService } from "../../auth/abstractions/account.service";
|
import { AccountService } from "../../auth/abstractions/account.service";
|
||||||
@ -33,10 +33,7 @@ const keys = {
|
|||||||
state: "state",
|
state: "state",
|
||||||
stateVersion: "stateVersion",
|
stateVersion: "stateVersion",
|
||||||
global: "global",
|
global: "global",
|
||||||
authenticatedAccounts: "authenticatedAccounts",
|
|
||||||
activeUserId: "activeUserId",
|
|
||||||
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
|
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
|
||||||
accountActivity: "accountActivity",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const partialKeys = {
|
const partialKeys = {
|
||||||
@ -58,9 +55,6 @@ export class StateService<
|
|||||||
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
|
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
|
||||||
accounts$ = this.accountsSubject.asObservable();
|
accounts$ = this.accountsSubject.asObservable();
|
||||||
|
|
||||||
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
|
|
||||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
|
||||||
|
|
||||||
private hasBeenInited = false;
|
private hasBeenInited = false;
|
||||||
protected isRecoveredSession = false;
|
protected isRecoveredSession = false;
|
||||||
|
|
||||||
@ -112,36 +106,16 @@ export class StateService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all likely authenticated accounts
|
// Get all likely authenticated accounts
|
||||||
const authenticatedAccounts = (
|
const authenticatedAccounts = await firstValueFrom(
|
||||||
(await this.storageService.get<string[]>(keys.authenticatedAccounts)) ?? []
|
this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))),
|
||||||
).filter((account) => account != null);
|
);
|
||||||
|
|
||||||
await this.updateState(async (state) => {
|
await this.updateState(async (state) => {
|
||||||
for (const i in authenticatedAccounts) {
|
for (const i in authenticatedAccounts) {
|
||||||
state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
|
state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After all individual accounts have been added
|
|
||||||
state.authenticatedAccounts = authenticatedAccounts;
|
|
||||||
|
|
||||||
const storedActiveUser = await this.storageService.get<string>(keys.activeUserId);
|
|
||||||
if (storedActiveUser != null) {
|
|
||||||
state.activeUserId = storedActiveUser;
|
|
||||||
}
|
|
||||||
await this.pushAccounts();
|
await this.pushAccounts();
|
||||||
this.activeAccountSubject.next(state.activeUserId);
|
|
||||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
|
||||||
// account service tracks logged out accounts, but State service does not, so we need to add the active account
|
|
||||||
// if it's not in the accounts list.
|
|
||||||
if (state.activeUserId != null && this.accountsSubject.value[state.activeUserId] == null) {
|
|
||||||
const activeDiskAccount = await this.getAccountFromDisk({ userId: state.activeUserId });
|
|
||||||
await this.accountService.addAccount(state.activeUserId as UserId, {
|
|
||||||
name: activeDiskAccount.profile.name,
|
|
||||||
email: activeDiskAccount.profile.email,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.accountService.switchAccount(state.activeUserId as UserId);
|
|
||||||
// End TODO
|
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
@ -161,61 +135,25 @@ export class StateService<
|
|||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
|
||||||
// The determination of state should be handled by the various services that control those values.
|
|
||||||
await this.accountService.addAccount(userId as UserId, {
|
|
||||||
name: diskAccount.profile.name,
|
|
||||||
email: diskAccount.profile.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAccount(account: TAccount) {
|
async addAccount(account: TAccount) {
|
||||||
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
|
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
|
||||||
await this.updateState(async (state) => {
|
await this.updateState(async (state) => {
|
||||||
state.authenticatedAccounts.push(account.profile.userId);
|
|
||||||
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
|
|
||||||
state.accounts[account.profile.userId] = account;
|
state.accounts[account.profile.userId] = account;
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
await this.scaffoldNewAccountStorage(account);
|
await this.scaffoldNewAccountStorage(account);
|
||||||
await this.setLastActive(new Date().getTime(), { userId: account.profile.userId });
|
|
||||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
|
||||||
await this.accountService.addAccount(account.profile.userId as UserId, {
|
|
||||||
name: account.profile.name,
|
|
||||||
email: account.profile.email,
|
|
||||||
});
|
|
||||||
await this.setActiveUser(account.profile.userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setActiveUser(userId: string): Promise<void> {
|
async clean(options?: StorageOptions): Promise<void> {
|
||||||
await this.clearDecryptedDataForActiveUser();
|
|
||||||
await this.updateState(async (state) => {
|
|
||||||
state.activeUserId = userId;
|
|
||||||
await this.storageService.save(keys.activeUserId, userId);
|
|
||||||
this.activeAccountSubject.next(state.activeUserId);
|
|
||||||
// TODO: temporary update to avoid routing all account status changes through account service for now.
|
|
||||||
await this.accountService.switchAccount(userId as UserId);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.pushAccounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
async clean(options?: StorageOptions): Promise<UserId> {
|
|
||||||
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
|
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
|
||||||
await this.deAuthenticateAccount(options.userId);
|
await this.deAuthenticateAccount(options.userId);
|
||||||
let currentUser = (await this.state())?.activeUserId;
|
|
||||||
if (options.userId === currentUser) {
|
|
||||||
currentUser = await this.dynamicallySetActiveUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.removeAccountFromDisk(options?.userId);
|
await this.removeAccountFromDisk(options?.userId);
|
||||||
await this.removeAccountFromMemory(options?.userId);
|
await this.removeAccountFromMemory(options?.userId);
|
||||||
await this.pushAccounts();
|
await this.pushAccounts();
|
||||||
return currentUser as UserId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -515,24 +453,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmailVerified(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.profile.emailVerified ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEmailVerified(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const account = await this.getAccount(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
account.profile.emailVerified = value;
|
|
||||||
await this.saveAccount(
|
|
||||||
account,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||||
@ -642,35 +562,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLastActive(options?: StorageOptions): Promise<number> {
|
|
||||||
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
|
|
||||||
|
|
||||||
const accountActivity = await this.storageService.get<{ [userId: string]: number }>(
|
|
||||||
keys.accountActivity,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accountActivity == null || Object.keys(accountActivity).length < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountActivity[options.userId];
|
|
||||||
}
|
|
||||||
|
|
||||||
async setLastActive(value: number, options?: StorageOptions): Promise<void> {
|
|
||||||
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
|
|
||||||
if (options.userId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const accountActivity =
|
|
||||||
(await this.storageService.get<{ [userId: string]: number }>(
|
|
||||||
keys.accountActivity,
|
|
||||||
options,
|
|
||||||
)) ?? {};
|
|
||||||
accountActivity[options.userId] = value;
|
|
||||||
await this.storageService.save(keys.accountActivity, accountActivity, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLastSync(options?: StorageOptions): Promise<string> {
|
async getLastSync(options?: StorageOptions): Promise<string> {
|
||||||
return (
|
return (
|
||||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
|
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
|
||||||
@ -910,24 +801,28 @@ export class StateService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
|
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
|
||||||
|
const userId =
|
||||||
|
options.userId ??
|
||||||
|
(await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
));
|
||||||
|
|
||||||
return await this.state().then(async (state) => {
|
return await this.state().then(async (state) => {
|
||||||
if (state.accounts == null) {
|
if (state.accounts == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return state.accounts[await this.getUserIdFromMemory(options)];
|
return state.accounts[userId];
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async getUserIdFromMemory(options: StorageOptions): Promise<string> {
|
|
||||||
return await this.state().then((state) => {
|
|
||||||
return options?.userId != null
|
|
||||||
? state.accounts[options.userId]?.profile?.userId
|
|
||||||
: state.activeUserId;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
|
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
|
||||||
if (options?.userId == null && (await this.state())?.activeUserId == null) {
|
const userId =
|
||||||
|
options.userId ??
|
||||||
|
(await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (userId == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1086,53 +981,76 @@ export class StateService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async defaultInMemoryOptions(): Promise<StorageOptions> {
|
protected async defaultInMemoryOptions(): Promise<StorageOptions> {
|
||||||
|
const userId = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageLocation: StorageLocation.Memory,
|
storageLocation: StorageLocation.Memory,
|
||||||
userId: (await this.state()).activeUserId,
|
userId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async defaultOnDiskOptions(): Promise<StorageOptions> {
|
protected async defaultOnDiskOptions(): Promise<StorageOptions> {
|
||||||
|
const userId = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageLocation: StorageLocation.Disk,
|
storageLocation: StorageLocation.Disk,
|
||||||
htmlStorageLocation: HtmlStorageLocation.Session,
|
htmlStorageLocation: HtmlStorageLocation.Session,
|
||||||
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
|
userId,
|
||||||
useSecureStorage: false,
|
useSecureStorage: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
|
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
|
||||||
|
const userId = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageLocation: StorageLocation.Disk,
|
storageLocation: StorageLocation.Disk,
|
||||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||||
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
|
userId,
|
||||||
useSecureStorage: false,
|
useSecureStorage: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
|
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
|
||||||
|
const userId = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageLocation: StorageLocation.Disk,
|
storageLocation: StorageLocation.Disk,
|
||||||
htmlStorageLocation: HtmlStorageLocation.Memory,
|
htmlStorageLocation: HtmlStorageLocation.Memory,
|
||||||
userId: (await this.state())?.activeUserId ?? (await this.getUserId()),
|
userId,
|
||||||
useSecureStorage: false,
|
useSecureStorage: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
|
protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
|
||||||
|
const userId = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageLocation: StorageLocation.Disk,
|
storageLocation: StorageLocation.Disk,
|
||||||
useSecureStorage: true,
|
useSecureStorage: true,
|
||||||
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
|
userId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getActiveUserIdFromStorage(): Promise<string> {
|
protected async getActiveUserIdFromStorage(): Promise<string> {
|
||||||
return await this.storageService.get<string>(keys.activeUserId);
|
return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
|
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
|
||||||
userId = userId ?? (await this.state())?.activeUserId;
|
userId ??= await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
const storedAccount = await this.getAccount(
|
const storedAccount = await this.getAccount(
|
||||||
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
|
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
|
||||||
);
|
);
|
||||||
@ -1143,7 +1061,10 @@ export class StateService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
|
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
|
||||||
userId = userId ?? (await this.state())?.activeUserId;
|
userId ??= await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
const storedAccount = await this.getAccount(
|
const storedAccount = await this.getAccount(
|
||||||
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
|
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
|
||||||
);
|
);
|
||||||
@ -1154,7 +1075,10 @@ export class StateService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
|
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
|
||||||
userId = userId ?? (await this.state())?.activeUserId;
|
userId ??= await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.setUserKeyAutoUnlock(null, { userId: userId });
|
await this.setUserKeyAutoUnlock(null, { userId: userId });
|
||||||
await this.setUserKeyBiometric(null, { userId: userId });
|
await this.setUserKeyBiometric(null, { userId: userId });
|
||||||
await this.setCryptoMasterKeyAuto(null, { userId: userId });
|
await this.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||||
@ -1163,8 +1087,11 @@ export class StateService<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
|
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
|
||||||
|
userId ??= await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.updateState(async (state) => {
|
await this.updateState(async (state) => {
|
||||||
userId = userId ?? state.activeUserId;
|
|
||||||
delete state.accounts[userId];
|
delete state.accounts[userId];
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
@ -1178,15 +1105,16 @@ export class StateService<
|
|||||||
return Object.assign(this.createAccount(), persistentAccountInformation);
|
return Object.assign(this.createAccount(), persistentAccountInformation);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async clearDecryptedDataForActiveUser(): Promise<void> {
|
async clearDecryptedData(userId: UserId): Promise<void> {
|
||||||
await this.updateState(async (state) => {
|
await this.updateState(async (state) => {
|
||||||
const userId = state?.activeUserId;
|
|
||||||
if (userId != null && state?.accounts[userId]?.data != null) {
|
if (userId != null && state?.accounts[userId]?.data != null) {
|
||||||
state.accounts[userId].data = new AccountData();
|
state.accounts[userId].data = new AccountData();
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.pushAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createAccount(init: Partial<TAccount> = null): TAccount {
|
protected createAccount(init: Partial<TAccount> = null): TAccount {
|
||||||
@ -1201,14 +1129,6 @@ export class StateService<
|
|||||||
// We must have a manual call to clear tokens as we can't leverage state provider to clean
|
// We must have a manual call to clear tokens as we can't leverage state provider to clean
|
||||||
// up our data as we have secure storage in the mix.
|
// up our data as we have secure storage in the mix.
|
||||||
await this.tokenService.clearTokens(userId as UserId);
|
await this.tokenService.clearTokens(userId as UserId);
|
||||||
await this.setLastActive(null, { userId: userId });
|
|
||||||
await this.updateState(async (state) => {
|
|
||||||
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
|
|
||||||
|
|
||||||
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async removeAccountFromDisk(userId: string) {
|
protected async removeAccountFromDisk(userId: string) {
|
||||||
@ -1217,32 +1137,6 @@ export class StateService<
|
|||||||
await this.removeAccountFromSecureStorage(userId);
|
await this.removeAccountFromSecureStorage(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async nextUpActiveUser() {
|
|
||||||
const accounts = (await this.state())?.accounts;
|
|
||||||
if (accounts == null || Object.keys(accounts).length < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newActiveUser;
|
|
||||||
for (const userId in accounts) {
|
|
||||||
if (userId == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (await this.getIsAuthenticated({ userId: userId })) {
|
|
||||||
newActiveUser = userId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
newActiveUser = null;
|
|
||||||
}
|
|
||||||
return newActiveUser as UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async dynamicallySetActiveUser() {
|
|
||||||
const newActiveUser = await this.nextUpActiveUser();
|
|
||||||
await this.setActiveUser(newActiveUser);
|
|
||||||
return newActiveUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async saveSecureStorageKey<T extends JsonValue>(
|
protected async saveSecureStorageKey<T extends JsonValue>(
|
||||||
key: string,
|
key: string,
|
||||||
value: T,
|
value: T,
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { firstValueFrom, timeout } from "rxjs";
|
import { firstValueFrom, map, timeout } from "rxjs";
|
||||||
|
|
||||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
|
import { AccountService } from "../../auth/abstractions/account.service";
|
||||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||||
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
|
||||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||||
|
import { UserId } from "../../types/guid";
|
||||||
import { MessagingService } from "../abstractions/messaging.service";
|
import { MessagingService } from "../abstractions/messaging.service";
|
||||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||||
import { StateService } from "../abstractions/state.service";
|
import { StateService } from "../abstractions/state.service";
|
||||||
@ -27,6 +29,7 @@ export class SystemService implements SystemServiceAbstraction {
|
|||||||
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||||
private biometricStateService: BiometricStateService,
|
private biometricStateService: BiometricStateService,
|
||||||
|
private accountService: AccountService,
|
||||||
private taskSchedulerService?: TaskSchedulerService,
|
private taskSchedulerService?: TaskSchedulerService,
|
||||||
) {
|
) {
|
||||||
void this.taskSchedulerService?.registerTaskHandler(
|
void this.taskSchedulerService?.registerTaskHandler(
|
||||||
@ -36,12 +39,14 @@ export class SystemService implements SystemServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async startProcessReload(authService: AuthService): Promise<void> {
|
async startProcessReload(authService: AuthService): Promise<void> {
|
||||||
const accounts = await firstValueFrom(this.stateService.accounts$);
|
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||||
if (accounts != null) {
|
if (accounts != null) {
|
||||||
const keys = Object.keys(accounts);
|
const keys = Object.keys(accounts);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
for (const userId of keys) {
|
for (const userId of keys) {
|
||||||
if ((await authService.getAuthStatus(userId)) === AuthenticationStatus.Unlocked) {
|
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
|
||||||
|
status = await authService.getAuthStatus(userId);
|
||||||
|
if (status === AuthenticationStatus.Unlocked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,15 +76,24 @@ export class SystemService implements SystemServiceAbstraction {
|
|||||||
clearInterval(this.reloadInterval);
|
clearInterval(this.reloadInterval);
|
||||||
this.reloadInterval = null;
|
this.reloadInterval = null;
|
||||||
|
|
||||||
const currentUser = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500)));
|
const currentUser = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(
|
||||||
|
map((a) => a?.id),
|
||||||
|
timeout(500),
|
||||||
|
),
|
||||||
|
);
|
||||||
// Replace current active user if they will be logged out on reload
|
// Replace current active user if they will be logged out on reload
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
const timeoutAction = await firstValueFrom(
|
const timeoutAction = await firstValueFrom(
|
||||||
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
|
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
|
||||||
);
|
);
|
||||||
if (timeoutAction === VaultTimeoutAction.LogOut) {
|
if (timeoutAction === VaultTimeoutAction.LogOut) {
|
||||||
const nextUser = await this.stateService.nextUpActiveUser();
|
const nextUser = await firstValueFrom(
|
||||||
await this.stateService.setActiveUser(nextUser);
|
this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)),
|
||||||
|
);
|
||||||
|
// Can be removed once we migrate password generation history to state providers
|
||||||
|
await this.stateService.clearDecryptedData(currentUser);
|
||||||
|
await this.accountService.switchAccount(nextUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,71 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { CsprngArray } from "../../types/csprng";
|
||||||
|
import { UserId } from "../../types/guid";
|
||||||
|
import { UserKey } from "../../types/key";
|
||||||
|
import { KeySuffixOptions } from "../enums";
|
||||||
|
import { Utils } from "../misc/utils";
|
||||||
|
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||||
|
|
||||||
|
import { CryptoService } from "./crypto.service";
|
||||||
|
import { UserAutoUnlockKeyService } from "./user-auto-unlock-key.service";
|
||||||
|
|
||||||
|
describe("UserAutoUnlockKeyService", () => {
|
||||||
|
let userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||||
|
|
||||||
|
const mockUserId = Utils.newGuid() as UserId;
|
||||||
|
|
||||||
|
const cryptoService = mock<CryptoService>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userAutoUnlockKeyService = new UserAutoUnlockKeyService(cryptoService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setUserKeyInMemoryIfAutoUserKeySet", () => {
|
||||||
|
it("does nothing if the userId is null", async () => {
|
||||||
|
// Act
|
||||||
|
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(cryptoService.getUserKeyFromStorage).not.toHaveBeenCalled();
|
||||||
|
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if the autoUserKey is null", async () => {
|
||||||
|
// Arrange
|
||||||
|
const userId = mockUserId;
|
||||||
|
|
||||||
|
cryptoService.getUserKeyFromStorage.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
||||||
|
KeySuffixOptions.Auto,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the user key in memory if the autoUserKey is not null", async () => {
|
||||||
|
// Arrange
|
||||||
|
const userId = mockUserId;
|
||||||
|
|
||||||
|
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||||
|
const mockAutoUserKey: UserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||||
|
|
||||||
|
cryptoService.getUserKeyFromStorage.mockResolvedValue(mockAutoUserKey);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await (userAutoUnlockKeyService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
||||||
|
KeySuffixOptions.Auto,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockAutoUserKey, userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,36 @@
|
|||||||
|
import { UserId } from "../../types/guid";
|
||||||
|
import { CryptoService } from "../abstractions/crypto.service";
|
||||||
|
import { KeySuffixOptions } from "../enums";
|
||||||
|
|
||||||
|
// TODO: this is a half measure improvement which allows us to reduce some side effects today (cryptoService.getUserKey setting user key in memory if auto key exists)
|
||||||
|
// but ideally, in the future, we would be able to put this logic into the cryptoService
|
||||||
|
// after the vault timeout settings service is transitioned to state provider so that
|
||||||
|
// the getUserKey logic can simply go to the correct location based on the vault timeout settings
|
||||||
|
// similar to the TokenService (it would either go to secure storage for the auto user key or memory for the user key)
|
||||||
|
|
||||||
|
export class UserAutoUnlockKeyService {
|
||||||
|
constructor(private cryptoService: CryptoService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presence of the user key in memory dictates whether the user's vault is locked or unlocked.
|
||||||
|
* However, for users that have the auto unlock user key set, we need to set the user key in memory
|
||||||
|
* on application bootstrap and on active account changes so that the user's vault loads unlocked.
|
||||||
|
* @param userId - The user id to check for an auto user key.
|
||||||
|
*/
|
||||||
|
async setUserKeyInMemoryIfAutoUserKeySet(userId: UserId): Promise<void> {
|
||||||
|
if (userId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoUserKey = await this.cryptoService.getUserKeyFromStorage(
|
||||||
|
KeySuffixOptions.Auto,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (autoUserKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cryptoService.setUserKey(autoUserKey, userId);
|
||||||
|
}
|
||||||
|
}
|
@ -1,162 +0,0 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
|
||||||
|
|
||||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
|
|
||||||
import { CsprngArray } from "../../types/csprng";
|
|
||||||
import { UserId } from "../../types/guid";
|
|
||||||
import { UserKey } from "../../types/key";
|
|
||||||
import { LogService } from "../abstractions/log.service";
|
|
||||||
import { KeySuffixOptions } from "../enums";
|
|
||||||
import { Utils } from "../misc/utils";
|
|
||||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
|
||||||
|
|
||||||
import { CryptoService } from "./crypto.service";
|
|
||||||
import { UserKeyInitService } from "./user-key-init.service";
|
|
||||||
|
|
||||||
describe("UserKeyInitService", () => {
|
|
||||||
let userKeyInitService: UserKeyInitService;
|
|
||||||
|
|
||||||
const mockUserId = Utils.newGuid() as UserId;
|
|
||||||
|
|
||||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
|
||||||
|
|
||||||
const cryptoService = mock<CryptoService>();
|
|
||||||
const logService = mock<LogService>();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
userKeyInitService = new UserKeyInitService(accountService, cryptoService, logService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("listenForActiveUserChangesToSetUserKey()", () => {
|
|
||||||
it("calls setUserKeyInMemoryIfAutoUserKeySet if there is an active user", () => {
|
|
||||||
// Arrange
|
|
||||||
accountService.activeAccountSubject.next({
|
|
||||||
id: mockUserId,
|
|
||||||
name: "name",
|
|
||||||
email: "email",
|
|
||||||
});
|
|
||||||
|
|
||||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet = jest.fn();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
|
|
||||||
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
|
|
||||||
expect(subscription).not.toBeFalsy();
|
|
||||||
|
|
||||||
expect((userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet).toHaveBeenCalledWith(
|
|
||||||
mockUserId,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls setUserKeyInMemoryIfAutoUserKeySet if there is an active user and tracks subsequent emissions", () => {
|
|
||||||
// Arrange
|
|
||||||
accountService.activeAccountSubject.next({
|
|
||||||
id: mockUserId,
|
|
||||||
name: "name",
|
|
||||||
email: "email",
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockUser2Id = Utils.newGuid() as UserId;
|
|
||||||
|
|
||||||
jest
|
|
||||||
.spyOn(userKeyInitService as any, "setUserKeyInMemoryIfAutoUserKeySet")
|
|
||||||
.mockImplementation(() => Promise.resolve());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
|
|
||||||
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
|
||||||
|
|
||||||
accountService.activeAccountSubject.next({
|
|
||||||
id: mockUser2Id,
|
|
||||||
name: "name",
|
|
||||||
email: "email",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
|
|
||||||
expect(subscription).not.toBeFalsy();
|
|
||||||
|
|
||||||
expect((userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet).toHaveBeenCalledTimes(
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
|
|
||||||
).toHaveBeenNthCalledWith(1, mockUserId);
|
|
||||||
expect(
|
|
||||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
|
|
||||||
).toHaveBeenNthCalledWith(2, mockUser2Id);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call setUserKeyInMemoryIfAutoUserKeySet if there is not an active user", () => {
|
|
||||||
// Arrange
|
|
||||||
accountService.activeAccountSubject.next(null);
|
|
||||||
|
|
||||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet = jest.fn();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
|
|
||||||
const subscription = userKeyInitService.listenForActiveUserChangesToSetUserKey();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
|
|
||||||
expect(subscription).not.toBeFalsy();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet,
|
|
||||||
).not.toHaveBeenCalledWith(mockUserId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setUserKeyInMemoryIfAutoUserKeySet", () => {
|
|
||||||
it("does nothing if the userId is null", async () => {
|
|
||||||
// Act
|
|
||||||
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(null);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(cryptoService.getUserKeyFromStorage).not.toHaveBeenCalled();
|
|
||||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing if the autoUserKey is null", async () => {
|
|
||||||
// Arrange
|
|
||||||
const userId = mockUserId;
|
|
||||||
|
|
||||||
cryptoService.getUserKeyFromStorage.mockResolvedValue(null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
|
||||||
KeySuffixOptions.Auto,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets the user key in memory if the autoUserKey is not null", async () => {
|
|
||||||
// Arrange
|
|
||||||
const userId = mockUserId;
|
|
||||||
|
|
||||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
|
||||||
const mockAutoUserKey: UserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
|
||||||
|
|
||||||
cryptoService.getUserKeyFromStorage.mockResolvedValue(mockAutoUserKey);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await (userKeyInitService as any).setUserKeyInMemoryIfAutoUserKeySet(userId);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(cryptoService.getUserKeyFromStorage).toHaveBeenCalledWith(
|
|
||||||
KeySuffixOptions.Auto,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockAutoUserKey, userId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,57 +0,0 @@
|
|||||||
import { EMPTY, Subscription, catchError, filter, from, switchMap } from "rxjs";
|
|
||||||
|
|
||||||
import { AccountService } from "../../auth/abstractions/account.service";
|
|
||||||
import { UserId } from "../../types/guid";
|
|
||||||
import { CryptoService } from "../abstractions/crypto.service";
|
|
||||||
import { LogService } from "../abstractions/log.service";
|
|
||||||
import { KeySuffixOptions } from "../enums";
|
|
||||||
|
|
||||||
// TODO: this is a half measure improvement which allows us to reduce some side effects today (cryptoService.getUserKey setting user key in memory if auto key exists)
|
|
||||||
// but ideally, in the future, we would be able to put this logic into the cryptoService
|
|
||||||
// after the vault timeout settings service is transitioned to state provider so that
|
|
||||||
// the getUserKey logic can simply go to the correct location based on the vault timeout settings
|
|
||||||
// similar to the TokenService (it would either go to secure storage for the auto user key or memory for the user key)
|
|
||||||
|
|
||||||
export class UserKeyInitService {
|
|
||||||
constructor(
|
|
||||||
private accountService: AccountService,
|
|
||||||
private cryptoService: CryptoService,
|
|
||||||
private logService: LogService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// Note: must listen for changes to support account switching
|
|
||||||
listenForActiveUserChangesToSetUserKey(): Subscription {
|
|
||||||
return this.accountService.activeAccount$
|
|
||||||
.pipe(
|
|
||||||
filter((activeAccount) => activeAccount != null),
|
|
||||||
switchMap((activeAccount) =>
|
|
||||||
from(this.setUserKeyInMemoryIfAutoUserKeySet(activeAccount?.id)).pipe(
|
|
||||||
catchError((err: unknown) => {
|
|
||||||
this.logService.warning(
|
|
||||||
`setUserKeyInMemoryIfAutoUserKeySet failed with error: ${err}`,
|
|
||||||
);
|
|
||||||
// Returning EMPTY to protect observable chain from cancellation in case of error
|
|
||||||
return EMPTY;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setUserKeyInMemoryIfAutoUserKeySet(userId: UserId) {
|
|
||||||
if (userId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoUserKey = await this.cryptoService.getUserKeyFromStorage(
|
|
||||||
KeySuffixOptions.Auto,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
if (autoUserKey == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cryptoService.setUserKey(autoUserKey, userId);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,6 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
|
import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
|
||||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
import { SingleUserStateProvider } from "../user-state.provider";
|
import { SingleUserStateProvider } from "../user-state.provider";
|
||||||
|
|
||||||
@ -14,7 +13,7 @@ describe("DefaultActiveUserStateProvider", () => {
|
|||||||
id: userId,
|
id: userId,
|
||||||
name: "name",
|
name: "name",
|
||||||
email: "email",
|
email: "email",
|
||||||
status: AuthenticationStatus.Locked,
|
emailVerified: false,
|
||||||
};
|
};
|
||||||
const accountService = mockAccountServiceWith(userId, accountInfo);
|
const accountService = mockAccountServiceWith(userId, accountInfo);
|
||||||
let sut: DefaultActiveUserStateProvider;
|
let sut: DefaultActiveUserStateProvider;
|
||||||
|
@ -82,6 +82,7 @@ describe("DefaultActiveUserState", () => {
|
|||||||
activeAccountSubject.next({
|
activeAccountSubject.next({
|
||||||
id: userId,
|
id: userId,
|
||||||
email: `test${id}@example.com`,
|
email: `test${id}@example.com`,
|
||||||
|
emailVerified: false,
|
||||||
name: `Test User ${id}`,
|
name: `Test User ${id}`,
|
||||||
});
|
});
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
@ -69,7 +69,12 @@ describe("DefaultStateProvider", () => {
|
|||||||
userId?: UserId,
|
userId?: UserId,
|
||||||
) => Observable<string>,
|
) => Observable<string>,
|
||||||
) => {
|
) => {
|
||||||
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut };
|
const accountInfo = {
|
||||||
|
email: "email",
|
||||||
|
emailVerified: false,
|
||||||
|
name: "name",
|
||||||
|
status: AuthenticationStatus.LoggedOut,
|
||||||
|
};
|
||||||
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
|
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
|
||||||
deserializer: (s) => s,
|
deserializer: (s) => s,
|
||||||
});
|
});
|
||||||
@ -114,7 +119,12 @@ describe("DefaultStateProvider", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("getUserState$", () => {
|
describe("getUserState$", () => {
|
||||||
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut };
|
const accountInfo = {
|
||||||
|
email: "email",
|
||||||
|
emailVerified: false,
|
||||||
|
name: "name",
|
||||||
|
status: AuthenticationStatus.LoggedOut,
|
||||||
|
};
|
||||||
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
|
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
|
||||||
deserializer: (s) => s,
|
deserializer: (s) => s,
|
||||||
});
|
});
|
||||||
|
@ -38,6 +38,7 @@ export const BILLING_DISK = new StateDefinition("billing", "disk");
|
|||||||
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
|
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
|
||||||
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
||||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||||
|
export const ACCOUNT_DISK = new StateDefinition("account", "disk");
|
||||||
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
|
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
|
||||||
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
|
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
|
||||||
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
|
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { MockProxy, any, mock } from "jest-mock-extended";
|
import { MockProxy, any, mock } from "jest-mock-extended";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
|
|
||||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||||
import { SearchService } from "../../abstractions/search.service";
|
import { SearchService } from "../../abstractions/search.service";
|
||||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
|
import { AccountInfo } from "../../auth/abstractions/account.service";
|
||||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||||
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
|
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
|
||||||
@ -13,7 +14,6 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
|
|||||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||||
import { StateService } from "../../platform/abstractions/state.service";
|
import { StateService } from "../../platform/abstractions/state.service";
|
||||||
import { Utils } from "../../platform/misc/utils";
|
import { Utils } from "../../platform/misc/utils";
|
||||||
import { Account } from "../../platform/models/domain/account";
|
|
||||||
import { StateEventRunnerService } from "../../platform/state";
|
import { StateEventRunnerService } from "../../platform/state";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { CipherService } from "../../vault/abstractions/cipher.service";
|
import { CipherService } from "../../vault/abstractions/cipher.service";
|
||||||
@ -39,7 +39,6 @@ describe("VaultTimeoutService", () => {
|
|||||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||||
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
|
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
|
||||||
|
|
||||||
let accountsSubject: BehaviorSubject<Record<string, Account>>;
|
|
||||||
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
|
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
|
||||||
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
|
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
|
||||||
|
|
||||||
@ -65,10 +64,6 @@ describe("VaultTimeoutService", () => {
|
|||||||
lockedCallback = jest.fn();
|
lockedCallback = jest.fn();
|
||||||
loggedOutCallback = jest.fn();
|
loggedOutCallback = jest.fn();
|
||||||
|
|
||||||
accountsSubject = new BehaviorSubject(null);
|
|
||||||
|
|
||||||
stateService.accounts$ = accountsSubject;
|
|
||||||
|
|
||||||
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
|
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
|
||||||
|
|
||||||
vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject);
|
vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject);
|
||||||
@ -127,21 +122,39 @@ describe("VaultTimeoutService", () => {
|
|||||||
return Promise.resolve(accounts[userId]?.vaultTimeout);
|
return Promise.resolve(accounts[userId]?.vaultTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
stateService.getLastActive.mockImplementation((options) => {
|
|
||||||
return Promise.resolve(accounts[options.userId]?.lastActive);
|
|
||||||
});
|
|
||||||
|
|
||||||
stateService.getUserId.mockResolvedValue(globalSetups?.userId);
|
stateService.getUserId.mockResolvedValue(globalSetups?.userId);
|
||||||
|
|
||||||
stateService.activeAccount$ = new BehaviorSubject<string>(globalSetups?.userId);
|
// Set desired user active and known users on accounts service : note the only thing that matters here is that the ID are set
|
||||||
|
|
||||||
if (globalSetups?.userId) {
|
if (globalSetups?.userId) {
|
||||||
accountService.activeAccountSubject.next({
|
accountService.activeAccountSubject.next({
|
||||||
id: globalSetups.userId as UserId,
|
id: globalSetups.userId as UserId,
|
||||||
email: null,
|
email: null,
|
||||||
|
emailVerified: false,
|
||||||
name: null,
|
name: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
accountService.accounts$ = of(
|
||||||
|
Object.entries(accounts).reduce(
|
||||||
|
(agg, [id]) => {
|
||||||
|
agg[id] = {
|
||||||
|
email: "",
|
||||||
|
emailVerified: true,
|
||||||
|
name: "",
|
||||||
|
};
|
||||||
|
return agg;
|
||||||
|
},
|
||||||
|
{} as Record<string, AccountInfo>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
accountService.accountActivity$ = of(
|
||||||
|
Object.entries(accounts).reduce(
|
||||||
|
(agg, [id, info]) => {
|
||||||
|
agg[id] = info.lastActive ? new Date(info.lastActive) : null;
|
||||||
|
return agg;
|
||||||
|
},
|
||||||
|
{} as Record<string, Date>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false);
|
platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false);
|
||||||
|
|
||||||
@ -158,16 +171,6 @@ describe("VaultTimeoutService", () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountsSubjectValue: Record<string, Account> = Object.keys(accounts).reduce(
|
|
||||||
(agg, key) => {
|
|
||||||
const newPartial: Record<string, unknown> = {};
|
|
||||||
newPartial[key] = null; // No values actually matter on this other than the key
|
|
||||||
return Object.assign(agg, newPartial);
|
|
||||||
},
|
|
||||||
{} as Record<string, Account>,
|
|
||||||
);
|
|
||||||
accountsSubject.next(accountsSubjectValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectUserToHaveLocked = (userId: string) => {
|
const expectUserToHaveLocked = (userId: string) => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { firstValueFrom, timeout } from "rxjs";
|
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { SearchService } from "../../abstractions/search.service";
|
import { SearchService } from "../../abstractions/search.service";
|
||||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
@ -64,14 +64,25 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||||||
// Get whether or not the view is open a single time so it can be compared for each user
|
// Get whether or not the view is open a single time so it can be compared for each user
|
||||||
const isViewOpen = await this.platformUtilsService.isViewOpen();
|
const isViewOpen = await this.platformUtilsService.isViewOpen();
|
||||||
|
|
||||||
const activeUserId = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500)));
|
await firstValueFrom(
|
||||||
|
combineLatest([
|
||||||
const accounts = await firstValueFrom(this.stateService.accounts$);
|
this.accountService.activeAccount$,
|
||||||
for (const userId in accounts) {
|
this.accountService.accountActivity$,
|
||||||
if (userId != null && (await this.shouldLock(userId, activeUserId, isViewOpen))) {
|
]).pipe(
|
||||||
await this.executeTimeoutAction(userId);
|
switchMap(async ([activeAccount, accountActivity]) => {
|
||||||
}
|
const activeUserId = activeAccount?.id;
|
||||||
}
|
for (const userIdString in accountActivity) {
|
||||||
|
const userId = userIdString as UserId;
|
||||||
|
if (
|
||||||
|
userId != null &&
|
||||||
|
(await this.shouldLock(userId, accountActivity[userId], activeUserId, isViewOpen))
|
||||||
|
) {
|
||||||
|
await this.executeTimeoutAction(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async lock(userId?: string): Promise<void> {
|
async lock(userId?: string): Promise<void> {
|
||||||
@ -123,6 +134,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||||||
|
|
||||||
private async shouldLock(
|
private async shouldLock(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
lastActive: Date,
|
||||||
activeUserId: string,
|
activeUserId: string,
|
||||||
isViewOpen: boolean,
|
isViewOpen: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@ -146,13 +158,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastActive = await this.stateService.getLastActive({ userId: userId });
|
|
||||||
if (lastActive == null) {
|
if (lastActive == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vaultTimeoutSeconds = vaultTimeout * 60;
|
const vaultTimeoutSeconds = vaultTimeout * 60;
|
||||||
const diffSeconds = (new Date().getTime() - lastActive) / 1000;
|
const diffSeconds = (new Date().getTime() - lastActive.getTime()) / 1000;
|
||||||
return diffSeconds >= vaultTimeoutSeconds;
|
return diffSeconds >= vaultTimeoutSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,13 +57,14 @@ import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-st
|
|||||||
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
|
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
|
||||||
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
|
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
|
||||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||||
|
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
|
||||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||||
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
||||||
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
|
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
|
||||||
import { MinVersionMigrator } from "./migrations/min-version";
|
import { MinVersionMigrator } from "./migrations/min-version";
|
||||||
|
|
||||||
export const MIN_VERSION = 3;
|
export const MIN_VERSION = 3;
|
||||||
export const CURRENT_VERSION = 59;
|
export const CURRENT_VERSION = 60;
|
||||||
export type MinVersion = typeof MIN_VERSION;
|
export type MinVersion = typeof MIN_VERSION;
|
||||||
|
|
||||||
export function createMigrationBuilder() {
|
export function createMigrationBuilder() {
|
||||||
@ -124,7 +125,8 @@ export function createMigrationBuilder() {
|
|||||||
.with(AuthRequestMigrator, 55, 56)
|
.with(AuthRequestMigrator, 55, 56)
|
||||||
.with(CipherServiceMigrator, 56, 57)
|
.with(CipherServiceMigrator, 56, 57)
|
||||||
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
|
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
|
||||||
.with(KdfConfigMigrator, 58, CURRENT_VERSION);
|
.with(KdfConfigMigrator, 58, 59)
|
||||||
|
.with(KnownAccountsMigrator, 59, CURRENT_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function currentVersion(
|
export async function currentVersion(
|
||||||
|
@ -27,6 +27,14 @@ const exampleJSON = {
|
|||||||
},
|
},
|
||||||
global_serviceName_key: "global_serviceName_key",
|
global_serviceName_key: "global_serviceName_key",
|
||||||
user_userId_serviceName_key: "user_userId_serviceName_key",
|
user_userId_serviceName_key: "user_userId_serviceName_key",
|
||||||
|
global_account_accounts: {
|
||||||
|
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||||
|
otherStuff: "otherStuff3",
|
||||||
|
},
|
||||||
|
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||||
|
otherStuff: "otherStuff4",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||||
@ -81,6 +89,41 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
|
|||||||
const accounts = await sut.getAccounts();
|
const accounts = await sut.getAccounts();
|
||||||
expect(accounts).toEqual([]);
|
expect(accounts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles global scoped known accounts for version 60 and after", async () => {
|
||||||
|
sut.currentVersion = 60;
|
||||||
|
const accounts = await sut.getAccounts();
|
||||||
|
expect(accounts).toEqual([
|
||||||
|
// Note, still gets values stored in state service objects, just grabs user ids from global
|
||||||
|
{
|
||||||
|
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||||
|
account: { otherStuff: "otherStuff1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||||
|
account: { otherStuff: "otherStuff2" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getKnownUserIds", () => {
|
||||||
|
it("returns all user ids", async () => {
|
||||||
|
const userIds = await sut.getKnownUserIds();
|
||||||
|
expect(userIds).toEqual([
|
||||||
|
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||||
|
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all user ids when version is 60 or greater", async () => {
|
||||||
|
sut.currentVersion = 60;
|
||||||
|
const userIds = await sut.getKnownUserIds();
|
||||||
|
expect(userIds).toEqual([
|
||||||
|
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||||
|
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getFromGlobal", () => {
|
describe("getFromGlobal", () => {
|
||||||
|
@ -162,7 +162,7 @@ export class MigrationHelper {
|
|||||||
async getAccounts<ExpectedAccountType>(): Promise<
|
async getAccounts<ExpectedAccountType>(): Promise<
|
||||||
{ userId: string; account: ExpectedAccountType }[]
|
{ userId: string; account: ExpectedAccountType }[]
|
||||||
> {
|
> {
|
||||||
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? [];
|
const userIds = await this.getKnownUserIds();
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
userIds.map(async (userId) => ({
|
userIds.map(async (userId) => ({
|
||||||
userId,
|
userId,
|
||||||
@ -171,6 +171,17 @@ export class MigrationHelper {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to read known users ids.
|
||||||
|
*/
|
||||||
|
async getKnownUserIds(): Promise<string[]> {
|
||||||
|
if (this.currentVersion < 61) {
|
||||||
|
return knownAccountUserIdsBuilderPre61(this.storageService);
|
||||||
|
} else {
|
||||||
|
return knownAccountUserIdsBuilder(this.storageService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a user storage key appropriate for the current version.
|
* Builds a user storage key appropriate for the current version.
|
||||||
*
|
*
|
||||||
@ -233,3 +244,18 @@ function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
|
|||||||
function globalKeyBuilderPre9(): string {
|
function globalKeyBuilderPre9(): string {
|
||||||
throw Error("No key builder should be used for versions prior to 9.");
|
throw Error("No key builder should be used for versions prior to 9.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function knownAccountUserIdsBuilderPre61(
|
||||||
|
storageService: AbstractStorageService,
|
||||||
|
): Promise<string[]> {
|
||||||
|
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function knownAccountUserIdsBuilder(
|
||||||
|
storageService: AbstractStorageService,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const accounts = await storageService.get<Record<string, unknown>>(
|
||||||
|
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
|
||||||
|
);
|
||||||
|
return Object.keys(accounts ?? {});
|
||||||
|
}
|
||||||
|
@ -0,0 +1,145 @@
|
|||||||
|
import { MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { MigrationHelper } from "../migration-helper";
|
||||||
|
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACCOUNT_ACCOUNTS,
|
||||||
|
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||||
|
ACCOUNT_ACTIVITY,
|
||||||
|
KnownAccountsMigrator,
|
||||||
|
} from "./60-known-accounts";
|
||||||
|
|
||||||
|
const migrateJson = () => {
|
||||||
|
return {
|
||||||
|
authenticatedAccounts: ["user1", "user2"],
|
||||||
|
activeUserId: "user1",
|
||||||
|
user1: {
|
||||||
|
profile: {
|
||||||
|
email: "user1",
|
||||||
|
name: "User 1",
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
profile: {
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accountActivity: {
|
||||||
|
user1: 1609459200000, // 2021-01-01
|
||||||
|
user2: 1609545600000, // 2021-01-02
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rollbackJson = () => {
|
||||||
|
return {
|
||||||
|
user1: {
|
||||||
|
profile: {
|
||||||
|
email: "user1",
|
||||||
|
name: "User 1",
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
profile: {
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global_account_accounts: {
|
||||||
|
user1: {
|
||||||
|
profile: {
|
||||||
|
email: "user1",
|
||||||
|
name: "User 1",
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
profile: {
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global_account_activeAccountId: "user1",
|
||||||
|
global_account_activity: {
|
||||||
|
user1: "2021-01-01T00:00:00.000Z",
|
||||||
|
user2: "2021-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ReplicateKnownAccounts", () => {
|
||||||
|
let helper: MockProxy<MigrationHelper>;
|
||||||
|
let sut: KnownAccountsMigrator;
|
||||||
|
|
||||||
|
describe("migrate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
helper = mockMigrationHelper(migrateJson(), 59);
|
||||||
|
sut = new KnownAccountsMigrator(59, 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates accounts", async () => {
|
||||||
|
await sut.migrate(helper);
|
||||||
|
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS, {
|
||||||
|
user1: {
|
||||||
|
email: "user1",
|
||||||
|
name: "User 1",
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
email: "",
|
||||||
|
emailVerified: false,
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(helper.remove).toHaveBeenCalledWith("authenticatedAccounts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates active account it", async () => {
|
||||||
|
await sut.migrate(helper);
|
||||||
|
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID, "user1");
|
||||||
|
expect(helper.remove).toHaveBeenCalledWith("activeUserId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates account activity", async () => {
|
||||||
|
await sut.migrate(helper);
|
||||||
|
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY, {
|
||||||
|
user1: '"2021-01-01T00:00:00.000Z"',
|
||||||
|
user2: '"2021-01-02T00:00:00.000Z"',
|
||||||
|
});
|
||||||
|
expect(helper.remove).toHaveBeenCalledWith("accountActivity");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rollback", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
helper = mockMigrationHelper(rollbackJson(), 60);
|
||||||
|
sut = new KnownAccountsMigrator(59, 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back authenticated accounts", async () => {
|
||||||
|
await sut.rollback(helper);
|
||||||
|
expect(helper.set).toHaveBeenCalledWith("authenticatedAccounts", ["user1", "user2"]);
|
||||||
|
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back active account id", async () => {
|
||||||
|
await sut.rollback(helper);
|
||||||
|
expect(helper.set).toHaveBeenCalledWith("activeUserId", "user1");
|
||||||
|
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back account activity", async () => {
|
||||||
|
await sut.rollback(helper);
|
||||||
|
expect(helper.set).toHaveBeenCalledWith("accountActivity", {
|
||||||
|
user1: 1609459200000,
|
||||||
|
user2: 1609545600000,
|
||||||
|
});
|
||||||
|
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
111
libs/common/src/state-migrations/migrations/60-known-accounts.ts
Normal file
111
libs/common/src/state-migrations/migrations/60-known-accounts.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||||
|
import { Migrator } from "../migrator";
|
||||||
|
|
||||||
|
export const ACCOUNT_ACCOUNTS: KeyDefinitionLike = {
|
||||||
|
stateDefinition: {
|
||||||
|
name: "account",
|
||||||
|
},
|
||||||
|
key: "accounts",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACCOUNT_ACTIVE_ACCOUNT_ID: KeyDefinitionLike = {
|
||||||
|
stateDefinition: {
|
||||||
|
name: "account",
|
||||||
|
},
|
||||||
|
key: "activeAccountId",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACCOUNT_ACTIVITY: KeyDefinitionLike = {
|
||||||
|
stateDefinition: {
|
||||||
|
name: "account",
|
||||||
|
},
|
||||||
|
key: "activity",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedAccountType = {
|
||||||
|
profile?: {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class KnownAccountsMigrator extends Migrator<59, 60> {
|
||||||
|
async migrate(helper: MigrationHelper): Promise<void> {
|
||||||
|
await this.migrateAuthenticatedAccounts(helper);
|
||||||
|
await this.migrateActiveAccountId(helper);
|
||||||
|
await this.migrateAccountActivity(helper);
|
||||||
|
}
|
||||||
|
async rollback(helper: MigrationHelper): Promise<void> {
|
||||||
|
// authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back
|
||||||
|
const accounts = (await helper.getFromGlobal<Record<string, unknown>>(ACCOUNT_ACCOUNTS)) ?? {};
|
||||||
|
await helper.set("authenticatedAccounts", Object.keys(accounts));
|
||||||
|
await helper.removeFromGlobal(ACCOUNT_ACCOUNTS);
|
||||||
|
|
||||||
|
// Active Account Id
|
||||||
|
const activeAccountId = await helper.getFromGlobal<string>(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||||
|
if (activeAccountId) {
|
||||||
|
await helper.set("activeUserId", activeAccountId);
|
||||||
|
}
|
||||||
|
await helper.removeFromGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||||
|
|
||||||
|
// Account Activity
|
||||||
|
const accountActivity = await helper.getFromGlobal<Record<string, string>>(ACCOUNT_ACTIVITY);
|
||||||
|
if (accountActivity) {
|
||||||
|
const toStore = Object.entries(accountActivity).reduce(
|
||||||
|
(agg, [userId, dateString]) => {
|
||||||
|
agg[userId] = new Date(dateString).getTime();
|
||||||
|
return agg;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
await helper.set("accountActivity", toStore);
|
||||||
|
}
|
||||||
|
await helper.removeFromGlobal(ACCOUNT_ACTIVITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateAuthenticatedAccounts(helper: MigrationHelper) {
|
||||||
|
const authenticatedAccounts = (await helper.get<string[]>("authenticatedAccounts")) ?? [];
|
||||||
|
const accounts = await Promise.all(
|
||||||
|
authenticatedAccounts.map(async (userId) => {
|
||||||
|
const account = await helper.get<ExpectedAccountType>(userId);
|
||||||
|
return { userId, account };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const accountsToStore = accounts.reduce(
|
||||||
|
(agg, { userId, account }) => {
|
||||||
|
if (account?.profile) {
|
||||||
|
agg[userId] = {
|
||||||
|
email: account.profile.email ?? "",
|
||||||
|
emailVerified: account.profile.emailVerified ?? false,
|
||||||
|
name: account.profile.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return agg;
|
||||||
|
},
|
||||||
|
{} as Record<string, { email: string; emailVerified: boolean; name: string | undefined }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await helper.setToGlobal(ACCOUNT_ACCOUNTS, accountsToStore);
|
||||||
|
await helper.remove("authenticatedAccounts");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateAccountActivity(helper: MigrationHelper) {
|
||||||
|
const stored = await helper.get<Record<string, Date>>("accountActivity");
|
||||||
|
const accountActivity = Object.entries(stored ?? {}).reduce(
|
||||||
|
(agg, [userId, dateMs]) => {
|
||||||
|
agg[userId] = JSON.stringify(new Date(dateMs));
|
||||||
|
return agg;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
await helper.setToGlobal(ACCOUNT_ACTIVITY, accountActivity);
|
||||||
|
await helper.remove("accountActivity");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateActiveAccountId(helper: MigrationHelper) {
|
||||||
|
const activeAccountId = await helper.get<string>("activeUserId");
|
||||||
|
await helper.setToGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID, activeAccountId);
|
||||||
|
await helper.remove("activeUserId");
|
||||||
|
}
|
||||||
|
}
|
@ -62,6 +62,7 @@ describe("SendService", () => {
|
|||||||
accountService.activeAccountSubject.next({
|
accountService.activeAccountSubject.next({
|
||||||
id: mockUserId,
|
id: mockUserId,
|
||||||
email: "email",
|
email: "email",
|
||||||
|
emailVerified: false,
|
||||||
name: "name",
|
name: "name",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -326,7 +326,10 @@ export class SyncService implements SyncServiceAbstraction {
|
|||||||
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
|
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
|
||||||
await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor);
|
await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor);
|
||||||
await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId);
|
await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId);
|
||||||
await this.stateService.setEmailVerified(response.emailVerified);
|
await this.accountService.setAccountEmailVerified(
|
||||||
|
response.id as UserId,
|
||||||
|
response.emailVerified,
|
||||||
|
);
|
||||||
|
|
||||||
await this.billingAccountProfileStateService.setHasPremium(
|
await this.billingAccountProfileStateService.setHasPremium(
|
||||||
response.premiumPersonally,
|
response.premiumPersonally,
|
||||||
|
33
libs/components/src/a11y/a11y-cell.directive.ts
Normal file
33
libs/components/src/a11y/a11y-cell.directive.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core";
|
||||||
|
|
||||||
|
import { FocusableElement } from "../shared/focusable-element";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: "bitA11yCell",
|
||||||
|
standalone: true,
|
||||||
|
providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }],
|
||||||
|
})
|
||||||
|
export class A11yCellDirective implements FocusableElement {
|
||||||
|
@HostBinding("attr.role")
|
||||||
|
role: "gridcell" | null;
|
||||||
|
|
||||||
|
@ContentChild(FocusableElement)
|
||||||
|
private focusableChild: FocusableElement;
|
||||||
|
|
||||||
|
getFocusTarget() {
|
||||||
|
let focusTarget: HTMLElement;
|
||||||
|
if (this.focusableChild) {
|
||||||
|
focusTarget = this.focusableChild.getFocusTarget();
|
||||||
|
} else {
|
||||||
|
focusTarget = this.elementRef.nativeElement.querySelector("button, a");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!focusTarget) {
|
||||||
|
return this.elementRef.nativeElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return focusTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private elementRef: ElementRef<HTMLElement>) {}
|
||||||
|
}
|
145
libs/components/src/a11y/a11y-grid.directive.ts
Normal file
145
libs/components/src/a11y/a11y-grid.directive.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
ContentChildren,
|
||||||
|
Directive,
|
||||||
|
HostBinding,
|
||||||
|
HostListener,
|
||||||
|
Input,
|
||||||
|
QueryList,
|
||||||
|
} from "@angular/core";
|
||||||
|
|
||||||
|
import type { A11yCellDirective } from "./a11y-cell.directive";
|
||||||
|
import { A11yRowDirective } from "./a11y-row.directive";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: "bitA11yGrid",
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class A11yGridDirective implements AfterViewInit {
|
||||||
|
@HostBinding("attr.role")
|
||||||
|
role = "grid";
|
||||||
|
|
||||||
|
@ContentChildren(A11yRowDirective)
|
||||||
|
rows: QueryList<A11yRowDirective>;
|
||||||
|
|
||||||
|
/** The number of pages to navigate on `PageUp` and `PageDown` */
|
||||||
|
@Input() pageSize = 5;
|
||||||
|
|
||||||
|
private grid: A11yCellDirective[][];
|
||||||
|
|
||||||
|
/** The row that currently has focus */
|
||||||
|
private activeRow = 0;
|
||||||
|
|
||||||
|
/** The cell that currently has focus */
|
||||||
|
private activeCol = 0;
|
||||||
|
|
||||||
|
@HostListener("keydown", ["$event"])
|
||||||
|
onKeyDown(event: KeyboardEvent) {
|
||||||
|
switch (event.code) {
|
||||||
|
case "ArrowUp":
|
||||||
|
this.updateCellFocusByDelta(-1, 0);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
this.updateCellFocusByDelta(0, 1);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
this.updateCellFocusByDelta(1, 0);
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
this.updateCellFocusByDelta(0, -1);
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
|
||||||
|
break;
|
||||||
|
case "PageUp":
|
||||||
|
this.updateCellFocusByDelta(-this.pageSize, 0);
|
||||||
|
break;
|
||||||
|
case "PageDown":
|
||||||
|
this.updateCellFocusByDelta(this.pageSize, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prevent default scrolling behavior */
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initializeGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeGrid(): void {
|
||||||
|
try {
|
||||||
|
this.grid = this.rows.map((listItem) => {
|
||||||
|
listItem.role = "row";
|
||||||
|
return [...listItem.cells];
|
||||||
|
});
|
||||||
|
this.grid.flat().forEach((cell) => {
|
||||||
|
cell.role = "gridcell";
|
||||||
|
cell.getFocusTarget().tabIndex = -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.getActiveCellContent().tabIndex = 0;
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Unable to initialize grid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the focusable content of the active cell */
|
||||||
|
private getActiveCellContent(): HTMLElement {
|
||||||
|
return this.grid[this.activeRow][this.activeCol].getFocusTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move focus via a delta against the currently active gridcell */
|
||||||
|
private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
|
||||||
|
const prevActive = this.getActiveCellContent();
|
||||||
|
|
||||||
|
this.activeCol += colDelta;
|
||||||
|
this.activeRow += rowDelta;
|
||||||
|
|
||||||
|
// Row upper bound
|
||||||
|
if (this.activeRow >= this.grid.length) {
|
||||||
|
this.activeRow = this.grid.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row lower bound
|
||||||
|
if (this.activeRow < 0) {
|
||||||
|
this.activeRow = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column upper bound
|
||||||
|
if (this.activeCol >= this.grid[this.activeRow].length) {
|
||||||
|
if (this.activeRow < this.grid.length - 1) {
|
||||||
|
// Wrap to next row on right arrow
|
||||||
|
this.activeCol = 0;
|
||||||
|
this.activeRow += 1;
|
||||||
|
} else {
|
||||||
|
this.activeCol = this.grid[this.activeRow].length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column lower bound
|
||||||
|
if (this.activeCol < 0) {
|
||||||
|
if (this.activeRow > 0) {
|
||||||
|
// Wrap to prev row on left arrow
|
||||||
|
this.activeRow -= 1;
|
||||||
|
this.activeCol = this.grid[this.activeRow].length - 1;
|
||||||
|
} else {
|
||||||
|
this.activeCol = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextActive = this.getActiveCellContent();
|
||||||
|
nextActive.tabIndex = 0;
|
||||||
|
nextActive.focus();
|
||||||
|
|
||||||
|
if (nextActive !== prevActive) {
|
||||||
|
prevActive.tabIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
libs/components/src/a11y/a11y-row.directive.ts
Normal file
31
libs/components/src/a11y/a11y-row.directive.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
ContentChildren,
|
||||||
|
Directive,
|
||||||
|
HostBinding,
|
||||||
|
QueryList,
|
||||||
|
ViewChildren,
|
||||||
|
} from "@angular/core";
|
||||||
|
|
||||||
|
import { A11yCellDirective } from "./a11y-cell.directive";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: "bitA11yRow",
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class A11yRowDirective implements AfterViewInit {
|
||||||
|
@HostBinding("attr.role")
|
||||||
|
role: "row" | null;
|
||||||
|
|
||||||
|
cells: A11yCellDirective[];
|
||||||
|
|
||||||
|
@ViewChildren(A11yCellDirective)
|
||||||
|
private viewCells: QueryList<A11yCellDirective>;
|
||||||
|
|
||||||
|
@ContentChildren(A11yCellDirective)
|
||||||
|
private contentCells: QueryList<A11yCellDirective>;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.cells = [...this.viewCells, ...this.contentCells];
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||||
|
|
||||||
|
import { FocusableElement } from "../shared/focusable-element";
|
||||||
|
|
||||||
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||||
|
|
||||||
const styles: Record<BadgeVariant, string[]> = {
|
const styles: Record<BadgeVariant, string[]> = {
|
||||||
@ -22,8 +24,9 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
|
|||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
|
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
|
||||||
|
providers: [{ provide: FocusableElement, useExisting: BadgeDirective }],
|
||||||
})
|
})
|
||||||
export class BadgeDirective {
|
export class BadgeDirective implements FocusableElement {
|
||||||
@HostBinding("class") get classList() {
|
@HostBinding("class") get classList() {
|
||||||
return [
|
return [
|
||||||
"tw-inline-block",
|
"tw-inline-block",
|
||||||
@ -62,6 +65,10 @@ export class BadgeDirective {
|
|||||||
*/
|
*/
|
||||||
@Input() truncate = true;
|
@Input() truncate = true;
|
||||||
|
|
||||||
|
getFocusTarget() {
|
||||||
|
return this.el.nativeElement;
|
||||||
|
}
|
||||||
|
|
||||||
private hasHoverEffects = false;
|
private hasHoverEffects = false;
|
||||||
|
|
||||||
constructor(private el: ElementRef<HTMLElement>) {
|
constructor(private el: ElementRef<HTMLElement>) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Component, HostBinding, Input } from "@angular/core";
|
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
|
||||||
|
|
||||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||||
|
import { FocusableElement } from "../shared/focusable-element";
|
||||||
|
|
||||||
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
||||||
|
|
||||||
@ -123,9 +124,12 @@ const sizes: Record<IconButtonSize, string[]> = {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: "button[bitIconButton]:not(button[bitButton])",
|
selector: "button[bitIconButton]:not(button[bitButton])",
|
||||||
templateUrl: "icon-button.component.html",
|
templateUrl: "icon-button.component.html",
|
||||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
|
providers: [
|
||||||
|
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
|
||||||
|
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class BitIconButtonComponent implements ButtonLikeAbstraction {
|
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||||
@Input("bitIconButton") icon: string;
|
@Input("bitIconButton") icon: string;
|
||||||
|
|
||||||
@Input() buttonType: IconButtonType;
|
@Input() buttonType: IconButtonType;
|
||||||
@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
|
|||||||
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
|
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
|
||||||
this.buttonType = value;
|
this.buttonType = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFocusTarget() {
|
||||||
|
return this.elementRef.nativeElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private elementRef: ElementRef) {}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ export * from "./form-field";
|
|||||||
export * from "./icon-button";
|
export * from "./icon-button";
|
||||||
export * from "./icon";
|
export * from "./icon";
|
||||||
export * from "./input";
|
export * from "./input";
|
||||||
|
export * from "./item";
|
||||||
export * from "./layout";
|
export * from "./layout";
|
||||||
export * from "./link";
|
export * from "./link";
|
||||||
export * from "./menu";
|
export * from "./menu";
|
||||||
|
@ -3,12 +3,7 @@ import { take } from "rxjs/operators";
|
|||||||
|
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
|
||||||
/**
|
import { FocusableElement } from "../shared/focusable-element";
|
||||||
* Interface for implementing focusable components. Used by the AutofocusDirective.
|
|
||||||
*/
|
|
||||||
export abstract class FocusableElement {
|
|
||||||
focus: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directive to focus an element.
|
* Directive to focus an element.
|
||||||
@ -46,7 +41,7 @@ export class AutofocusDirective {
|
|||||||
|
|
||||||
private focus() {
|
private focus() {
|
||||||
if (this.focusableElement) {
|
if (this.focusableElement) {
|
||||||
this.focusableElement.focus();
|
this.focusableElement.getFocusTarget().focus();
|
||||||
} else {
|
} else {
|
||||||
this.el.nativeElement.focus();
|
this.el.nativeElement.focus();
|
||||||
}
|
}
|
||||||
|
1
libs/components/src/item/index.ts
Normal file
1
libs/components/src/item/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./item.module";
|
12
libs/components/src/item/item-action.component.ts
Normal file
12
libs/components/src/item/item-action.component.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
|
||||||
|
import { A11yCellDirective } from "../a11y/a11y-cell.directive";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-item-action",
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }],
|
||||||
|
})
|
||||||
|
export class ItemActionComponent extends A11yCellDirective {}
|
16
libs/components/src/item/item-content.component.html
Normal file
16
libs/components/src/item/item-content.component.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<div class="tw-flex tw-gap-2 tw-items-center">
|
||||||
|
<ng-content select="[slot=start]"></ng-content>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-flex-col tw-items-start tw-text-start tw-w-full [&_p]:tw-mb-0">
|
||||||
|
<div class="tw-text-main tw-text-base">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-muted tw-text-sm">
|
||||||
|
<ng-content select="[slot=secondary]"></ng-content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-gap-2 tw-items-center">
|
||||||
|
<ng-content select="[slot=end]"></ng-content>
|
||||||
|
</div>
|
15
libs/components/src/item/item-content.component.ts
Normal file
15
libs/components/src/item/item-content.component.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-item-content, [bit-item-content]",
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: `item-content.component.html`,
|
||||||
|
host: {
|
||||||
|
class:
|
||||||
|
"fvw-target tw-outline-none tw-text-main hover:tw-text-main hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between",
|
||||||
|
},
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ItemContentComponent {}
|
13
libs/components/src/item/item-group.component.ts
Normal file
13
libs/components/src/item/item-group.component.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-item-group",
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
class: "tw-block",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class ItemGroupComponent {}
|
21
libs/components/src/item/item.component.html
Normal file
21
libs/components/src/item/item.component.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!-- TODO: Colors will be finalized in the extension refresh feature branch -->
|
||||||
|
<div
|
||||||
|
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-bg-primary-300/20 tw-text-main tw-border-solid tw-border-b tw-border-0 tw-rounded-lg tw-mb-1.5"
|
||||||
|
[ngClass]="
|
||||||
|
focusVisibleWithin()
|
||||||
|
? 'tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-primary-600 tw-border-transparent'
|
||||||
|
: 'tw-border-b-secondary-300 [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-border-b-transparent'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<bit-item-action class="item-main-content tw-block tw-w-full">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</bit-item-action>
|
||||||
|
|
||||||
|
<div
|
||||||
|
#endSlot
|
||||||
|
class="tw-p-2 tw-flex tw-gap-1 tw-items-center"
|
||||||
|
[hidden]="endSlot.childElementCount === 0"
|
||||||
|
>
|
||||||
|
<ng-content select="[slot=end]"></ng-content>
|
||||||
|
</div>
|
||||||
|
</div>
|
29
libs/components/src/item/item.component.ts
Normal file
29
libs/components/src/item/item.component.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { ChangeDetectionStrategy, Component, HostListener, signal } from "@angular/core";
|
||||||
|
|
||||||
|
import { A11yRowDirective } from "../a11y/a11y-row.directive";
|
||||||
|
|
||||||
|
import { ItemActionComponent } from "./item-action.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-item",
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ItemActionComponent],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: "item.component.html",
|
||||||
|
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
|
||||||
|
})
|
||||||
|
export class ItemComponent extends A11yRowDirective {
|
||||||
|
/**
|
||||||
|
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
|
||||||
|
*/
|
||||||
|
protected focusVisibleWithin = signal(false);
|
||||||
|
@HostListener("focusin", ["$event.target"])
|
||||||
|
onFocusIn(target: HTMLElement) {
|
||||||
|
this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
|
||||||
|
}
|
||||||
|
@HostListener("focusout")
|
||||||
|
onFocusOut() {
|
||||||
|
this.focusVisibleWithin.set(false);
|
||||||
|
}
|
||||||
|
}
|
141
libs/components/src/item/item.mdx
Normal file
141
libs/components/src/item/item.mdx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
|
||||||
|
|
||||||
|
import * as stories from "./item.stories";
|
||||||
|
|
||||||
|
<Meta of={stories} />
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ItemModule } from "@bitwarden/components";
|
||||||
|
```
|
||||||
|
|
||||||
|
# Item
|
||||||
|
|
||||||
|
`<bit-item>` is a horizontal card that contains one or more interactive actions.
|
||||||
|
|
||||||
|
It is a generic container that can be used for either standalone content, an alternative to tables,
|
||||||
|
or to list nav links.
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story of={stories.Default} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Primary Content
|
||||||
|
|
||||||
|
The primary content of an item is supplied by `bit-item-content`.
|
||||||
|
|
||||||
|
### Content Types
|
||||||
|
|
||||||
|
The content can be a button, anchor, or static container.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<bit-item>
|
||||||
|
<a bit-item-content routerLink="..."> Hi, I am a link. </a>
|
||||||
|
</bit-item>
|
||||||
|
|
||||||
|
<bit-item>
|
||||||
|
<button bit-item-content (click)="...">And I am a button.</button>
|
||||||
|
</bit-item>
|
||||||
|
|
||||||
|
<bit-item>
|
||||||
|
<bit-item-content> I'm just static :( </bit-item-content>
|
||||||
|
</bit-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story of={stories.ContentTypes} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
### Content Slots
|
||||||
|
|
||||||
|
`bit-item-content` contains the following slots to help position the content:
|
||||||
|
|
||||||
|
| Slot | Description |
|
||||||
|
| ------------------ | --------------------------------------------------- |
|
||||||
|
| default | primary text or arbitrary content; fan favorite |
|
||||||
|
| `slot="secondary"` | supporting text; under the default slot |
|
||||||
|
| `slot="start"` | commonly an icon or avatar; before the default slot |
|
||||||
|
| `slot="end"` | commonly an icon; after the default slot |
|
||||||
|
|
||||||
|
- Note: There is also an `end` slot within `bit-item` itself. Place
|
||||||
|
[interactive secondary actions](#secondary-actions) there, and place non-interactive content (such
|
||||||
|
as icons) in `bit-item-content`
|
||||||
|
|
||||||
|
```html
|
||||||
|
<bit-item>
|
||||||
|
<button bit-item-content type="button">
|
||||||
|
<bit-avatar slot="start" text="Foo"></bit-avatar>
|
||||||
|
foo@bitwarden.com
|
||||||
|
<ng-container slot="secondary">
|
||||||
|
<div>Bitwarden.com</div>
|
||||||
|
<div><em>locked</em></div>
|
||||||
|
</ng-container>
|
||||||
|
<i slot="end" class="bwi bwi-lock" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</bit-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story of={stories.ContentSlots} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Secondary Actions
|
||||||
|
|
||||||
|
Secondary interactive actions can be placed in the item through the `"end"` slot, outside of
|
||||||
|
`bit-item-content`.
|
||||||
|
|
||||||
|
Each action must be wrapped by `<bit-item-action>`.
|
||||||
|
|
||||||
|
Actions are commonly icon buttons or badge buttons.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<bit-item>
|
||||||
|
<button bit-item-content>...</button>
|
||||||
|
|
||||||
|
<ng-container slot="end">
|
||||||
|
<bit-item-action>
|
||||||
|
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||||
|
</bit-item-action>
|
||||||
|
<bit-item-action>
|
||||||
|
<button type="button" bitIconButton="bwi-clone" aria-label="Copy"></button>
|
||||||
|
</bit-item-action>
|
||||||
|
<bit-item-action>
|
||||||
|
<button type="button" bitIconButton="bwi-ellipsis-v" aria-label="Options"></button>
|
||||||
|
</bit-item-action>
|
||||||
|
</ng-container>
|
||||||
|
</bit-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Item Groups
|
||||||
|
|
||||||
|
Groups of items can be associated by wrapping them in the `<bit-item-group>`.
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story of={stories.MultipleActionList} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story of={stories.SingleActionList} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
### A11y
|
||||||
|
|
||||||
|
Keyboard nav is currently disabled due to a bug when used within a virtual scroll viewport.
|
||||||
|
|
||||||
|
Item groups utilize arrow-based keyboard navigation
|
||||||
|
([further reading here](https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/#kbd_label)).
|
||||||
|
|
||||||
|
Use `aria-label` or `aria-labelledby` to give groups an accessible name.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<bit-item-group aria-label="My Items">
|
||||||
|
<bit-item>...</bit-item>
|
||||||
|
<bit-item>...</bit-item>
|
||||||
|
<bit-item>...</bit-item>
|
||||||
|
</bit-item-group>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Virtual Scrolling
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story of={stories.VirtualScrolling} />
|
||||||
|
</Canvas>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user