1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

feat(set-initial-password): [Auth/PM-18784] SetInitialPasswordComponent Handle TDE Offboarding (#14861)

This PR makes it so that `SetInitialPasswordComponent` handles the TDE offboarding flow where an org user now needs to set an initial master password.

Feature flag: `PM16117_SetInitialPasswordRefactor`
This commit is contained in:
rr-bw
2025-07-02 07:23:45 -07:00
committed by GitHub
parent 1837974e0a
commit cc65f5efc6
8 changed files with 391 additions and 56 deletions

View File

@@ -14,6 +14,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
@@ -28,6 +29,7 @@ import {
SetInitialPasswordService,
SetInitialPasswordCredentials,
SetInitialPasswordUserType,
SetInitialPasswordTdeOffboardingCredentials,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -245,4 +247,44 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
}

View File

@@ -19,6 +19,7 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
@@ -35,6 +36,7 @@ import { DefaultSetInitialPasswordService } from "./default-set-initial-password
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@@ -52,6 +54,11 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let userId: UserId;
let userKey: UserKey;
let userKeyEncString: EncString;
let masterKeyEncryptedUserKey: [UserKey, EncString];
beforeEach(() => {
apiService = mock<ApiService>();
encryptService = mock<EncryptService>();
@@ -64,6 +71,11 @@ describe("DefaultSetInitialPasswordService", () => {
organizationUserApiService = mock<OrganizationUserApiService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
userId = "userId" as UserId;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
sut = new DefaultSetInitialPasswordService(
apiService,
encryptService,
@@ -86,13 +98,8 @@ describe("DefaultSetInitialPasswordService", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
let userType: SetInitialPasswordUserType;
let userId: UserId;
// Mock other function data
let userKey: UserKey;
let userKeyEncString: EncString;
let masterKeyEncryptedUserKey: [UserKey, EncString];
let existingUserPublicKey: UserPublicKey;
let existingUserPrivateKey: UserPrivateKey;
let userKeyEncryptedPrivateKey: EncString;
@@ -121,14 +128,9 @@ describe("DefaultSetInitialPasswordService", () => {
orgId: "orgId",
resetPasswordAutoEnroll: false,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
// Mock other function data
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey;
existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey;
userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey");
@@ -630,4 +632,114 @@ describe("DefaultSetInitialPasswordService", () => {
});
});
});
describe("setInitialPasswordTdeOffboarding(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordTdeOffboardingCredentials;
beforeEach(() => {
// Mock function parameters
credentials = {
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newPasswordHint: "newPasswordHint",
};
});
function setupTdeOffboardingMocks() {
keyService.userKey$.mockReturnValue(of(userKey));
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
}
it("should successfully set an initial password for the TDE offboarding user", async () => {
// Arrange
setupTdeOffboardingMocks();
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = masterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = credentials.newServerMasterKeyHash;
request.masterPasswordHint = credentials.newPasswordHint;
// Act
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith(
request,
);
});
describe("given the initial password has been successfully set", () => {
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
// Arrange
setupTdeOffboardingMocks();
// Act
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
});
});
describe("general error handling", () => {
["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => {
it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
// Arrange
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
...credentials,
[key]: null,
};
// Act
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
// Assert
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
});
});
it(`should throw if the userId was not passed in`, async () => {
// Arrange
userId = null;
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userId not found. Could not set password.");
});
it(`should throw if the userKey was not found`, async () => {
// Arrange
keyService.userKey$.mockReturnValue(of(null));
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userKey not found. Could not set password.");
});
it(`should throw if a newMasterKeyEncryptedUserKey was not returned`, async () => {
// Arrange
masterKeyEncryptedUserKey[1].encryptedString = "" as EncryptedString;
setupTdeOffboardingMocks();
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"newMasterKeyEncryptedUserKey not found. Could not set password.",
);
});
});
});
});

View File

@@ -21,7 +21,12 @@
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[primaryButtonText]="{ key: 'createAccount' }"
[primaryButtonText]="{
key:
userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER
? 'setPassword'
: 'createAccount',
}"
[secondaryButtonText]="{ key: 'logOut' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
(onSecondaryButtonClick)="logout()"

View File

@@ -10,14 +10,20 @@ import {
InputPasswordFlow,
PasswordInputResult,
} from "@bitwarden/auth/angular";
// 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 { LogoutService } from "@bitwarden/auth/common";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -33,6 +39,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@@ -54,6 +61,7 @@ export class SetInitialPasswordComponent implements OnInit {
protected submitting = false;
protected userId?: UserId;
protected userType?: SetInitialPasswordUserType;
protected SetInitialPasswordUserType = SetInitialPasswordUserType;
constructor(
private accountService: AccountService,
@@ -61,10 +69,13 @@ export class SetInitialPasswordComponent implements OnInit {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private dialogService: DialogService,
private i18nService: I18nService,
private logoutService: LogoutService,
private logService: LogService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private messagingService: MessagingService,
private organizationApiService: OrganizationApiServiceAbstraction,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private router: Router,
private setInitialPasswordService: SetInitialPasswordService,
private ssoLoginService: SsoLoginServiceAbstraction,
@@ -80,13 +91,13 @@ export class SetInitialPasswordComponent implements OnInit {
this.userId = activeAccount?.id;
this.email = activeAccount?.email;
await this.determineUserType();
await this.handleQueryParams();
await this.establishUserType();
await this.getOrgInfo();
this.initializing = false;
}
private async determineUserType() {
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
}
@@ -95,6 +106,14 @@ export class SetInitialPasswordComponent implements OnInit {
this.masterPasswordService.forceSetPasswordReason$(this.userId),
);
if (this.forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser) {
this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "joinOrganization" },
pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" },
});
}
if (
this.forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
@@ -104,20 +123,35 @@ export class SetInitialPasswordComponent implements OnInit {
pageTitle: { key: "setMasterPassword" },
pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" },
});
} else {
this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
}
if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) {
this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "joinOrganization" },
pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" },
pageTitle: { key: "setMasterPassword" },
pageSubtitle: { key: "tdeDisabledMasterPasswordRequired" },
});
}
// If we somehow end up here without a reason, navigate to root
if (this.forceSetPasswordReason === ForceSetPasswordReason.None) {
await this.router.navigate(["/"]);
}
}
private async handleQueryParams() {
private async getOrgInfo() {
if (!this.userId) {
throw new Error("userId not found. Could not handle query params.");
}
if (this.userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER) {
this.masterPasswordPolicyOptions =
(await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(this.userId))) ??
null;
return;
}
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
this.orgSsoIdentifier =
@@ -146,38 +180,34 @@ export class SetInitialPasswordComponent implements OnInit {
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
if (!passwordInputResult.newMasterKey) {
throw new Error("newMasterKey not found. Could not set initial password.");
}
if (!passwordInputResult.newServerMasterKeyHash) {
throw new Error("newServerMasterKeyHash not found. Could not set initial password.");
}
if (!passwordInputResult.newLocalMasterKeyHash) {
throw new Error("newLocalMasterKeyHash not found. Could not set initial password.");
}
// newPasswordHint can have an empty string as a valid value, so we specifically check for null or undefined
if (passwordInputResult.newPasswordHint == null) {
throw new Error("newPasswordHint not found. Could not set initial password.");
}
if (!passwordInputResult.kdfConfig) {
throw new Error("kdfConfig not found. Could not set initial password.");
}
if (!this.userId) {
throw new Error("userId not found. Could not set initial password.");
}
if (!this.userType) {
throw new Error("userType not found. Could not set initial password.");
}
if (!this.orgSsoIdentifier) {
throw new Error("orgSsoIdentifier not found. Could not set initial password.");
}
if (!this.orgId) {
throw new Error("orgId not found. Could not set initial password.");
}
// resetPasswordAutoEnroll can have `false` as a valid value, so we specifically check for null or undefined
if (this.resetPasswordAutoEnroll == null) {
throw new Error("resetPasswordAutoEnroll not found. Could not set initial password.");
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
}
}
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx);
assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userType, "userType", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
try {
const credentials: SetInitialPasswordCredentials = {
@@ -202,11 +232,44 @@ export class SetInitialPasswordComponent implements OnInit {
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
try {
const credentials: SetInitialPasswordTdeOffboardingCredentials = {
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
};
await this.setInitialPasswordService.setInitialPasswordTdeOffboarding(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
await this.logoutService.logout(this.userId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
} catch (e) {
this.logService.error("Error setting initial password during TDE offboarding", e);
this.validationService.showError(e);
} finally {
this.submitting = false;
}
}
private showSuccessToastByUserType() {
if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
this.toastService.showToast({
@@ -220,12 +283,7 @@ export class SetInitialPasswordComponent implements OnInit {
title: "",
message: this.i18nService.t("inviteAccepted"),
});
}
if (
this.userType ===
SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP
) {
} else {
this.toastService.showToast({
variant: "success",
title: "",

View File

@@ -19,6 +19,12 @@ export const _SetInitialPasswordUserType = {
*/
TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
"tde_org_user_reset_password_permission_requires_mp",
/**
* A user in an org that offboarded from trusted device encryption and is now a
* master-password-encryption org
*/
OFFBOARDED_TDE_ORG_USER: "offboarded_tde_org_user",
} as const;
type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType;
@@ -40,6 +46,12 @@ export interface SetInitialPasswordCredentials {
resetPasswordAutoEnroll: boolean;
}
export interface SetInitialPasswordTdeOffboardingCredentials {
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newPasswordHint: string;
}
/**
* Handles setting an initial password for an existing authed user.
*
@@ -61,4 +73,17 @@ export abstract class SetInitialPasswordService {
userType: SetInitialPasswordUserType,
userId: UserId,
) => Promise<void>;
/**
* Sets an initial password for a user who logs in after their org offboarded from
* trusted device encryption and is now a master-password-encryption org:
* - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER}
*
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
* @param userId the account `userId`
*/
abstract setInitialPasswordTdeOffboarding: (
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise<void>;
}

View File

@@ -0,0 +1,45 @@
/**
* Asserts that a value is non-nullish (not `null` or `undefined`); throws if value is nullish.
*
* @param val the value to check
* @param name the name of the value to include in the error message
* @param ctx context to optionally append to the error message
* @throws if the value is null or undefined
*
* @example
*
* ```
* // `newPasswordHint` can have an empty string as a valid value, so we check non-nullish
* this.assertNonNullish(
* passwordInputResult.newPasswordHint,
* "newPasswordHint",
* "Could not set initial password."
* );
* // Output error message: "newPasswordHint is null or undefined. Could not set initial password."
* ```
*
* @remarks
*
* If you use this method repeatedly to check several values, it may help to assign any
* additional context (`ctx`) to a variable and pass it in to each call. This prevents the
* call from reformatting vertically via prettier in your text editor, taking up multiple lines.
*
* For example:
* ```
* const ctx = "Could not set initial password.";
*
* this.assertNonNullish(valueOne, "valueOne", ctx);
* this.assertNonNullish(valueTwo, "valueTwo", ctx);
* this.assertNonNullish(valueThree, "valueThree", ctx);
* ```
*/
export function assertNonNullish<T>(
val: T,
name: string,
ctx?: string,
): asserts val is NonNullable<T> {
if (val == null) {
// If context is provided, append it to the error message with a space before it.
throw new Error(`${name} is null or undefined.${ctx ? ` ${ctx}` : ""}`);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Asserts that a value is truthy; throws if value is falsy.
*
* @param val the value to check
* @param name the name of the value to include in the error message
* @param ctx context to optionally append to the error message
* @throws if the value is falsy (`false`, `""`, `0`, `null`, `undefined`, `void`, or `NaN`)
*
* @example
*
* ```
* this.assertTruthy(
* this.organizationId,
* "organizationId",
* "Could not set initial password."
* );
* // Output error message: "organizationId is falsy. Could not set initial password."
* ```
*
* @remarks
*
* If you use this method repeatedly to check several values, it may help to assign any
* additional context (`ctx`) to a variable and pass it in to each call. This prevents the
* call from reformatting vertically via prettier in your text editor, taking up multiple lines.
*
* For example:
* ```
* const ctx = "Could not set initial password.";
*
* this.assertTruthy(valueOne, "valueOne", ctx);
* this.assertTruthy(valueTwo, "valueTwo", ctx);
* this.assertTruthy(valueThree, "valueThree", ctx);
*/
export function assertTruthy<T>(
val: T,
name: string,
ctx?: string,
): asserts val is Exclude<T, false | "" | 0 | null | undefined | void | 0n> {
// Because `NaN` is a value (not a type) of type 'number', that means we cannot add
// it to the list of falsy values in the type assertion. Instead, we check for it
// separately at runtime.
if (!val || (typeof val === "number" && Number.isNaN(val))) {
// If context is provided, append it to the error message with a space before it.
throw new Error(`${name} is falsy.${ctx ? ` ${ctx}` : ""}`);
}
}

View File

@@ -0,0 +1,2 @@
export { assertTruthy } from "./assert-truthy.util";
export { assertNonNullish } from "./assert-non-nullish.util";