1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 00:23:17 +00:00

resolve merge conflicts

This commit is contained in:
William Martin
2025-06-30 12:58:11 -04:00
1495 changed files with 52711 additions and 21255 deletions

View File

@@ -1,6 +1,6 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../shared/tsconfig.spec");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
@@ -8,13 +8,12 @@ const sharedConfig = require("../../libs/shared/jest.config.angular");
module.exports = {
...sharedConfig,
displayName: "libs/admin-console tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in tests
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
prefix: "<rootDir>/../../",
},
),
};

View File

@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType } from "./collection";
import { CollectionDetailsResponse } from "./collection.response";
export class CollectionData {
@@ -12,6 +13,7 @@ export class CollectionData {
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
type: CollectionType;
constructor(response: CollectionDetailsResponse) {
this.id = response.id;
@@ -21,6 +23,7 @@ export class CollectionData {
this.readOnly = response.readOnly;
this.manage = response.manage;
this.hidePasswords = response.hidePasswords;
this.type = response.type;
}
static fromJSON(obj: Jsonify<CollectionData>) {

View File

@@ -2,11 +2,14 @@ import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/model
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType } from "./collection";
export class CollectionResponse extends BaseResponse {
id: CollectionId;
organizationId: OrganizationId;
name: string;
externalId: string;
type: CollectionType;
constructor(response: any) {
super(response);
@@ -14,6 +17,7 @@ export class CollectionResponse extends BaseResponse {
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.externalId = this.getResponseProperty("ExternalId");
this.type = this.getResponseProperty("Type");
}
}

View File

@@ -2,7 +2,7 @@ import { makeSymmetricCryptoKey, mockEnc } from "@bitwarden/common/spec";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { Collection } from "./collection";
import { Collection, CollectionTypes } from "./collection";
import { CollectionData } from "./collection.data";
describe("Collection", () => {
@@ -17,6 +17,7 @@ describe("Collection", () => {
readOnly: true,
manage: true,
hidePasswords: true,
type: CollectionTypes.DefaultUserCollection,
};
});
@@ -32,6 +33,7 @@ describe("Collection", () => {
organizationId: null,
readOnly: null,
manage: null,
type: null,
});
});
@@ -46,6 +48,7 @@ describe("Collection", () => {
readOnly: true,
manage: true,
hidePasswords: true,
type: CollectionTypes.DefaultUserCollection,
});
});
@@ -58,6 +61,7 @@ describe("Collection", () => {
collection.readOnly = false;
collection.hidePasswords = false;
collection.manage = true;
collection.type = CollectionTypes.DefaultUserCollection;
const key = makeSymmetricCryptoKey<OrgKey>();
@@ -72,6 +76,7 @@ describe("Collection", () => {
readOnly: false,
manage: true,
assigned: true,
type: CollectionTypes.DefaultUserCollection,
});
});
});

View File

@@ -7,6 +7,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
import { CollectionView } from "./collection.view";
export const CollectionTypes = {
SharedCollection: 0,
DefaultUserCollection: 1,
} as const;
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain {
id: string;
organizationId: string;
@@ -15,6 +22,7 @@ export class Collection extends Domain {
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
type: CollectionType;
constructor(obj?: CollectionData) {
super();
@@ -33,8 +41,9 @@ export class Collection extends Domain {
readOnly: null,
hidePasswords: null,
manage: null,
type: null,
},
["id", "organizationId", "readOnly", "hidePasswords", "manage"],
["id", "organizationId", "readOnly", "hidePasswords", "manage", "type"],
);
}

View File

@@ -6,7 +6,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { View } from "@bitwarden/common/models/view/view";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection } from "./collection";
import { Collection, CollectionType } from "./collection";
import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
@@ -21,6 +21,7 @@ export class CollectionView implements View, ITreeNodeObject {
hidePasswords: boolean = null;
manage: boolean = null;
assigned: boolean = null;
type: CollectionType = null;
constructor(c?: Collection | CollectionAccessDetailsResponse) {
if (!c) {
@@ -39,6 +40,7 @@ export class CollectionView implements View, ITreeNodeObject {
if (c instanceof CollectionAccessDetailsResponse) {
this.assigned = c.assigned;
}
this.type = c.type;
}
canEditItems(org: Organization): boolean {

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
export class OrganizationUserConfirmRequest {
key: string;
key: EncryptedString | undefined;
defaultUserCollectionName: EncryptedString | undefined;
}

View File

@@ -1,13 +1,5 @@
{
"extends": "../shared/tsconfig",
"compilerOptions": {
"paths": {
"@bitwarden/admin-console/common": ["../admin-console/src/common"],
"@bitwarden/auth/common": ["../auth/src/common"],
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/key-management": ["../key-management/src"]
}
},
"extends": "../../tsconfig.base",
"include": ["src", "spec", "../../libs/common/custom-matchers.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"emitDecoratorMetadata": false
},
"files": ["./test.setup.ts"]
}

View File

@@ -1,6 +1,6 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../shared/tsconfig.spec");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
@@ -8,13 +8,12 @@ const sharedConfig = require("../../libs/shared/jest.config.angular");
module.exports = {
...sharedConfig,
displayName: "libs/angular tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in tests
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
prefix: "<rootDir>/../../",
},
),
};

View File

@@ -11,7 +11,6 @@ import { ButtonModule } from "@bitwarden/components";
*/
@Component({
selector: "app-authentication-timeout",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, RouterModule],
template: `
<p class="tw-text-center">

View File

@@ -13,7 +13,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { activeAuthGuard } from "./active-auth.guard";
@Component({ template: "" })
@Component({ template: "", standalone: false })
class EmptyComponent {}
describe("activeAuthGuard", () => {

View File

@@ -13,8 +13,10 @@ import {
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -25,12 +27,14 @@ describe("AuthGuard", () => {
authStatus: AuthenticationStatus,
forceSetPasswordReason: ForceSetPasswordReason,
keyConnectorServiceRequiresAccountConversion: boolean = false,
featureFlag: FeatureFlag | null = null,
) => {
const authService: MockProxy<AuthService> = mock<AuthService>();
authService.getAuthStatus.mockResolvedValue(authStatus);
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
const keyConnectorService: MockProxy<KeyConnectorService> = mock<KeyConnectorService>();
keyConnectorService.convertAccountRequired$ = of(keyConnectorServiceRequiresAccountConversion);
const configService: MockProxy<ConfigService> = mock<ConfigService>();
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
@@ -45,6 +49,12 @@ describe("AuthGuard", () => {
),
);
if (featureFlag) {
configService.getFeatureFlag.mockResolvedValue(true);
} else {
configService.getFeatureFlag.mockResolvedValue(false);
}
const forceSetPasswordReasonSubject = new BehaviorSubject<ForceSetPasswordReason>(
forceSetPasswordReason,
);
@@ -59,7 +69,10 @@ describe("AuthGuard", () => {
{ path: "guarded-route", component: EmptyComponent, canActivate: [authGuard] },
{ path: "lock", component: EmptyComponent },
{ path: "set-password", component: EmptyComponent },
{ path: "set-password-jit", component: EmptyComponent },
{ path: "set-initial-password", component: EmptyComponent },
{ path: "update-temp-password", component: EmptyComponent },
{ path: "change-password", component: EmptyComponent },
{ path: "remove-password", component: EmptyComponent },
]),
],
@@ -69,6 +82,7 @@ describe("AuthGuard", () => {
{ provide: KeyConnectorService, useValue: keyConnectorService },
{ provide: AccountService, useValue: accountService },
{ provide: MasterPasswordServiceAbstraction, useValue: masterPasswordService },
{ provide: ConfigService, useValue: configService },
],
});
@@ -110,70 +124,147 @@ describe("AuthGuard", () => {
expect(router.url).toBe("/remove-password");
});
it("should redirect to set-password when user is TDE user without password and has password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
);
describe("given user is Unlocked", () => {
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
const tests = [
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
ForceSetPasswordReason.TdeOffboarding,
];
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-password");
});
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
it("should redirect to update-temp-password when user has force set password reason", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
describe("given user attempts to navigate to /set-initial-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
it("should redirect to update-temp-password when user has weak password", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.WeakMasterPassword,
);
await router.navigate(["/set-initial-password"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
});
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
describe("given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
const tests = [
{
reason: ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
url: "/set-password",
},
{
reason: ForceSetPasswordReason.TdeOffboarding,
url: "/update-temp-password",
},
];
it("should allow navigation to set-password when the user is unlocked, is a TDE user without password, and has password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
);
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach(({ reason, url }) => {
it(`should redirect to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/set-password"]);
expect(router.url).toContain("/set-password");
});
await router.navigate(["/guarded-route"]);
expect(router.url).toContain(url);
});
});
});
it("should allow navigation to update-temp-password when the user is unlocked and has admin force password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
);
describe("given user attempts to navigate to the set- or update- password route itself", () => {
tests.forEach(({ reason, url }) => {
it(`should allow navigation to continue to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
await router.navigate([url]);
expect(router.url).toContain(url);
});
});
});
});
it("should allow navigation to update-temp-password when the user is unlocked and has weak password", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.WeakMasterPassword,
);
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is ON", () => {
const tests = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
];
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
it("should allow navigation to remove-password when the user is unlocked and has 'none' password reset permission", async () => {
const { router } = setup(AuthenticationStatus.Unlocked, ForceSetPasswordReason.None);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/change-password");
});
});
});
await router.navigate(["/remove-password"]);
expect(router.url).toContain("/remove-password");
describe("given user attempts to navigate to /change-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
await router.navigate(["/change-password"]);
expect(router.url).toContain("/change-password");
});
});
});
});
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is OFF", () => {
const tests = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
];
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
});
});
describe("given user attempts to navigate to /update-temp-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
});
});
});
});
});

View File

@@ -14,8 +14,10 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
export const authGuard: CanActivateFn = async (
@@ -28,6 +30,7 @@ export const authGuard: CanActivateFn = async (
const keyConnectorService = inject(KeyConnectorService);
const accountService = inject(AccountService);
const masterPasswordService = inject(MasterPasswordServiceAbstraction);
const configService = inject(ConfigService);
const authStatus = await authService.getAuthStatus();
@@ -57,19 +60,43 @@ export const authGuard: CanActivateFn = async (
masterPasswordService.forceSetPasswordReason$(userId),
);
const isSetInitialPasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const isChangePasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
// TDE org user has "manage account recovery" permission
if (
forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission &&
!routerState.url.includes("set-password")
!routerState.url.includes("set-password") &&
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/set-password"]);
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password";
return router.createUrlTree([route]);
}
// TDE Offboarding
if (
forceSetPasswordReason !== ForceSetPasswordReason.None &&
!routerState.url.includes("update-temp-password")
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding &&
!routerState.url.includes("update-temp-password") &&
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/update-temp-password"]);
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/update-temp-password";
return router.createUrlTree([route]);
}
// Post- Account Recovery or Weak Password on login
if (
(forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) &&
!routerState.url.includes("update-temp-password") &&
!routerState.url.includes("change-password")
) {
const route = isChangePasswordFlagOn ? "/change-password" : "/update-temp-password";
return router.createUrlTree([route]);
}
return true;

View File

@@ -1,6 +1,6 @@
export * from "./auth.guard";
export * from "./active-auth.guard";
export * from "./lock.guard";
export * from "./redirect.guard";
export * from "./redirect/redirect.guard";
export * from "./tde-decryption-required.guard";
export * from "./unauth.guard";

View File

@@ -44,7 +44,7 @@ describe("lockGuard", () => {
const keyService: MockProxy<KeyService> = mock<KeyService>();
keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
keyService.everHadUserKey$ = of(setupParams.everHadUserKey);
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
platformUtilService.getClientType.mockReturnValue(setupParams.clientType);
@@ -79,7 +79,6 @@ describe("lockGuard", () => {
{ path: "", component: EmptyComponent },
{ path: "lock", component: EmptyComponent, canActivate: [lockGuard()] },
{ path: "non-lock-route", component: EmptyComponent },
{ path: "migrate-legacy-encryption", component: EmptyComponent },
]),
],
providers: [
@@ -182,18 +181,6 @@ describe("lockGuard", () => {
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should send the user to migrate-legacy-encryption if they are a legacy user on a web client", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Web,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/migrate-legacy-encryption");
});
it("should allow navigation to the lock route when device trust is supported, the user has a MP, and the user is coming from the login-initiated page", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,

View File

@@ -11,11 +11,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ClientType } from "@bitwarden/common/enums";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeyService } from "@bitwarden/key-management";
/**
@@ -33,7 +31,6 @@ export function lockGuard(): CanActivateFn {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const platformUtilService = inject(PlatformUtilsService);
const messagingService = inject(MessagingService);
const router = inject(Router);
const userVerificationService = inject(UserVerificationService);
@@ -59,12 +56,7 @@ export function lockGuard(): CanActivateFn {
return false;
}
// If legacy user on web, redirect to migration page
if (await keyService.isLegacyUser()) {
if (platformUtilService.getClientType() === ClientType.Web) {
return router.createUrlTree(["migrate-legacy-encryption"]);
}
// Log out legacy users on other clients
messagingService.send("logout");
return false;
}
@@ -84,7 +76,7 @@ export function lockGuard(): CanActivateFn {
}
// If authN user with TDE directly navigates to lock, reject that navigation
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(activeUser.id));
if (tdeEnabled && !everHadUserKey) {
return false;
}

View File

@@ -0,0 +1,53 @@
# Redirect Guard
The `redirectGuard` redirects the user based on their `AuthenticationStatus`. It is applied to the root route (`/`).
<br>
### Order of Operations
The `redirectGuard` will redirect the user based on the following checks, _in order_:
- **`AuthenticationStatus.LoggedOut`** &rarr; redirect to `/login`
- **`AuthenticationStatus.Unlocked`** &rarr; redirect to `/vault`
- **`AuthenticationStatus.Locked`**
- **TDE Locked State** &rarr; redirect to `/login-initiated`
- A user is in a TDE Locked State if they meet all 3 of the following conditions
1. Auth status is `Locked`
2. TDE is enabled
3. User has never had a user key (that is, user has not unlocked/decrypted yet)
- **Standard Locked State** &rarr; redirect to `/lock`
<br>
| Order | AuthenticationStatus | Redirect To |
| ----- | ------------------------------------------------------------------------------- | ------------------ |
| 1 | `LoggedOut` | `/login` |
| 2 | `Unlocked` | `/vault` |
| 3 | **TDE Locked State** <br> `Locked` + <br> `tdeEnabled` + <br> `!everHadUserKey` | `/login-initiated` |
| 4 | **Standard Locked State** <br> `Locked` | `/lock` |
<br>
### Default Routes and Route Overrides
The default redirect routes are mapped to object properties:
```typescript
const defaultRoutes: RedirectRoutes = {
loggedIn: "/vault",
loggedOut: "/login",
locked: "/lock",
notDecrypted: "/login-initiated",
};
```
But when applying the guard to the root route, the developer can override specific redirect routes by passing in a custom object. This is useful for subtle differences in client-specific routing:
```typescript
// app-routing.module.ts (Browser Extension)
{
path: "",
canActivate: [redirectGuard({ loggedIn: "/tabs/current"})],
}
```

View File

@@ -2,8 +2,10 @@ import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { KeyService } from "@bitwarden/key-management";
@@ -23,33 +25,42 @@ const defaultRoutes: RedirectRoutes = {
};
/**
* Guard that consolidates all redirection logic, should be applied to root route.
* Redirects the user to the appropriate route based on their `AuthenticationStatus`.
* This guard should be applied to the root route.
*
* TODO: This should return Observable<boolean | UrlTree> once we can get rid of all the promises
*/
export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActivateFn {
const routes = { ...defaultRoutes, ...overrides };
return async (route) => {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const accountService = inject(AccountService);
const logService = inject(LogService);
const router = inject(Router);
const authStatus = await authService.getAuthStatus();
// Logged Out
if (authStatus === AuthenticationStatus.LoggedOut) {
return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams });
}
// Unlocked
if (authStatus === AuthenticationStatus.Unlocked) {
return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams });
}
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.
// Locked: TDE Locked State
// - If user meets all 3 of the following conditions:
// 1. Auth status is Locked
// 2. TDE is enabled
// 3. User has never had a user key (has not decrypted yet)
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId));
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) {
logService.info(
"Sending user to TDE decryption options. AuthStatus is %s. TDE support is %s. Ever had user key is %s.",
@@ -60,6 +71,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams });
}
// Locked: Standard Locked State
if (authStatus === AuthenticationStatus.Locked) {
return router.createUrlTree([routes.locked], { queryParams: route.queryParams });
}

View File

@@ -0,0 +1,107 @@
import { TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
describe("tdeDecryptionRequiredGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
};
const setup = (
activeUser: Account | null,
authStatus: AuthenticationStatus | null = null,
tdeEnabled: boolean = false,
everHadUserKey: boolean = false,
) => {
const accountService = mock<AccountService>();
const authService = mock<AuthService>();
const keyService = mock<KeyService>();
const deviceTrustService = mock<DeviceTrustServiceAbstraction>();
const logService = mock<LogService>();
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
if (authStatus !== null) {
authService.getAuthStatus.mockResolvedValue(authStatus);
}
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
deviceTrustService.supportsDeviceTrust$ = of(tdeEnabled);
const testBed = TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: AuthService, useValue: authService },
{ provide: KeyService, useValue: keyService },
{ provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService },
{ provide: LogService, useValue: logService },
provideRouter([
{ path: "", component: EmptyComponent },
{
path: "protected-route",
component: EmptyComponent,
canActivate: [tdeDecryptionRequiredGuard()],
},
]),
],
});
return {
router: testBed.inject(Router),
};
};
it("redirects to root when the active account is null", async () => {
const { router } = setup(null, null);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
test.each([AuthenticationStatus.Unlocked, AuthenticationStatus.LoggedOut])(
"redirects to root when the user isn't locked",
async (authStatus) => {
const { router } = setup(activeUser, authStatus);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
},
);
it("redirects to root when TDE is not enabled", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, false, true);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
it("redirects to root when user has had a user key", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, true);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
it("allows access when user is locked, TDE is enabled, and user has never had a user key", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, false);
const result = await router.navigate(["protected-route"]);
expect(result).toBe(true);
expect(router.url).toBe("/protected-route");
});
});

View File

@@ -5,8 +5,9 @@ import {
RouterStateSnapshot,
CanActivateFn,
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
@@ -24,12 +25,18 @@ export function tdeDecryptionRequiredGuard(): CanActivateFn {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const accountService = inject(AccountService);
const logService = inject(LogService);
const router = inject(Router);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(map((a) => a?.id)));
if (userId == null) {
return router.createUrlTree(["/"]);
}
const authStatus = await authService.getAuthStatus();
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
// We need to determine if we should bypass the decryption options and send the user to the vault.
// The ONLY time that we want to send a user to the decryption options is when:

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -43,7 +43,7 @@ describe("UnauthGuard", () => {
authService.authStatusFor$.mockReturnValue(activeAccountStatusObservable);
}
keyService.everHadUserKey$ = new BehaviorSubject<boolean>(everHadUserKey);
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
deviceTrustService.supportsDeviceTrustByUserId$.mockReturnValue(
new BehaviorSubject<boolean>(tdeEnabled),
);

View File

@@ -50,7 +50,7 @@ async function unauthGuard(
const tdeEnabled = await firstValueFrom(
deviceTrustService.supportsDeviceTrustByUserId$(activeUser.id),
);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(activeUser.id));
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.

View File

@@ -0,0 +1,216 @@
# Master Password Management Flows
The Auth Team manages several components that allow a user to either:
1. Set an initial master password
2. Change an existing master password
This document maps all of our password management flows to the components that handle them.
<br>
**Table of Contents**
> - [The Base `InputPasswordComponent`](#the-base-inputpasswordcomponent)
> - [Set Initial Password Flows](#set-initial-password-flows)
> - [Change Password Flows](#change-password-flows)
<br>
**Acronyms**
<ul>
<li>MP = "master password"</li>
<li>MPE = "master password encryption"</li>
<li>TDE = "trusted device encryption"</li>
<li>JIT provision = "just-in-time provision"</li>
</ul>
<br>
## The Base `InputPasswordComponent`
Central to our master password management flows is the base [InputPasswordComponent](https://components.bitwarden.com/?path=/docs/auth-input-password--docs), which is responsible for displaying the appropriate form fields in the UI, performing form validation, and generating appropriate cryptographic properties for each flow. This keeps our UI, validation, and key generation consistent across all master password management flows.
<br>
## Set Initial Password Flows
<table>
<thead>
<tr>
<td><strong>Flow</strong></td>
<td><strong>Route</strong><br><small>(on which user sets MP)</small></td>
<td><strong>Component(s)</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>
<br>
<strong>Account Registration</strong>
<br><br>
<ol>
<li>Standard Flow</li>
<br>
<li>Self Hosted Flow</li>
<br>
<li>Email Invite Flows <small>(🌐 web only)</small></li>
<br>
</ol>
</td>
<td><code>/finish-signup</code></td>
<td>
<code>RegistrationFinishComponent</code>
<br>
<small>- embeds <code>InputPasswordComponent</code></small>
</tr>
<tr>
<td>
<strong>Trial Initiation</strong> <small>(🌐 web only)</small>
</td>
<td><code>/trial-initiation</code> or<br> <code>/secrets-manager-trial-initiation</code></td>
<td>
<code>CompleteTrialInitiationComponent</code>
<br>
<small>- embeds <code>InputPasswordComponent</code></small>
</td>
</tr>
<tr>
<td>
<br>
<strong>Upon Authentication</strong> (an existing authed user)
<br><br>
<ol>
<li><strong>User JIT provisions<small>*</small> into an MPE org</strong></li>
<br>
<li>
<strong>User JIT provisions<small>*</small> into a TDE org with the "manage account recovery" permission</strong>
<p>That is, the user was given this permission on invitation or by the time they JIT provision.</p>
</li>
<br>
<li>
<strong>TDE user permissions upgraded</strong>
<p>TDE user authenticates after permissions were upgraded to include "manage account recovery".</p>
</li>
<br>
<li>
<strong>TDE offboarding</strong>
<p>User authenticates after their org offboarded from TDE and is now a MPE org.</p>
<p>User must be on a trusted device to set MP, otherwise user must go through Account Recovery.</p>
</li>
</ol>
</td>
<td><code>/set-initial-password</code></td>
<td>
<code>SetInitialPasswordComponent</code>
<br>
<small>- embeds <code>InputPasswordComponent</code></small>
</td>
</tr>
</tbody>
</table>
\* A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider them to be an “existing authed user” _from the perspective of the set initial password flow_. This is because at the time they set their initial password, their account already exists in the database (before setting their password) and they have already authenticated via SSO.
- The same is not true in the _Account Registration_ flows above—that is, during account registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their initial password, their account does not yet exist in the database, and will only be created once they set an initial password.
<br>
## Change Password Flows
<table>
<thead>
<tr>
<td><strong>Flow</strong></td>
<td><strong>Route</strong><br><small>(on which user changes MP)</small></td>
<td><strong>Component(s)</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>
<br>
<strong>Account Settings</strong>
(<small><a href="https://bitwarden.com/help/master-password/#change-master-password">Docs</a></small>)
<br>
<small>(🌐 web only)</small>
<br><br>
<p>User changes MP via account settings.</p>
<br>
</td>
<td>
<code>/settings/security/password</code>
<br>(<code>security-routing.module.ts</code>)
</td>
<td>
<code>PasswordSettingsComponent</code>
<br><small>- embeds <code>ChangePasswordComponent</code></small>
<br><small>- embeds <code>InputPasswordComponent</code></small>
</td>
</tr>
<tr>
<td>
<br>
<strong>Upon Authentication</strong>
<br><br>
<ol>
<li>
<strong>Login with non-compliant MP after email accept</strong> <small>(🌐 web only)</small>
<p>User clicks an org email invite link and logs in with their MP that does not meet the orgs policy requirements.</p>
</li>
<br>
<li>
<strong>Login with non-compliant MP</strong>
<p>Existing org user logs in with their MP that does not meet updated org policy requirements.</p>
</li>
<br>
<li>
<strong>Login after Account Recovery</strong>
<p>User logs in after their MP was reset via Account Recovery.</p>
</li>
</ol>
</td>
<td><code>/change-password</code></td>
<td>
<code>ChangePasswordComponent</code>
<br><small>- embeds <code>InputPasswordComponent</code></small>
</td>
</tr>
<tr>
<td>
<br>
<strong>Emergency Access Takeover</strong>
<small>(<a href="https://bitwarden.com/help/emergency-access/">Docs</a>)</small>
<br>
<small>(🌐 web only)</small>
<br><br>
<p>Emergency access Grantee changes the MP for the Grantor.</p>
<br>
</td>
<td>Grantee opens dialog while on <code>/settings/emergency-access</code></td>
<td>
<code>EmergencyAccessTakeoverDialogComponent</code>
<br><small>- embeds <code>InputPasswordComponent</code></small>
</td>
</tr>
<tr>
<td>
<br>
<strong>Account Recovery</strong>
<small>(<a href="https://bitwarden.com/help/account-recovery/">Docs</a>)</small>
<br>
<small>(🌐 web only)</small>
<br><br>
<p>Org member with "manage account recovery" permission changes the MP for another org user via Account Recovery.</p>
<br>
</td>
<td>Org member opens dialog while on <code>/organizations/{org-id}/members</code></td>
<td>
<code>AccountRecoveryDialogComponent</code>
<br><small>- embeds <code>InputPasswordComponent</code></small>
</td>
</tr>
</tbody>
</table>

View File

@@ -27,6 +27,7 @@ const testStringFeatureValue = "test-value";
</div>
</div>
`,
standalone: false,
})
class TestComponent {
testBooleanFeature = testBooleanFeature;

View File

@@ -2,7 +2,6 @@ import { Directive, HostListener, Input } from "@angular/core";
@Directive({
selector: "[appTextDrag]",
standalone: true,
host: {
draggable: "true",
class: "tw-cursor-move",

View File

@@ -85,11 +85,11 @@ import { IconComponent } from "./vault/components/icon.component";
TextDragDirective,
CopyClickDirective,
A11yTitleDirective,
AutofocusDirective,
],
declarations: [
A11yInvalidDirective,
ApiActionDirective,
AutofocusDirective,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,

View File

@@ -2,7 +2,6 @@ import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "pluralize",
standalone: true,
})
export class PluralizePipe implements PipeTransform {
transform(count: number, singular: string, plural: string): string {

View File

@@ -12,7 +12,7 @@ import { I18nMockService, ToastService } from "@bitwarden/components/src";
import { canAccessFeature } from "./feature-flag.guard";
@Component({ template: "" })
@Component({ template: "", standalone: false })
export class EmptyComponent {}
describe("canAccessFeature", () => {

View File

@@ -0,0 +1,36 @@
import { mock } from "jest-mock-extended";
import { Subject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DocumentLangSetter } from "./document-lang.setter";
describe("DocumentLangSetter", () => {
const document = mock<Document>();
const i18nService = mock<I18nService>();
const sut = new DocumentLangSetter(document, i18nService);
describe("start", () => {
it("reacts to locale changes while start called with a non-closed subscription", async () => {
const localeSubject = new Subject<string>();
i18nService.locale$ = localeSubject;
localeSubject.next("en");
expect(document.documentElement.lang).toBeFalsy();
const sub = sut.start();
localeSubject.next("es");
expect(document.documentElement.lang).toBe("es");
sub.unsubscribe();
localeSubject.next("ar");
expect(document.documentElement.lang).toBe("es");
});
});
});

View File

@@ -0,0 +1,26 @@
import { Subscription } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
/**
* A service for managing the setting of the `lang="<locale>" attribute on the
* main document for the application.
*/
export class DocumentLangSetter {
constructor(
private readonly document: Document,
private readonly i18nService: I18nService,
) {}
/**
* Starts listening to an upstream source for the best locale for the user
* and applies it to the application document.
* @returns A subscription that can be unsubscribed if you wish to stop
* applying lang attribute updates to the application document.
*/
start(): Subscription {
return this.i18nService.locale$.subscribe((locale) => {
this.document.documentElement.lang = locale;
});
}
}

View File

@@ -0,0 +1 @@
export { DocumentLangSetter } from "./document-lang.setter";

View File

@@ -43,12 +43,9 @@ on any component.
The persistence layer ensures that the popup will open at the same route as was active when it
closed, provided that none of the lifetime expiration events have occurred.
:::tip Excluding a route
If a particular route should be excluded from the history and not persisted, add
`doNotSaveUrl: true` to the `data` property on the route.
:::
> [!TIP]
> If a particular route should be excluded from the history and not persisted, add
> `doNotSaveUrl: true` to the `data` property on the route.
### View data persistence
@@ -85,13 +82,10 @@ const mySignal = this.viewCacheService.signal({
mySignal.set("value")
```
:::note Equality comparison
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
the updated value is not equal to the current signal state. See documentation
[here](https://angular.dev/guide/signals#signal-equality-functions).
:::
> [!NOTE]
> By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
> the updated value is not equal to the current signal state. See documentation
> [here](https://angular.dev/guide/signals#signal-equality-functions).
Putting this together, the most common implementation pattern would be:

View File

@@ -21,6 +21,7 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
export { SafeInjectionToken } from "@bitwarden/ui-common";
export const WINDOW = new SafeInjectionToken<Window>("WINDOW");
export const DOCUMENT = new SafeInjectionToken<Document>("DOCUMENT");
export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService
>("OBSERVABLE_MEMORY_STORAGE");

View File

@@ -14,8 +14,6 @@ import {
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
DefaultLoginDecryptionOptionsService,
@@ -37,11 +35,12 @@ import {
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AuthRequestApiService,
AuthRequestApiServiceAbstraction,
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
@@ -50,6 +49,7 @@ import {
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
LogoutReason,
LogoutService,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
@@ -59,7 +59,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
@@ -257,7 +256,6 @@ import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { SearchService } from "@bitwarden/common/services/search.service";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
@@ -279,6 +277,7 @@ import {
FolderService as FolderServiceAbstraction,
InternalFolderService,
} from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/vault/abstractions/search.service";
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import {
@@ -294,10 +293,16 @@ import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { SearchService } from "@bitwarden/common/vault/services/search.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
ToastService,
} from "@bitwarden/components";
import {
GeneratorHistoryService,
LocalGeneratorHistoryService,
@@ -337,6 +342,7 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
@@ -349,6 +355,7 @@ import { NoopViewCacheService } from "../platform/view-cache/internal";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,
DOCUMENT,
ENV_ADDITIONAL_REGIONS,
HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
@@ -378,6 +385,7 @@ const safeProviders: SafeProvider[] = [
safeProvider(ModalService),
safeProvider(PasswordRepromptService),
safeProvider({ provide: WINDOW, useValue: window }),
safeProvider({ provide: DOCUMENT, useValue: document }),
safeProvider({
provide: LOCALE_ID as SafeInjectionToken<string>,
useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale,
@@ -402,6 +410,7 @@ const safeProviders: SafeProvider[] = [
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
// TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService
safeProvider({
provide: LOGOUT_CALLBACK,
useFactory:
@@ -672,6 +681,11 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
],
}),
safeProvider({
provide: RestrictedItemTypesService,
useClass: RestrictedItemTypesService,
deps: [ConfigService, AccountService, OrganizationServiceAbstraction, PolicyServiceAbstraction],
}),
safeProvider({
provide: PasswordStrengthServiceAbstraction,
useClass: PasswordStrengthService,
@@ -877,6 +891,7 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
AccountServiceAbstraction,
ApiServiceAbstraction,
RestrictedItemTypesService,
],
}),
safeProvider({
@@ -892,6 +907,7 @@ const safeProviders: SafeProvider[] = [
CollectionService,
KdfConfigService,
AccountServiceAbstraction,
RestrictedItemTypesService,
],
}),
safeProvider({
@@ -1165,6 +1181,11 @@ const safeProviders: SafeProvider[] = [
useClass: DevicesServiceImplementation,
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
}),
safeProvider({
provide: AuthRequestApiServiceAbstraction,
useClass: DefaultAuthRequestApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: DeviceTrustServiceAbstraction,
useClass: DeviceTrustService,
@@ -1189,12 +1210,12 @@ const safeProviders: SafeProvider[] = [
useClass: AuthRequestService,
deps: [
AppIdServiceAbstraction,
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyService,
EncryptService,
ApiServiceAbstraction,
StateProvider,
AuthRequestApiServiceAbstraction,
],
}),
safeProvider({
@@ -1459,17 +1480,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: CipherAuthorizationService,
useClass: DefaultCipherAuthorizationService,
deps: [
CollectionService,
OrganizationServiceAbstraction,
AccountServiceAbstraction,
ConfigService,
],
}),
safeProvider({
provide: AuthRequestApiService,
useClass: DefaultAuthRequestApiService,
deps: [ApiServiceAbstraction, LogService],
deps: [CollectionService, OrganizationServiceAbstraction, AccountServiceAbstraction],
}),
safeProvider({
provide: LoginApprovalComponentServiceAbstraction,
@@ -1511,7 +1522,6 @@ const safeProviders: SafeProvider[] = [
StateProvider,
ApiServiceAbstraction,
OrganizationServiceAbstraction,
ConfigService,
AuthServiceAbstraction,
NotificationsService,
MessageListener,
@@ -1543,6 +1553,16 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: LogoutService,
useClass: DefaultLogoutService,
deps: [MessagingServiceAbstraction],
}),
safeProvider({
provide: DocumentLangSetter,
useClass: DocumentLangSetter,
deps: [DOCUMENT, I18nServiceAbstraction],
}),
safeProvider({
provide: CipherEncryptionService,
useClass: DefaultCipherEncryptionService,

View File

@@ -20,7 +20,6 @@ type BackgroundTypes = "danger" | "primary" | "success" | "warning";
@Component({
selector: "tools-password-strength",
templateUrl: "password-strength-v2.component.html",
standalone: true,
imports: [CommonModule, JslibModule, ProgressModule],
})
export class PasswordStrengthV2Component implements OnChanges {

View File

@@ -12,7 +12,6 @@ import {
combineLatest,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -25,6 +24,7 @@ import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
@Directive()

View File

@@ -20,5 +20,5 @@ export abstract class DeprecatedVaultFilterService {
buildCollapsedFilterNodes: () => Promise<Set<string>>;
storeCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
checkForSingleOrganizationPolicy: () => Promise<boolean>;
checkForPersonalOwnershipPolicy: () => Promise<boolean>;
checkForOrganizationDataOwnershipPolicy: () => Promise<boolean>;
}

View File

@@ -25,12 +25,14 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CipherService,
EncryptionContext,
} from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -82,7 +84,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
showCardNumber = false;
showCardCode = false;
cipherType = CipherType;
typeOptions: any[];
cardBrandOptions: any[];
cardExpMonthOptions: any[];
identityTitleOptions: any[];
@@ -102,7 +103,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected componentName = "";
protected destroy$ = new Subject<void>();
protected writeableCollections: CollectionView[];
private personalOwnershipPolicyAppliesToActiveUser: boolean;
private organizationDataOwnershipAppliesToUser: boolean;
private previousCipherId: string;
get fido2CredentialCreationDateValue(): string {
@@ -137,13 +138,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
) {
this.typeOptions = [
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
{ name: i18nService.t("typeCard"), value: CipherType.Card },
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
];
this.cardBrandOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "Visa", value: "Visa" },
@@ -201,10 +195,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
concatMap(async (policyAppliesToActiveUser) => {
this.personalOwnershipPolicyAppliesToActiveUser = policyAppliesToActiveUser;
this.organizationDataOwnershipAppliesToUser = policyAppliesToActiveUser;
await this.init();
}),
takeUntil(this.destroy$),
@@ -213,8 +207,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled();
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
ngOnDestroy() {
@@ -226,7 +218,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
if (this.ownershipOptions.length) {
this.ownershipOptions = [];
}
if (this.personalOwnershipPolicyAppliesToActiveUser) {
if (this.organizationDataOwnershipAppliesToUser) {
this.allowPersonal = false;
} else {
const myEmail = await firstValueFrom(
@@ -346,7 +338,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(
this.cipher,
[this.collectionId as CollectionId],
this.isAdminConsoleAction,
);
@@ -740,17 +731,17 @@ export class AddEditComponent implements OnInit, OnDestroy {
return this.cipherService.encrypt(this.cipher, userId);
}
protected saveCipher(cipher: Cipher) {
protected saveCipher(data: EncryptionContext) {
let orgAdmin = this.organization?.canEditAllCiphers;
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!cipher.collectionIds) {
if (!data.cipher.collectionIds) {
orgAdmin = this.organization?.canEditUnassignedCiphers;
}
return this.cipher.id == null
? this.cipherService.createWithServer(cipher, orgAdmin)
: this.cipherService.updateWithServer(cipher, orgAdmin);
? this.cipherService.createWithServer(data, orgAdmin)
: this.cipherService.updateWithServer(data, orgAdmin);
}
protected deleteCipher(userId: UserId) {

View File

@@ -7,7 +7,6 @@ import { I18nPipe } from "@bitwarden/ui-common";
@Component({
selector: "bit-spotlight",
templateUrl: "spotlight.component.html",
standalone: true,
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
})
export class SpotlightComponent {

View File

@@ -8,18 +8,22 @@ import {
combineLatest,
filter,
from,
map,
of,
shareReplay,
switchMap,
takeUntil,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
@Directive()
export class VaultItemsComponent implements OnInit, OnDestroy {
@@ -35,6 +39,19 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
organization: Organization;
CipherType = CipherType;
protected itemTypes$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedItemTypes) =>
// Filter out restricted item types
CIPHER_MENU_ITEMS.filter(
(itemType) =>
!restrictedItemTypes.some(
(restrictedType) => restrictedType.cipherType === itemType.type,
),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected searchPending = false;
/** Construct filters as an observable so it can be appended to the cipher stream. */
@@ -62,6 +79,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) {
this.subscribeToCiphers();
}
@@ -143,18 +161,22 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this._searchText$,
this._filter$,
of(userId),
this.restrictedItemTypesService.restricted$,
]),
),
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => {
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
let allCiphers = indexedCiphers ?? [];
const _failedCiphers = failedCiphers ?? [];
allCiphers = [..._failedCiphers, ...allCiphers];
const restrictedTypeFilter = (cipher: CipherView) =>
!this.restrictedItemTypesService.isCipherRestricted(cipher, restricted);
return this.searchService.searchCiphers(
userId,
searchText,
[filter, this.deletedFilter],
[filter, this.deletedFilter, restrictedTypeFilter],
allCiphers,
);
}),

View File

@@ -40,7 +40,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@@ -521,9 +521,7 @@ export class ViewComponent implements OnDestroy, OnInit {
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher);
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
if (this.cipher.folderId) {

View File

@@ -1,14 +1,18 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { catchError, switchMap } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricStateService } from "@bitwarden/key-management";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@@ -21,6 +25,9 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
private logService = inject(LogService);
private pinService = inject(PinServiceAbstraction);
private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private biometricStateService = inject(BiometricStateService);
private policyService = inject(PolicyService);
private organizationService = inject(OrganizationService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
@@ -36,16 +43,45 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
from(this.pinService.isPinSet(userId)),
from(this.vaultTimeoutSettingsService.isBiometricLockSet(userId)),
this.biometricStateService.biometricUnlockEnabled$,
this.organizationService.organizations$(userId),
this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId),
]).pipe(
map(([profileCreationDate, status, profileCutoff, isPinSet, isBiometricLockSet]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
const hideNudge = profileOlderThanCutoff || isPinSet || isBiometricLockSet;
return {
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
};
}),
switchMap(
async ([
profileCreationDate,
status,
profileCutoff,
isPinSet,
biometricUnlockEnabled,
organizations,
policies,
]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
const hasOrgWithRemovePinPolicyOn = organizations.some((org) => {
return policies.some(
(p) => p.type === PolicyType.RemoveUnlockWithPin && p.organizationId === org.id,
);
});
const hideNudge =
profileOlderThanCutoff ||
isPinSet ||
biometricUnlockEnabled ||
hasOrgWithRemovePinPolicyOn;
const acctSecurityNudgeStatus = {
hasBadgeDismissed: status.hasBadgeDismissed || hideNudge,
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
};
if (isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) {
await this.setNudgeStatus(nudgeType, acctSecurityNudgeStatus, userId);
}
return acctSecurityNudgeStatus;
},
),
);
}
}

View File

@@ -1,42 +0,0 @@
import { Injectable, inject } from "@angular/core";
import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@Injectable({ providedIn: "root" })
export class DownloadBitwardenNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Failed to load profile date:");
// Default to today to ensure the nudge is shown
return of(new Date());
}),
);
return combineLatest([
profileDate$,
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {
const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff;
return {
hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff,
hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff,
};
}),
);
}
}

View File

@@ -30,10 +30,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const filteredCiphers = ciphers?.filter((cipher) => {
return cipher.deletedDate == null;
});
const vaultHasContents = !(filteredCiphers == null || filteredCiphers.length === 0);
const vaultHasContents = !(ciphers == null || ciphers.length === 0);
if (orgs == null || orgs.length === 0) {
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
? of(nudgeStatus)
@@ -47,18 +44,22 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// Do not show nudge when
// user has previously dismissed nudge
// OR
// user belongs to an organization and cannot create collections || manage collections
if (
nudgeStatus.hasBadgeDismissed ||
nudgeStatus.hasSpotlightDismissed ||
hasManageCollections ||
canCreateCollections
) {
// When the user has dismissed the nudge or spotlight, return the nudge status directly
if (nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed) {
return of(nudgeStatus);
}
// When the user belongs to an organization and cannot create collections or manage collections,
// hide the nudge and spotlight
if (!hasManageCollections && !canCreateCollections) {
return of({
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
});
}
// Otherwise, return the nudge status based on the vault contents
return of({
hasSpotlightDismissed: vaultHasContents,
hasBadgeDismissed: vaultHasContents,

View File

@@ -1,6 +1,6 @@
export * from "./autofill-nudge.service";
export * from "./account-security-nudge.service";
export * from "./has-items-nudge.service";
export * from "./download-bitwarden-nudge.service";
export * from "./empty-vault-nudge.service";
export * from "./vault-settings-import-nudge.service";
export * from "./new-item-nudge.service";
export * from "./new-account-nudge.service";

View File

@@ -12,16 +12,16 @@ import { NudgeStatus, NudgeType } from "../nudges.service";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Custom Nudge Service to use for the Autofill Nudge in the Vault
* Custom Nudge Service to check if account is older than 30 days
*/
@Injectable({
providedIn: "root",
})
export class AutofillNudgeService extends DefaultSingleNudgeService {
export class NewAccountNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
nudgeStatus$(_: NudgeType, userId: UserId): Observable<NudgeStatus> {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
catchError(() => {
this.logService.error("Error getting profile creation date");
@@ -32,7 +32,7 @@ export class AutofillNudgeService extends DefaultSingleNudgeService {
return combineLatest([
profileDate$,
this.getNudgeStatus$(NudgeType.AutofillNudge, userId),
this.getNudgeStatus$(nudgeType, userId),
of(Date.now() - THIRTY_DAYS_MS),
]).pipe(
map(([profileCreationDate, status, profileCutoff]) => {

View File

@@ -0,0 +1,74 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, of, switchMap } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
/**
* Custom Nudge Service for the vault settings import badge.
*/
@Injectable({
providedIn: "root",
})
export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
organizationService = inject(OrganizationService);
collectionService = inject(CollectionService);
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1;
const { hasBadgeDismissed, hasSpotlightDismissed } = nudgeStatus;
// When the user has no organizations, return the nudge status directly
if ((orgs?.length ?? 0) === 0) {
return hasBadgeDismissed || hasSpotlightDismissed
? of(nudgeStatus)
: of({
hasSpotlightDismissed: vaultHasMoreThanOneItem,
hasBadgeDismissed: vaultHasMoreThanOneItem,
});
}
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly
if (hasBadgeDismissed || hasSpotlightDismissed) {
return of(nudgeStatus);
}
// When the user belongs to an organization and cannot create collections or manage collections,
// hide the nudge and spotlight
if (!hasManageCollections && !canCreateCollections) {
return of({
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
});
}
// Otherwise, return the nudge status based on the vault contents
return of({
hasSpotlightDismissed: vaultHasMoreThanOneItem,
hasBadgeDismissed: vaultHasMoreThanOneItem,
});
}),
);
}
}

View File

@@ -6,6 +6,8 @@ import { firstValueFrom, of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -13,13 +15,15 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { BiometricStateService } from "@bitwarden/key-management";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../libs/common/spec";
import {
HasItemsNudgeService,
EmptyVaultNudgeService,
DownloadBitwardenNudgeService,
NewAccountNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { NudgesService, NudgeType } from "./nudges.service";
@@ -33,7 +37,7 @@ describe("Vault Nudges Service", () => {
getFeatureFlag: jest.fn().mockReturnValue(true),
};
const nudgeServices = [EmptyVaultNudgeService, DownloadBitwardenNudgeService];
const nudgeServices = [EmptyVaultNudgeService, NewAccountNudgeService];
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
@@ -57,13 +61,17 @@ describe("Vault Nudges Service", () => {
useValue: mock<HasItemsNudgeService>(),
},
{
provide: DownloadBitwardenNudgeService,
useValue: mock<DownloadBitwardenNudgeService>(),
provide: NewAccountNudgeService,
useValue: mock<NewAccountNudgeService>(),
},
{
provide: EmptyVaultNudgeService,
useValue: mock<EmptyVaultNudgeService>(),
},
{
provide: VaultSettingsImportNudgeService,
useValue: mock<VaultSettingsImportNudgeService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
@@ -86,6 +94,18 @@ describe("Vault Nudges Service", () => {
provide: VaultTimeoutSettingsService,
useValue: mock<VaultTimeoutSettingsService>(),
},
{
provide: BiometricStateService,
useValue: mock<BiometricStateService>(),
},
{
provide: PolicyService,
useValue: mock<PolicyService>(),
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
},
],
});
});

View File

@@ -5,14 +5,15 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
NewAccountNudgeService,
HasItemsNudgeService,
EmptyVaultNudgeService,
AutofillNudgeService,
DownloadBitwardenNudgeService,
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
@@ -24,25 +25,23 @@ export type NudgeStatus = {
/**
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
*/
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum NudgeType {
/** Nudge to show when user has no items in their vault
* Add future nudges here
*/
EmptyVaultNudge = "empty-vault-nudge",
HasVaultItems = "has-vault-items",
AutofillNudge = "autofill-nudge",
AccountSecurity = "account-security",
DownloadBitwarden = "download-bitwarden",
NewLoginItemStatus = "new-login-item-status",
NewCardItemStatus = "new-card-item-status",
NewIdentityItemStatus = "new-identity-item-status",
NewNoteItemStatus = "new-note-item-status",
NewSshItemStatus = "new-ssh-item-status",
GeneratorNudgeStatus = "generator-nudge-status",
SendNudgeStatus = "send-nudge-status",
}
export const NudgeType = {
/** Nudge to show when user has no items in their vault */
EmptyVaultNudge: "empty-vault-nudge",
VaultSettingsImportNudge: "vault-settings-import-nudge",
HasVaultItems: "has-vault-items",
AutofillNudge: "autofill-nudge",
AccountSecurity: "account-security",
DownloadBitwarden: "download-bitwarden",
NewLoginItemStatus: "new-login-item-status",
NewCardItemStatus: "new-card-item-status",
NewIdentityItemStatus: "new-identity-item-status",
NewNoteItemStatus: "new-note-item-status",
NewSshItemStatus: "new-ssh-item-status",
GeneratorNudgeStatus: "generator-nudge-status",
} as const;
export type NudgeType = UnionOfValues<typeof NudgeType>;
export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
Partial<Record<NudgeType, NudgeStatus>>
@@ -56,6 +55,7 @@ export const NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
})
export class NudgesService {
private newItemNudgeService = inject(NewItemNudgeService);
private newAcctNudgeService = inject(NewAccountNudgeService);
/**
* Custom nudge services to use for specific nudge types
@@ -65,9 +65,11 @@ export class NudgesService {
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
[NudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[NudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[NudgeType.VaultSettingsImportNudge]: inject(VaultSettingsImportNudgeService),
[NudgeType.AccountSecurity]: inject(AccountSecurityNudgeService),
[NudgeType.AutofillNudge]: inject(AutofillNudgeService),
[NudgeType.DownloadBitwarden]: inject(DownloadBitwardenNudgeService),
[NudgeType.AutofillNudge]: this.newAcctNudgeService,
[NudgeType.DownloadBitwarden]: this.newAcctNudgeService,
[NudgeType.GeneratorNudgeStatus]: this.newAcctNudgeService,
[NudgeType.NewLoginItemStatus]: this.newItemNudgeService,
[NudgeType.NewCardItemStatus]: this.newItemNudgeService,
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
@@ -157,7 +159,11 @@ export class NudgesService {
*/
hasActiveBadges$(userId: UserId): Observable<boolean> {
// Add more nudge types here if they have the settings badge feature
const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden];
const nudgeTypes = [
NudgeType.EmptyVaultNudge,
NudgeType.DownloadBitwarden,
NudgeType.AutofillNudge,
];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
return this.getNudgeService(nudge)

View File

@@ -15,7 +15,7 @@ export class OrganizationFilterComponent {
@Input() collapsedFilterNodes: Set<string>;
@Input() organizations: Organization[];
@Input() activeFilter: VaultFilter;
@Input() activePersonalOwnershipPolicy: boolean;
@Input() activeOrganizationDataOwnership: boolean;
@Input() activeSingleOrganizationPolicy: boolean;
@Output() onNodeCollapseStateChange: EventEmitter<ITreeNodeObject> =
@@ -26,12 +26,12 @@ export class OrganizationFilterComponent {
let displayMode: DisplayMode = "organizationMember";
if (this.organizations == null || this.organizations.length < 1) {
displayMode = "noOrganizations";
} else if (this.activePersonalOwnershipPolicy && !this.activeSingleOrganizationPolicy) {
displayMode = "personalOwnershipPolicy";
} else if (!this.activePersonalOwnershipPolicy && this.activeSingleOrganizationPolicy) {
} else if (this.activeOrganizationDataOwnership && !this.activeSingleOrganizationPolicy) {
displayMode = "organizationDataOwnershipPolicy";
} else if (!this.activeOrganizationDataOwnership && this.activeSingleOrganizationPolicy) {
displayMode = "singleOrganizationPolicy";
} else if (this.activePersonalOwnershipPolicy && this.activeSingleOrganizationPolicy) {
displayMode = "singleOrganizationAndPersonalOwnershipPolicies";
} else if (this.activeOrganizationDataOwnership && this.activeSingleOrganizationPolicy) {
displayMode = "singleOrganizationAndOrganizatonDataOwnershipPolicies";
}
return displayMode;

View File

@@ -32,7 +32,7 @@ export class VaultFilterComponent implements OnInit {
isLoaded = false;
collapsedFilterNodes: Set<string>;
organizations: Organization[];
activePersonalOwnershipPolicy: boolean;
activeOrganizationDataOwnershipPolicy: boolean;
activeSingleOrganizationPolicy: boolean;
collections: DynamicTreeNode<CollectionView>;
folders$: Observable<DynamicTreeNode<FolderView>>;
@@ -47,8 +47,8 @@ export class VaultFilterComponent implements OnInit {
this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes();
this.organizations = await this.vaultFilterService.buildOrganizations();
if (this.organizations != null && this.organizations.length > 0) {
this.activePersonalOwnershipPolicy =
await this.vaultFilterService.checkForPersonalOwnershipPolicy();
this.activeOrganizationDataOwnershipPolicy =
await this.vaultFilterService.checkForOrganizationDataOwnershipPolicy();
this.activeSingleOrganizationPolicy =
await this.vaultFilterService.checkForSingleOrganizationPolicy();
}
@@ -88,8 +88,8 @@ export class VaultFilterComponent implements OnInit {
async reloadOrganizations() {
this.organizations = await this.vaultFilterService.buildOrganizations();
this.activePersonalOwnershipPolicy =
await this.vaultFilterService.checkForPersonalOwnershipPolicy();
this.activeOrganizationDataOwnershipPolicy =
await this.vaultFilterService.checkForOrganizationDataOwnershipPolicy();
this.activeSingleOrganizationPolicy =
await this.vaultFilterService.checkForSingleOrganizationPolicy();
}

View File

@@ -2,5 +2,5 @@ export type DisplayMode =
| "noOrganizations"
| "organizationMember"
| "singleOrganizationPolicy"
| "personalOwnershipPolicy"
| "singleOrganizationAndPersonalOwnershipPolicies";
| "organizationDataOwnershipPolicy"
| "singleOrganizationAndOrganizatonDataOwnershipPolicies";

View File

@@ -123,12 +123,12 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
);
}
async checkForPersonalOwnershipPolicy(): Promise<boolean> {
async checkForOrganizationDataOwnershipPolicy(): Promise<boolean> {
return await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
),
);

View File

@@ -1,27 +1,5 @@
{
"extends": "../shared/tsconfig",
"compilerOptions": {
"paths": {
"@bitwarden/admin-console/common": ["../admin-console/src/common"],
"@bitwarden/angular/*": ["../angular/src/*"],
"@bitwarden/auth/angular": ["../auth/src/angular"],
"@bitwarden/auth/common": ["../auth/src/common"],
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/components": ["../components/src"],
"@bitwarden/generator-components": ["../tools/generator/components/src"],
"@bitwarden/generator-core": ["../tools/generator/core/src"],
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/importer/core": ["../importer/src"],
"@bitwarden/importer-ui": ["../importer/src/components"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"],
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault": ["../vault/src"]
}
},
"extends": "../../tsconfig.base",
"include": ["src", "spec"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"emitDecoratorMetadata": false
},
"files": ["./test.setup.ts"]
}

View File

@@ -1,6 +1,6 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../shared/tsconfig.spec");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
@@ -8,13 +8,12 @@ const sharedConfig = require("../../libs/shared/jest.config.angular");
module.exports = {
...sharedConfig,
displayName: "libs/auth tests",
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in tests
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
prefix: "<rootDir>/../../",
},
),
};

View File

@@ -22,7 +22,6 @@ import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
@Component({
standalone: true,
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe],
@@ -72,8 +71,11 @@ export class ChangePasswordComponent implements OnInit {
throw new Error("activeAccount not found");
}
if (passwordInputResult.currentPassword == null) {
throw new Error("currentPassword not found");
if (
passwordInputResult.currentPassword == null ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("currentPassword or newPasswordHint not found");
}
await this.syncService.fullSync(true);

View File

@@ -116,7 +116,7 @@ describe("DefaultChangePasswordService", () => {
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
"invalid PasswordInputResult credentials, could not change password",
);
});
@@ -130,7 +130,7 @@ describe("DefaultChangePasswordService", () => {
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
"invalid PasswordInputResult credentials, could not change password",
);
});

View File

@@ -26,8 +26,14 @@ export class DefaultChangePasswordService implements ChangePasswordService {
if (!userId) {
throw new Error("userId not found");
}
if (!passwordInputResult.currentMasterKey || !passwordInputResult.currentServerMasterKeyHash) {
throw new Error("currentMasterKey or currentServerMasterKeyHash not found");
if (
!passwordInputResult.currentMasterKey ||
!passwordInputResult.currentServerMasterKeyHash ||
!passwordInputResult.newMasterKey ||
!passwordInputResult.newServerMasterKeyHash ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("invalid PasswordInputResult credentials, could not change password");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(

View File

@@ -11,7 +11,6 @@ export type FingerprintDialogData = {
@Component({
templateUrl: "fingerprint-dialog.component.html",
standalone: true,
imports: [JslibModule, ButtonModule, DialogModule],
})
export class FingerprintDialogComponent {

View File

@@ -1,8 +1,4 @@
export * from "./bitwarden-logo.icon";
export * from "./bitwarden-shield.icon";
export * from "./devices.icon";
export * from "./lock.icon";
export * from "./registration-check-email.icon";
export * from "./user-lock.icon";
export * from "./user-verification-biometrics-fingerprint.icon";
export * from "./wave.icon";

View File

@@ -1,13 +1,6 @@
/**
* This barrel file should only contain Angular exports
*/
// anon layout
export * from "./anon-layout/anon-layout.component";
export * from "./anon-layout/anon-layout-wrapper.component";
export * from "./anon-layout/anon-layout-wrapper-data.service";
export * from "./anon-layout/default-anon-layout-wrapper-data.service";
// change password
export * from "./change-password/change-password.component";
export * from "./change-password/change-password.service.abstraction";

View File

@@ -1,6 +1,11 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<auth-password-callout
*ngIf="masterPasswordPolicyOptions"
[message]="
flow === InputPasswordFlow.ChangePasswordDelegation
? 'changePasswordDelegationMasterPasswordPolicyInEffect'
: 'masterPasswordPolicyInEffect'
"
[policy]="masterPasswordPolicyOptions"
></auth-password-callout>
@@ -35,6 +40,22 @@
type="password"
formControlName="newPassword"
/>
<button
*ngIf="flow === InputPasswordFlow.ChangePasswordDelegation"
type="button"
bitIconButton="bwi-generate"
bitSuffix
[appA11yTitle]="'generatePassword' | i18n"
(click)="generatePassword()"
></button>
<button
*ngIf="flow === InputPasswordFlow.ChangePasswordDelegation"
type="button"
bitSuffix
bitIconButton="bwi-clone"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy()"
></button>
<button
type="button"
bitIconButton
@@ -42,7 +63,7 @@
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<bit-hint>
<bit-hint *ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation">
<span class="tw-font-bold">{{ "important" | i18n }} </span>
{{ "masterPassImportant" | i18n }}
{{ minPasswordLengthMsg }}.
@@ -74,26 +95,32 @@
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input id="input-password-form_new-password-hint" bitInput formControlName="newPasswordHint" />
<bit-hint>
{{
"masterPassHintText"
| i18n: formGroup.value.newPasswordHint.length : maxHintLength.toString()
}}
</bit-hint>
</bit-form-field>
<ng-container *ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation">
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input
id="input-password-form_new-password-hint"
bitInput
formControlName="newPasswordHint"
/>
<bit-hint>
{{
"masterPassHintText"
| i18n: formGroup.value.newPasswordHint.length.toString() : maxHintLength.toString()
}}
</bit-hint>
</bit-form-field>
<bit-form-control>
<input
id="input-password-form_check-for-breaches"
type="checkbox"
bitCheckbox
formControlName="checkForBreaches"
/>
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
id="input-password-form_check-for-breaches"
type="checkbox"
bitCheckbox
formControlName="checkForBreaches"
/>
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
</ng-container>
<bit-form-control *ngIf="flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation">
<input
@@ -116,7 +143,11 @@
</bit-label>
</bit-form-control>
<div class="tw-flex tw-gap-2" [ngClass]="inlineButtons ? 'tw-flex-row' : 'tw-flex-col'">
<div
*ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation"
class="tw-flex tw-gap-2"
[ngClass]="inlineButtons ? 'tw-flex-row' : 'tw-flex-col'"
>
<button type="submit" bitButton bitFormButton buttonType="primary" [loading]="loading">
{{ primaryButtonTextStr || ("setMasterPassword" | i18n) }}
</button>

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from "@angular/forms";
import { firstValueFrom } from "rxjs";
@@ -13,6 +13,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
@@ -30,6 +31,7 @@ import {
ToastService,
Translation,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
DEFAULT_KDF_CONFIG,
KdfConfig,
@@ -53,36 +55,50 @@ import { PasswordInputResult } from "./password-input-result";
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum InputPasswordFlow {
/**
* Form elements displayed:
* - [Input] New password
* - [Input] New password confirm
* - [Input] New password hint
* - [Checkbox] Check for breaches
* Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*
* Note: this flow does not receive an active account `userId` as an `@Input`
*/
SetInitialPasswordAccountRegistration,
/**
* Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*/
AccountRegistration, // important: this flow does not involve an activeAccount/userId
SetInitialPasswordAuthedUser,
/*
* All form elements above, plus: [Input] Current password (as the first element in the UI)
/**
* Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*/
ChangePassword,
/**
* All form elements above, plus: [Checkbox] Rotate account encryption key (as the last element in the UI)
* Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches, rotateUserKey]`
*/
ChangePasswordWithOptionalUserKeyRotation,
/**
* This flow is used when a user changes the password for another user's account, such as:
* - Emergency Access Takeover
* - Account Recovery
*
* Since both of those processes use a dialog, the `InputPasswordComponent` will not display
* buttons for `ChangePasswordDelegation` because the dialog will have its own buttons.
*
* Form Fields: `[newPassword, newPasswordConfirm]`
*
* Note: this flow does not receive an active account `userId` or `email` as `@Input`s
*/
ChangePasswordDelegation,
}
interface InputPasswordForm {
currentPassword?: FormControl<string>;
newPassword: FormControl<string>;
newPasswordConfirm: FormControl<string>;
newPasswordHint: FormControl<string>;
checkForBreaches: FormControl<boolean>;
newPasswordHint?: FormControl<string>;
currentPassword?: FormControl<string>;
checkForBreaches?: FormControl<boolean>;
rotateUserKey?: FormControl<boolean>;
}
@Component({
standalone: true,
selector: "auth-input-password",
templateUrl: "./input-password.component.html",
imports: [
@@ -92,20 +108,25 @@ interface InputPasswordForm {
FormFieldModule,
IconButtonModule,
InputModule,
ReactiveFormsModule,
SharedModule,
JslibModule,
PasswordCalloutComponent,
PasswordStrengthV2Component,
JslibModule,
ReactiveFormsModule,
SharedModule,
],
})
export class InputPasswordComponent implements OnInit {
@ViewChild(PasswordStrengthV2Component) passwordStrengthComponent:
| PasswordStrengthV2Component
| undefined = undefined;
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Output() onSecondaryButtonClick = new EventEmitter<void>();
@Output() isSubmitting = new EventEmitter<boolean>();
@Input({ required: true }) flow!: InputPasswordFlow;
@Input({ required: true, transform: (val: string) => val.trim().toLowerCase() }) email!: string;
@Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string;
@Input() userId?: UserId;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@@ -133,11 +154,6 @@ export class InputPasswordComponent implements OnInit {
Validators.minLength(this.minPasswordLength),
]),
newPasswordConfirm: this.formBuilder.nonNullable.control("", Validators.required),
newPasswordHint: this.formBuilder.nonNullable.control("", [
Validators.minLength(this.minHintLength),
Validators.maxLength(this.maxHintLength),
]),
checkForBreaches: this.formBuilder.nonNullable.control(true),
},
{
validators: [
@@ -147,12 +163,6 @@ export class InputPasswordComponent implements OnInit {
"newPasswordConfirm",
this.i18nService.t("masterPassDoesntMatch"),
),
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
],
},
);
@@ -177,9 +187,11 @@ export class InputPasswordComponent implements OnInit {
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
ngOnInit(): void {
@@ -188,6 +200,27 @@ export class InputPasswordComponent implements OnInit {
}
private addFormFieldsIfNecessary() {
if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) {
this.formGroup.addControl(
"newPasswordHint",
this.formBuilder.nonNullable.control("", [
Validators.minLength(this.minHintLength),
Validators.maxLength(this.maxHintLength),
]),
);
this.formGroup.addValidators([
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
]);
this.formGroup.addControl("checkForBreaches", this.formBuilder.nonNullable.control(true));
}
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
@@ -228,160 +261,204 @@ export class InputPasswordComponent implements OnInit {
}
}
protected submit = async () => {
this.verifyFlowAndUserId();
submit = async (): Promise<PasswordInputResult | undefined> => {
try {
this.isSubmitting.emit(true);
this.formGroup.markAllAsTouched();
this.verifyFlow();
if (this.formGroup.invalid) {
this.showErrorSummary = true;
return;
}
this.formGroup.markAllAsTouched();
if (!this.email) {
throw new Error("Email is required to create master key.");
}
const currentPassword = this.formGroup.controls.currentPassword?.value ?? "";
const newPassword = this.formGroup.controls.newPassword.value;
const newPasswordHint = this.formGroup.controls.newPasswordHint.value;
const checkForBreaches = this.formGroup.controls.checkForBreaches.value;
// 1. Determine kdfConfig
if (this.flow === InputPasswordFlow.AccountRegistration) {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
}
// 2. Verify current password is correct (if necessary)
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentPasswordVerified = await this.verifyCurrentPassword(
currentPassword,
this.kdfConfig,
);
if (!currentPasswordVerified) {
if (this.formGroup.invalid) {
this.showErrorSummary = true;
return;
}
const currentPassword = this.formGroup.controls.currentPassword?.value ?? "";
const newPassword = this.formGroup.controls.newPassword.value;
const newPasswordHint = this.formGroup.controls.newPasswordHint?.value ?? "";
const checkForBreaches = this.formGroup.controls.checkForBreaches?.value ?? true;
if (this.flow === InputPasswordFlow.ChangePasswordDelegation) {
return await this.handleChangePasswordDelegationFlow(newPassword);
}
if (!this.email) {
throw new Error("Email is required to create master key.");
}
// 1. Determine kdfConfig
if (this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration) {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
}
// 2. Verify current password is correct (if necessary)
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentPasswordVerified = await this.verifyCurrentPassword(
currentPassword,
this.kdfConfig,
);
if (!currentPasswordVerified) {
return;
}
}
// 3. Verify new password
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.passwordStrengthScore,
checkForBreaches,
);
if (!newPasswordVerified) {
return;
}
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,
this.email,
this.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
this.kdfConfig,
);
const currentServerMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.ServerAuthorization,
);
const currentLocalMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.currentPassword = currentPassword;
passwordInputResult.currentMasterKey = currentMasterKey;
passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash;
passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit cryptographic keys and other password related properties
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
} catch (e) {
this.validationService.showError(e);
} finally {
this.isSubmitting.emit(false);
}
};
/**
* We cannot mark the `userId` or `email` `@Input`s as required because some flows
* require them, and some do not. This method enforces that:
* - Certain flows MUST have a `userId` and/or `email` passed down
* - Certain flows must NOT have a `userId` and/or `email` passed down
*/
private verifyFlow() {
/** UserId checks */
// These flows require that an active account userId must NOT be passed down
if (
this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration ||
this.flow === InputPasswordFlow.ChangePasswordDelegation
) {
if (this.userId) {
throw new Error("There should be no active account userId passed down in a this flow.");
}
}
// 3. Verify new password
// All other flows require that an active account userId MUST be passed down
if (
this.flow !== InputPasswordFlow.SetInitialPasswordAccountRegistration &&
this.flow !== InputPasswordFlow.ChangePasswordDelegation
) {
if (!this.userId) {
throw new Error("This flow requires that an active account userId be passed down.");
}
}
/** Email checks */
// This flow requires that an email must NOT be passed down
if (this.flow === InputPasswordFlow.ChangePasswordDelegation) {
if (this.email) {
throw new Error("There should be no email passed down in this flow.");
}
}
// All other flows require that an email MUST be passed down
if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) {
if (!this.email) {
throw new Error("This flow requires that an email be passed down.");
}
}
}
private async handleChangePasswordDelegationFlow(
newPassword: string,
): Promise<PasswordInputResult | undefined> {
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.passwordStrengthScore,
checkForBreaches,
false,
);
if (!newPasswordVerified) {
return;
}
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,
this.email,
this.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
this.kdfConfig,
);
const currentServerMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.ServerAuthorization,
);
const currentLocalMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.currentPassword = currentPassword;
passwordInputResult.currentMasterKey = currentMasterKey;
passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash;
passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit cryptographic keys and other password related properties
this.onPasswordFormSubmit.emit(passwordInputResult);
};
/**
* This method prevents a dev from passing down the wrong `InputPasswordFlow`
* from the parent component or from failing to pass down a `userId` for flows
* that require it.
*
* We cannot mark the `userId` `@Input` as required because in an account registration
* flow we will not have an active account `userId` to pass down.
*/
private verifyFlowAndUserId() {
/**
* There can be no active account (and thus no userId) in an account registration
* flow. If there is a userId, it means the dev passed down the wrong InputPasswordFlow
* from the parent component.
*/
if (this.flow === InputPasswordFlow.AccountRegistration) {
if (this.userId) {
throw new Error(
"There can be no userId in an account registration flow. Please pass down the appropriate InputPasswordFlow from the parent component.",
);
}
}
/**
* There MUST be an active account (and thus a userId) in all other flows.
* If no userId is passed down, it means the dev either:
* (a) passed down the wrong InputPasswordFlow, or
* (b) passed down the correct InputPasswordFlow but failed to pass down a userId
*/
if (this.flow !== InputPasswordFlow.AccountRegistration) {
if (!this.userId) {
throw new Error("The selected InputPasswordFlow requires that a userId be passed down");
}
}
return passwordInputResult;
}
/**
@@ -392,16 +469,19 @@ export class InputPasswordComponent implements OnInit {
currentPassword: string,
kdfConfig: KdfConfig,
): Promise<boolean> {
if (!this.email) {
throw new Error("Email is required to verify current password.");
}
if (!this.userId) {
throw new Error("userId is required to verify current password.");
}
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
kdfConfig,
);
if (!this.userId) {
throw new Error("userId not passed down");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
this.userId,
@@ -550,4 +630,33 @@ export class InputPasswordComponent implements OnInit {
protected getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
protected async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.formGroup.patchValue({
newPassword: await this.passwordGenerationService.generatePassword(options),
});
if (!this.passwordStrengthComponent) {
throw new Error("PasswordStrengthComponent is not initialized");
}
this.passwordStrengthComponent.updatePasswordStrength(
this.formGroup.controls.newPassword.value,
);
}
protected copy() {
const value = this.formGroup.value.newPassword;
if (value == null) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
title: "",
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
});
}
}

View File

@@ -6,14 +6,20 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials. On form
submission, the component creates cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.) and emits those properties to the parent (along with the other
values defined in `PasswordInputResult`).
The `InputPasswordComponent` allows a user to enter master password related credentials.
Specifically, it does the following:
The component is intended for re-use in different scenarios throughout the application. Therefore it
is mostly presentational and simply emits values rather than acting on them itself. It is the job of
the parent component to act on those values as needed.
1. Displays form fields in the UI
2. Validates form fields
3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`,
`newServerMasterKeyHash`, etc.)
4. Emits the generated properties to the parent component
The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our
form UI and validation logic consistent. As such, it is intended for re-use in different set/change
password scenarios throughout the Bitwarden application. It is mostly presentational and simply
emits values rather than acting on them itself. It is the job of the parent component to act on
those values as needed.
<br />
@@ -22,11 +28,13 @@ the parent component to act on those values as needed.
- [@Inputs](#inputs)
- [@Outputs](#outputs)
- [The InputPasswordFlow](#the-inputpasswordflow)
- [Use Cases](#use-cases)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between AccountRegistration and SetInitialPasswordAuthedUser](#difference-between-accountregistration-and-setinitialpasswordautheduser)
- [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
- [Submitting From a Parent Dialog Component](#submitting-from-a-parent-dialog-component)
- [Example](#example)
<br />
@@ -37,12 +45,24 @@ the parent component to act on those values as needed.
- `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine
which form input elements will be displayed in the UI and which cryptographic keys will be created
and emitted.
- `email` - the parent component must provide an email so that the `InputPasswordComponent` can
create a master key.
and emitted. [Click here](#the-inputpasswordflow) to learn more about the different
`InputPasswordFlow` options.
**Optional (sometimes)**
These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs`
are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that
the `email` and/or `userId` is present in certain flows, while not present in other flows.
- `email` - allows the `InputPasswordComponent` to generate a master key
- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`,
verify that a current password is correct, and perform validation prior to user key rotation on
the parent
**Optional**
These `@Inputs` are truly optional.
- `loading` - a boolean used to indicate that the parent component is performing some
long-running/async operation and that the form should be disabled until the operation is complete.
The primary button will also show a spinner if `loading` is true.
@@ -57,6 +77,7 @@ the parent component to act on those values as needed.
## `@Output()`'s
- `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object
([see more below](#submit-logic)).
- `onSecondaryButtonClick` - on click, emits a notice that the secondary button has been clicked.
The parent component can listen for this event and take some custom action as needed (go back,
cancel, logout, etc.)
@@ -66,79 +87,100 @@ the parent component to act on those values as needed.
## The `InputPasswordFlow`
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component.
credential generation logic of the component. It is important for the dev to understand when to use
each flow.
<br />
### Use Cases
**`SetInitialPasswordAccountRegistration`**
Used in scenarios where we have no existing user, and thus NO active account `userId`:
- Standard Account Registration
- Email Invite Account Registration
- Trial Initiation Account registration<br /><br />
**`SetInitialPasswordAuthedUser`**
Used in scenarios where we do have an existing and authed user, and thus an active account `userId`:
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This is
because at the time they set their initial password, their account already exists in the
database (before setting their password) and they have already authenticated via SSO.
- The same is not true in the account registration flows above&mdash;that is, during account
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their
initial password, their account does not yet exist in the database, and will only be created
once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must now
have/set their initial password<br /><br />
**`ChangePassword`**
Used in scenarios where we simply want to offer the user the ability to change their password:
- User clicks an org email invite link an logs in with their password which does not meet the org's
policy requirements
- User logs in with password that does not meet the org's policy requirements
- User logs in after their password was reset via Account Recovery (and now they must change their
password)<br /><br />
**`ChangePasswordWithOptionalUserKeyRotation`**
Used in scenarios where we want to offer users the additional option of rotating their user key:
- Account Settings (Web) - change password screen
Note that the user key rotation itself does not happen on the `InputPasswordComponent`, but rather
on the parent component. The `InputPasswordComponent` simply emits a boolean value that indicates
whether or not the user key should be rotated.<br /><br />
**`ChangePasswordDelegation`**
Used in scenarios where one user changes the password for another user's account:
- Emergency Access Takeover
- Account Recovery<br /><br />
### HTML - Form Fields
The `InputPasswordFlow` determines which form fields get displayed in the UI.
**`InputPasswordFlow.AccountRegistration`** and **`InputPasswordFlow.SetInitialPasswordAuthedUser`**
- Input: New password
- Input: Confirm new password
- Input: Hint
- Checkbox: Check for breaches
**`InputPasswordFlow.ChangePassword`**
Includes everything above, plus:
- Input: Current password (as the first element in the UI)
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**
Includes everything above, plus:
- Checkbox: Rotate account encryption key (as the last element in the UI)
Click through the individual Stories in Storybook to see how the `InputPassswordFlow` determines
which form field UI elements get displayed.
<br />
### TypeScript - Credential Generation
- The `AccountRegistration` and `SetInitialPasswordAuthedUser` flows involve a user setting their
password for the first time. Therefore on submit the component will only generate new credentials
(`newMasterKey`) and not current credentials (`currentMasterKey`).
- The `ChangePassword` and `ChangePasswordWithOptionalUserKeyRotation` flows both require the user
to enter a current password along with a new password. Therefore on submit the component will
generate current credentials (`currentMasterKey`) along with new credentials (`newMasterKey`).
- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`**
- These flows involve a user setting their password for the first time. Therefore on submit the
component will only generate new credentials (`newMasterKey`) and not current credentials
(`currentMasterKey`).<br /><br />
- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`**
- These flows both require the user to enter a current password along with a new password.
Therefore on submit the component will generate current credentials (`currentMasterKey`) along
with new credentials (`newMasterKey`).<br /><br />
- **`ChangePasswordDelegation`**
- This flow does not generate any credentials, but simply validates the new password and emits it
up to the parent.
<br />
### Difference between `AccountRegistration` and `SetInitialPasswordAuthedUser`
### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser`
These two flows are similar in that they display the same form fields and only generate new
credentials, but we need to keep them separate for the following reasons:
- `AccountRegistration` involves scenarios where we have no existing user, and **thus NO active
account `userId`**:
- Standard Account Registration
- Email Invite Account Registration
- Trial Initiation Account Registration
<br />
- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and
**thus NO active account `userId`**:
- `SetInitialPasswordAuthedUser` involves scenarios where we do have an existing and authed user,
and **thus an active account `userId`**:
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This
is because at the time they set their initial password, their account already exists in the
database (before setting their password) and they have already authenticated via SSO.
- The same is not true in the account registration flows above&mdash;that is, during account
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set
their initial password, their account does not yet exist in the database, and will only be
created once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must
now have/set their initial password
The presence or absence of an active account `userId` is important because it determines how we get
the correct `kdfConfig` prior to key generation:
@@ -148,12 +190,12 @@ the correct `kdfConfig` prior to key generation:
`userId`
That said, we cannot mark the `userId` as a required via `@Input({ required: true })` because
`AccountRegistration` flows will not have a `userId`. But we still want to require a `userId` in a
`SetInitialPasswordAuthedUser` flow. Therefore the `InputPasswordComponent` has init logic that
ensures the following:
`SetInitialPasswordAccountRegistration` flows will not have a `userId`. But we still want to require
a `userId` in a `SetInitialPasswordAuthedUser` flow. Therefore the `InputPasswordComponent` has init
logic that ensures the following:
- If the passed down flow is `AccountRegistration`, require that the parent **MUST NOT** have passed
down a `userId`
- If the passed down flow is `SetInitialPasswordAccountRegistration`, require that the parent **MUST
NOT** have passed down a `userId`
- If the passed down flow is `SetInitialPasswordAuthedUser` require that the parent must also have
passed down a `userId`
@@ -169,11 +211,6 @@ Form validators ensure that:
- The new password and confirmed new password are the same
- The new password and password hint are NOT the same
Additional submit logic validation ensures that:
- The new password adheres to any enforced master password policy options (that were passed down
from the parent)
<br />
## Submit Logic
@@ -182,9 +219,10 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
1. Verifies inputs:
- Checks that the current password is correct (if it was required in the flow)
- Checks if the new password is found in a breach and warns the user if so (if the user selected
the checkbox)
- Checks that the new password meets any master password policy requirements enforced by an org
- Checks that the new password is not weak or found in any breaches (if the user selected the
checkbox)
- Checks that the new password adheres to any enforced master password policies that were
optionally passed down by the parent
2. Uses the form inputs to create cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.)
3. Emits those cryptographic properties up to the parent (along with other values defined in
@@ -192,23 +230,83 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
```typescript
export interface PasswordInputResult {
// Properties starting with "current..." are included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig: KdfConfig;
rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation
kdfConfig?: KdfConfig;
rotateUserKey?: boolean;
}
```
## Submitting From a Parent Dialog Component
Some of our set/change password flows use dialogs, such as Emergency Access Takeover and Account
Recovery. These are covered by the `ChangePasswordDelegation` flow. Because dialogs have their own
buttons, we don't want to display an additional Submit button in the `InputPasswordComponent` when
embedded in a dialog.
Therefore we do the following:
- The `InputPasswordComponent` hides the button in the UI and exposes its `submit()` method as a
public method.
- The parent dialog component can then access this method via `@ViewChild()`.
- When the user clicks the primary button on the parent dialog, we call the `submit()` method on the
`InputPasswordComponent`.
```html
<!-- emergency-access-takeover-dialog.component.html -->
<bit-dialog dialogSize="large">
<span bitDialogTitle><!-- ... --></span>
<div bitDialogContent>
<auth-input-password
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</div>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="handlePrimaryButtonClick()">
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
```
```typescript
// emergency-access-takeover-dialog.component.ts
export class EmergencyAccessTakeoverDialogComponent implements OnInit {
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent;
// ...
handlePrimaryButtonClick = async () => {
await this.inputPasswordComponent.submit();
};
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
// ... run logic that handles the `PasswordInputResult` object emission
}
}
```
<br />
# Example
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**

View File

@@ -11,12 +11,14 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `/apps` import from `/libs`
@@ -73,6 +75,7 @@ export default {
provide: PlatformUtilsService,
useValue: {
launchUri: () => Promise.resolve(true),
copyToClipboard: () => true,
},
},
{
@@ -125,16 +128,31 @@ export default {
showToast: action("ToastService.showToast"),
} as Partial<ToastService>,
},
{
provide: PasswordGenerationServiceAbstraction,
useValue: {
getOptions: () => ({}),
generatePassword: () => "generated-password",
},
},
{
provide: ValidationService,
useValue: {
showError: () => ["validation error"],
},
},
],
}),
],
args: {
InputPasswordFlow: {
AccountRegistration: InputPasswordFlow.AccountRegistration,
SetInitialPasswordAccountRegistration:
InputPasswordFlow.SetInitialPasswordAccountRegistration,
SetInitialPasswordAuthedUser: InputPasswordFlow.SetInitialPasswordAuthedUser,
ChangePassword: InputPasswordFlow.ChangePassword,
ChangePasswordWithOptionalUserKeyRotation:
InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation,
ChangePasswordDelegation: InputPasswordFlow.ChangePasswordDelegation,
},
userId: "1" as UserId,
email: "user@email.com",
@@ -154,12 +172,12 @@ export default {
type Story = StoryObj<InputPasswordComponent>;
export const AccountRegistration: Story = {
export const SetInitialPasswordAccountRegistration: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
></auth-input-password>
`,
@@ -205,14 +223,24 @@ export const ChangePasswordWithOptionalUserKeyRotation: Story = {
}),
};
export const ChangePasswordDelegation: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [flow]="InputPasswordFlow.ChangePasswordDelegation"></auth-input-password>
<br />
<div>Note: no buttons here as this flow is expected to be used in a dialog, which will have its own buttons</div>
`,
}),
};
export const WithPolicies: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.SetInitialPasswordAuthedUser"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[userId]="userId"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
></auth-input-password>
`,
@@ -224,7 +252,7 @@ export const SecondaryButton: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
@@ -238,7 +266,7 @@ export const SecondaryButtonWithPlaceHolderText: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'backTo', placeholders: ['homepage'] }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
@@ -252,7 +280,7 @@ export const InlineButton: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[inlineButtons]="true"
></auth-input-password>
@@ -265,7 +293,7 @@ export const InlineButtons: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
[inlineButtons]="true"

View File

@@ -8,11 +8,11 @@ export interface PasswordInputResult {
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig: KdfConfig;
kdfConfig?: KdfConfig;
rotateUserKey?: boolean;
}

View File

@@ -40,7 +40,6 @@ export interface LoginApprovalDialogParams {
@Component({
selector: "login-approval",
templateUrl: "login-approval.component.html",
standalone: true,
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
})
export class LoginApprovalComponent implements OnInit, OnDestroy {
@@ -101,6 +100,8 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
this.updateTimeText();
}, RequestTimeUpdate);
// 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.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
this.loading = false;

View File

@@ -10,6 +10,7 @@ import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginEmailServiceAbstraction,
LogoutService,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
@@ -29,6 +30,7 @@ import { UserId } from "@bitwarden/common/types/guid";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
AsyncActionsModule,
ButtonModule,
CheckboxModule,
@@ -39,8 +41,6 @@ import {
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
// FIXME: update to use a const object instead of a typescript enum
@@ -51,7 +51,6 @@ enum State {
}
@Component({
standalone: true,
templateUrl: "./login-decryption-options.component.html",
imports: [
AsyncActionsModule,
@@ -110,6 +109,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
private toastService: ToastService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private validationService: ValidationService,
private logoutService: LogoutService,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@@ -157,19 +157,17 @@ export class LoginDecryptionOptionsComponent implements OnInit {
}
private async handleMissingEmail() {
// TODO: PM-15174 - the solution for this bug will allow us to show the toast on app re-init after
// the user has been logged out and the process reload has occurred.
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("activeUserEmailNotFoundLoggingYouOut"),
});
setTimeout(async () => {
// We can't simply redirect to `/login` because the user is authed and the unauthGuard
// will prevent navigation. We must logout the user first via messagingService, which
// redirects to `/`, which will be handled by the redirectGuard to navigate the user to `/login`.
// The timeout just gives the user a chance to see the error toast before process reload runs on logout.
await this.loginDecryptionOptionsService.logOut();
}, 5000);
await this.logoutService.logout(this.activeAccountId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
}
private observeAndPersistRememberDeviceValueChanges() {
@@ -313,7 +311,9 @@ export class LoginDecryptionOptionsComponent implements OnInit {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (confirmed) {
this.messagingService.send("logout", { userId: userId });
await this.logoutService.logout(userId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
}
}
}

View File

@@ -3,8 +3,4 @@ export abstract class LoginDecryptionOptionsService {
* Handles client-specific logic that runs after a user was successfully created
*/
abstract handleCreateUserSuccess(): Promise<void | null>;
/**
* Logs the user out
*/
abstract logOut(): Promise<void>;
}

View File

@@ -39,7 +39,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
import { AuthRequestApiServiceAbstraction } from "../../common/abstractions/auth-request-api.service";
import { LoginViaAuthRequestCacheService } from "../../common/services/auth-request/default-login-via-auth-request-cache.service";
// FIXME: update to use a const object instead of a typescript enum
@@ -57,7 +57,6 @@ const matchOptions: IsActiveMatchOptions = {
};
@Component({
standalone: true,
templateUrl: "./login-via-auth-request.component.html",
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
providers: [{ provide: LoginViaAuthRequestCacheService }],
@@ -86,7 +85,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private anonymousHubService: AnonymousHubService,
private appIdService: AppIdService,
private authRequestApiService: AuthRequestApiService,
private authRequestApiService: AuthRequestApiServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private authService: AuthService,
private cryptoFunctionService: CryptoFunctionService,

View File

@@ -9,7 +9,6 @@ import { DefaultServerSettingsService } from "@bitwarden/common/platform/service
import { LinkModule } from "@bitwarden/components";
@Component({
standalone: true,
imports: [CommonModule, JslibModule, LinkModule, RouterModule],
template: `
<div class="tw-text-center" *ngIf="!(isUserRegistrationDisabled$ | async)">

View File

@@ -32,6 +32,7 @@ import { UserId } from "@bitwarden/common/types/guid";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
AsyncActionsModule,
ButtonModule,
CheckboxModule,
@@ -41,7 +42,6 @@ import {
ToastService,
} from "@bitwarden/components";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import { VaultIcon, WaveIcon } from "../icons";
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
@@ -56,7 +56,6 @@ export enum LoginUiState {
}
@Component({
standalone: true,
templateUrl: "./login.component.html",
imports: [
AsyncActionsModule,
@@ -282,16 +281,12 @@ export class LoginComponent implements OnInit, OnDestroy {
private async handleAuthResult(authResult: AuthResult): Promise<void> {
if (authResult.requiresEncryptionKeyMigration) {
/* Legacy accounts used the master key to encrypt data.
Migration is required but only performed on Web. */
if (this.clientType === ClientType.Web) {
await this.router.navigate(["migrate-legacy-encryption"]);
} else {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("encryptionKeyMigrationRequired"),
});
}
This is now unsupported and requires a downgraded client */
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("legacyEncryptionUnsupported"),
});
return;
}

View File

@@ -25,7 +25,6 @@ import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login
* Component for verifying a new device via a one-time password (OTP).
*/
@Component({
standalone: true,
selector: "app-new-device-verification",
templateUrl: "./new-device-verification.component.html",
imports: [
@@ -138,6 +137,8 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loginSuccessHandlerService.run(authResult.userId);
// If verification succeeds, navigate to vault

View File

@@ -13,7 +13,6 @@ import { CalloutModule } from "@bitwarden/components";
@Component({
selector: "auth-password-callout",
templateUrl: "password-callout.component.html",
standalone: true,
imports: [CommonModule, JslibModule, CalloutModule],
})
export class PasswordCalloutComponent {

View File

@@ -23,7 +23,6 @@ import {
} from "@bitwarden/components";
@Component({
standalone: true,
templateUrl: "./password-hint.component.html",
imports: [
AsyncActionsModule,

View File

@@ -26,7 +26,6 @@ import { SelfHostedEnvConfigDialogComponent } from "../../self-hosted-env-config
* Outputs the selected region to the parent component so it can respond as necessary.
*/
@Component({
standalone: true,
selector: "auth-registration-env-selector",
templateUrl: "registration-env-selector.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],

View File

@@ -16,14 +16,13 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { ToastService } from "@bitwarden/components";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import {
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
PasswordLoginCredentials,
} from "../../../common";
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
import {
InputPasswordComponent,
InputPasswordFlow,
@@ -33,7 +32,6 @@ import { PasswordInputResult } from "../../input-password/password-input-result"
import { RegistrationFinishService } from "./registration-finish.service";
@Component({
standalone: true,
selector: "auth-registration-finish",
templateUrl: "./registration-finish.component.html",
imports: [CommonModule, JslibModule, RouterModule, InputPasswordComponent],
@@ -41,7 +39,7 @@ import { RegistrationFinishService } from "./registration-finish.service";
export class RegistrationFinishComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
inputPasswordFlow = InputPasswordFlow.AccountRegistration;
inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration;
loading = true;
submitting = false;
email: string;

View File

@@ -21,7 +21,6 @@ export interface RegistrationLinkExpiredComponentData {
}
@Component({
standalone: true,
selector: "auth-registration-link-expired",
templateUrl: "./registration-link-expired.component.html",
imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule],

View File

@@ -19,7 +19,6 @@ export interface RegistrationStartSecondaryComponentData {
}
@Component({
standalone: true,
selector: "auth-registration-start-secondary",
templateUrl: "./registration-start-secondary.component.html",
imports: [CommonModule, JslibModule, RouterModule, LinkModule],

View File

@@ -14,18 +14,18 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
AsyncActionsModule,
ButtonModule,
CheckboxModule,
FormFieldModule,
Icons,
IconModule,
LinkModule,
} from "@bitwarden/components";
import { LoginEmailService } from "../../../common";
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
import { RegistrationUserAddIcon } from "../../icons";
import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon";
import { RegistrationEnvSelectorComponent } from "../registration-env-selector/registration-env-selector.component";
// FIXME: update to use a const object instead of a typescript enum
@@ -42,7 +42,6 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record<Region, boolean> = {
};
@Component({
standalone: true,
selector: "auth-registration-start",
templateUrl: "./registration-start.component.html",
imports: [
@@ -171,7 +170,7 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
pageTitle: {
key: "checkYourEmail",
},
pageIcon: RegistrationCheckEmailIcon,
pageIcon: Icons.RegistrationCheckEmailIcon,
});
this.registrationStartStateChange.emit(this.state);
};

View File

@@ -18,6 +18,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperData,
AnonLayoutWrapperDataService,
AsyncActionsModule,
ButtonModule,
DialogModule,
@@ -34,8 +36,6 @@ import {
// eslint-disable-next-line import/no-restricted-paths, no-restricted-imports
import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests";
import { LoginEmailService } from "../../../common";
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
import { AnonLayoutWrapperData } from "../../anon-layout/anon-layout-wrapper.component";
import { RegistrationStartComponent } from "./registration-start.component";

View File

@@ -55,7 +55,6 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
* Dialog for configuring self-hosted environment settings.
*/
@Component({
standalone: true,
selector: "self-hosted-env-config-dialog",
templateUrl: "self-hosted-env-config-dialog.component.html",
imports: [

View File

@@ -122,7 +122,11 @@ describe("DefaultSetPasswordJitService", () => {
};
credentials = {
...passwordInputResult,
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,

View File

@@ -30,7 +30,6 @@ import {
} from "./set-password-jit.service.abstraction";
@Component({
standalone: true,
selector: "auth-set-password-jit",
templateUrl: "set-password-jit.component.html",
imports: [CommonModule, InputPasswordComponent, JslibModule],
@@ -98,7 +97,11 @@ export class SetPasswordJitComponent implements OnInit {
this.submitting = true;
const credentials: SetPasswordCredentials = {
...passwordInputResult,
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,

View File

@@ -23,10 +23,12 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -62,7 +64,6 @@ interface QueryParams {
* This component handles the SSO flow.
*/
@Component({
standalone: true,
templateUrl: "sso.component.html",
imports: [
AsyncActionsModule,
@@ -117,6 +118,7 @@ export class SsoComponent implements OnInit {
private toastService: ToastService,
private ssoComponentService: SsoComponentService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private configService: ConfigService,
) {
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
@@ -532,7 +534,12 @@ export class SsoComponent implements OnInit {
}
private async handleChangePasswordRequired(orgIdentifier: string) {
await this.router.navigate(["set-password-jit"], {
const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password-jit";
await this.router.navigate([route], {
queryParams: {
identifier: orgIdentifier,
},

View File

@@ -15,7 +15,6 @@ import {
} from "@bitwarden/components";
@Component({
standalone: true,
selector: "app-two-factor-auth-authenticator",
templateUrl: "two-factor-auth-authenticator.component.html",
imports: [

View File

@@ -26,7 +26,6 @@ import {
} from "./two-factor-auth-duo-component.service";
@Component({
standalone: true,
selector: "app-two-factor-auth-duo",
template: "",
imports: [

View File

@@ -28,7 +28,6 @@ import { TwoFactorAuthEmailComponentCacheService } from "./two-factor-auth-email
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
@Component({
standalone: true,
selector: "app-two-factor-auth-email",
templateUrl: "two-factor-auth-email.component.html",
imports: [

View File

@@ -33,7 +33,6 @@ export interface WebAuthnResult {
}
@Component({
standalone: true,
selector: "app-two-factor-auth-webauthn",
templateUrl: "two-factor-auth-webauthn.component.html",
imports: [

View File

@@ -15,7 +15,6 @@ import {
} from "@bitwarden/components";
@Component({
standalone: true,
selector: "app-two-factor-auth-yubikey",
templateUrl: "two-factor-auth-yubikey.component.html",
imports: [

View File

@@ -1,6 +1,5 @@
import {
DuoLaunchAction,
LegacyKeyMigrationAction,
TwoFactorAuthComponentService,
} from "./two-factor-auth-component.service";
@@ -9,10 +8,6 @@ export class DefaultTwoFactorAuthComponentService implements TwoFactorAuthCompon
return false;
}
determineLegacyKeyMigrationAction() {
return LegacyKeyMigrationAction.PREVENT_LOGIN_AND_SHOW_REQUIRE_MIGRATION_WARNING;
}
determineDuoLaunchAction(): DuoLaunchAction {
return DuoLaunchAction.DIRECT_LAUNCH;
}

View File

@@ -1,12 +1,5 @@
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum LegacyKeyMigrationAction {
PREVENT_LOGIN_AND_SHOW_REQUIRE_MIGRATION_WARNING,
NAVIGATE_TO_MIGRATION_COMPONENT,
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum DuoLaunchAction {
@@ -38,18 +31,6 @@ export abstract class TwoFactorAuthComponentService {
*/
abstract removePopupWidthExtension?(): void;
/**
* We used to use the user's master key to encrypt their data. We deprecated that approach
* and now use a user key. This method should be called if we detect that the user
* is still using the old master key encryption scheme (server sends down a flag to
* indicate this). This method then determines what action to take based on the client.
*
* We have two possible actions:
* 1. Prevent the user from logging in and show a warning that they need to migrate their key on the web client today.
* 2. Navigate the user to the key migration component on the web client.
*/
abstract determineLegacyKeyMigrationAction(): LegacyKeyMigrationAction;
/**
* Optionally closes any single action popouts (extension only).
* @returns true if we are in a single action popout and it was closed, false otherwise.

View File

@@ -27,6 +27,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -36,15 +37,13 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
import { UserId } from "@bitwarden/common/types/guid";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { DialogService, ToastService } from "@bitwarden/components";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import { DialogService, ToastService, AnonLayoutWrapperDataService } from "@bitwarden/components";
import { TwoFactorAuthComponentCacheService } from "./two-factor-auth-component-cache.service";
import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service";
import { TwoFactorAuthComponent } from "./two-factor-auth.component";
@Component({})
@Component({ standalone: false })
class TestTwoFactorComponent extends TwoFactorAuthComponent {}
describe("TwoFactorAuthComponent", () => {
@@ -77,6 +76,7 @@ describe("TwoFactorAuthComponent", () => {
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
let mockTwoFactorAuthCompCacheService: MockProxy<TwoFactorAuthComponentCacheService>;
let mockAuthService: MockProxy<AuthService>;
let mockConfigService: MockProxy<ConfigService>;
let mockUserDecryptionOpts: {
noMasterPassword: UserDecryptionOptions;
@@ -112,6 +112,7 @@ describe("TwoFactorAuthComponent", () => {
mockToastService = mock<ToastService>();
mockTwoFactorAuthCompService = mock<TwoFactorAuthComponentService>();
mockAuthService = mock<AuthService>();
mockConfigService = mock<ConfigService>();
mockEnvService = mock<EnvironmentService>();
mockLoginSuccessHandlerService = mock<LoginSuccessHandlerService>();
@@ -211,6 +212,7 @@ describe("TwoFactorAuthComponent", () => {
useValue: mockTwoFactorAuthCompCacheService,
},
{ provide: AuthService, useValue: mockAuthService },
{ provide: ConfigService, useValue: mockConfigService },
],
});
@@ -227,22 +229,6 @@ describe("TwoFactorAuthComponent", () => {
expect(component).toBeTruthy();
});
// Shared tests
const testChangePasswordOnSuccessfulLogin = () => {
it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Act
await component.submit("testToken");
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["set-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
};
describe("Standard 2FA scenarios", () => {
describe("submit", () => {
const token = "testToken";
@@ -282,20 +268,76 @@ describe("TwoFactorAuthComponent", () => {
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
});
testChangePasswordOnSuccessfulLogin();
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
it("navigates to the /set-initial-password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(true);
// Act
await component.submit("testToken");
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["set-initial-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
it("navigates to the /set-password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(false);
// Act
await component.submit("testToken");
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["set-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
});
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
it("does not navigate to the /set-initial-password route when the user has key connector even if user has no master password", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await component.submit(token, remember);
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
await component.submit(token, remember);
expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-initial-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
it("does not navigate to the /set-password route when the user has key connector even if user has no master password", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
await component.submit(token, remember);
expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
});

View File

@@ -32,7 +32,9 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -41,6 +43,7 @@ import { UserId } from "@bitwarden/common/types/guid";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
AnonLayoutWrapperDataService,
AsyncActionsModule,
ButtonModule,
CheckboxModule,
@@ -49,7 +52,6 @@ import {
ToastService,
} from "@bitwarden/components";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import {
TwoFactorAuthAuthenticatorIcon,
TwoFactorAuthEmailIcon,
@@ -69,7 +71,6 @@ import {
} from "./two-factor-auth-component-cache.service";
import {
DuoLaunchAction,
LegacyKeyMigrationAction,
TwoFactorAuthComponentService,
} from "./two-factor-auth-component.service";
import {
@@ -78,7 +79,6 @@ import {
} from "./two-factor-options.component";
@Component({
standalone: true,
selector: "app-two-factor-auth",
templateUrl: "two-factor-auth.component.html",
imports: [
@@ -171,6 +171,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private loginSuccessHandlerService: LoginSuccessHandlerService,
private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService,
private authService: AuthService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -267,6 +268,8 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private listenForAuthnSessionTimeout() {
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed(this.destroyRef))
// TODO: Fix this!
// eslint-disable-next-line rxjs/no-async-subscribe
.subscribe(async (expired) => {
if (!expired) {
return;
@@ -388,22 +391,12 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
// Migration is forced so prevent login via return
const legacyKeyMigrationAction: LegacyKeyMigrationAction =
this.twoFactorAuthComponentService.determineLegacyKeyMigrationAction();
switch (legacyKeyMigrationAction) {
case LegacyKeyMigrationAction.NAVIGATE_TO_MIGRATION_COMPONENT:
await this.router.navigate(["migrate-legacy-encryption"]);
break;
case LegacyKeyMigrationAction.PREVENT_LOGIN_AND_SHOW_REQUIRE_MIGRATION_WARNING:
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("encryptionKeyMigrationRequired"),
});
break;
}
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("legacyEncryptionUnsupported"),
});
return true;
}
@@ -569,7 +562,12 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
private async handleChangePasswordRequired(orgIdentifier: string | undefined) {
await this.router.navigate(["set-password"], {
const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password";
await this.router.navigate([route], {
queryParams: {
identifier: orgIdentifier,
},

View File

@@ -11,7 +11,7 @@ import { LoginStrategyServiceAbstraction } from "../../common";
import { TwoFactorAuthGuard } from "./two-factor-auth.guard";
@Component({ template: "" })
@Component({ template: "", standalone: true })
export class EmptyComponent {}
describe("TwoFactorAuthGuard", () => {

View File

@@ -32,7 +32,6 @@ export type TwoFactorOptionsDialogResult = {
};
@Component({
standalone: true,
selector: "app-two-factor-options",
templateUrl: "two-factor-options.component.html",
imports: [

View File

@@ -32,7 +32,6 @@ import { UserVerificationFormInputComponent } from "./user-verification-form-inp
@Component({
templateUrl: "user-verification-dialog.component.html",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,

View File

@@ -56,7 +56,6 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt
transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]),
]),
],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,

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