mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 00:23:17 +00:00
resolve merge conflicts
This commit is contained in:
@@ -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>/../../",
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": false
|
||||
},
|
||||
"files": ["./test.setup.ts"]
|
||||
}
|
||||
|
||||
@@ -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>/../../",
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
53
libs/angular/src/auth/guards/redirect/README.md
Normal file
53
libs/angular/src/auth/guards/redirect/README.md
Normal 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`** → redirect to `/login`
|
||||
- **`AuthenticationStatus.Unlocked`** → redirect to `/vault`
|
||||
- **`AuthenticationStatus.Locked`**
|
||||
- **TDE Locked State** → 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** → 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"})],
|
||||
}
|
||||
```
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
216
libs/angular/src/auth/password-management/README.md
Normal file
216
libs/angular/src/auth/password-management/README.md
Normal 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 org’s 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>
|
||||
@@ -27,6 +27,7 @@ const testStringFeatureValue = "test-value";
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
standalone: false,
|
||||
})
|
||||
class TestComponent {
|
||||
testBooleanFeature = testBooleanFeature;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Directive, HostListener, Input } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "[appTextDrag]",
|
||||
standalone: true,
|
||||
host: {
|
||||
draggable: "true",
|
||||
class: "tw-cursor-move",
|
||||
|
||||
@@ -85,11 +85,11 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
TextDragDirective,
|
||||
CopyClickDirective,
|
||||
A11yTitleDirective,
|
||||
AutofocusDirective,
|
||||
],
|
||||
declarations: [
|
||||
A11yInvalidDirective,
|
||||
ApiActionDirective,
|
||||
AutofocusDirective,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
36
libs/angular/src/platform/i18n/document-lang.setter.spec.ts
Normal file
36
libs/angular/src/platform/i18n/document-lang.setter.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
26
libs/angular/src/platform/i18n/document-lang.setter.ts
Normal file
26
libs/angular/src/platform/i18n/document-lang.setter.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
1
libs/angular/src/platform/i18n/index.ts
Normal file
1
libs/angular/src/platform/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DocumentLangSetter } from "./document-lang.setter";
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]) => {
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ export type DisplayMode =
|
||||
| "noOrganizations"
|
||||
| "organizationMember"
|
||||
| "singleOrganizationPolicy"
|
||||
| "personalOwnershipPolicy"
|
||||
| "singleOrganizationAndPersonalOwnershipPolicies";
|
||||
| "organizationDataOwnershipPolicy"
|
||||
| "singleOrganizationAndOrganizatonDataOwnershipPolicies";
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": false
|
||||
},
|
||||
"files": ["./test.setup.ts"]
|
||||
}
|
||||
|
||||
@@ -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>/../../",
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,7 +11,6 @@ export type FingerprintDialogData = {
|
||||
|
||||
@Component({
|
||||
templateUrl: "fingerprint-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [JslibModule, ButtonModule, DialogModule],
|
||||
})
|
||||
export class FingerprintDialogComponent {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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—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—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`**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(["/"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "./password-hint.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "./two-factor-auth-duo-component.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-two-factor-auth-duo",
|
||||
template: "",
|
||||
imports: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface WebAuthnResult {
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-two-factor-auth-webauthn",
|
||||
templateUrl: "two-factor-auth-webauthn.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ export type TwoFactorOptionsDialogResult = {
|
||||
};
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-two-factor-options",
|
||||
templateUrl: "two-factor-options.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -32,7 +32,6 @@ import { UserVerificationFormInputComponent } from "./user-verification-form-inp
|
||||
|
||||
@Component({
|
||||
templateUrl: "user-verification-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user