mirror of
https://github.com/bitwarden/browser
synced 2026-02-15 16:05:03 +00:00
Merge branch 'main' into SM-1301-getbyidsevent
This commit is contained in:
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -18,6 +18,7 @@ apps/cli/src/auth @bitwarden/team-auth-dev
|
||||
apps/desktop/src/auth @bitwarden/team-auth-dev
|
||||
apps/web/src/app/auth @bitwarden/team-auth-dev
|
||||
libs/auth @bitwarden/team-auth-dev
|
||||
libs/user-core @bitwarden/team-auth-dev
|
||||
# web connectors used for auth
|
||||
apps/web/src/connectors @bitwarden/team-auth-dev
|
||||
bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev
|
||||
@@ -91,6 +92,7 @@ libs/common/spec @bitwarden/team-platform-dev
|
||||
libs/common/src/state-migrations @bitwarden/team-platform-dev
|
||||
libs/platform @bitwarden/team-platform-dev
|
||||
libs/storage-core @bitwarden/team-platform-dev
|
||||
libs/logging @bitwarden/team-platform-dev
|
||||
libs/storage-test-utils @bitwarden/team-platform-dev
|
||||
# Web utils used across app and connectors
|
||||
apps/web/src/utils/ @bitwarden/team-platform-dev
|
||||
|
||||
23
.github/workflows/build-browser.yml
vendored
23
.github/workflows/build-browser.yml
vendored
@@ -268,6 +268,29 @@ jobs:
|
||||
working-directory: browser-source/
|
||||
run: npm link ../sdk-internal
|
||||
|
||||
- name: Check source file size
|
||||
if: ${{ startsWith(matrix.name, 'firefox') }}
|
||||
run: |
|
||||
# Declare variable as indexed array
|
||||
declare -a FILES
|
||||
|
||||
# Search for source files that are greater than 4M
|
||||
TARGET_DIR='./browser-source/apps/browser'
|
||||
while IFS=' ' read -r RESULT; do
|
||||
FILES+=("$RESULT")
|
||||
done < <(find $TARGET_DIR -size +4M)
|
||||
|
||||
# Validate results and provide messaging
|
||||
if [[ ${#FILES[@]} -ne 0 ]]; then
|
||||
echo "File(s) exceeds size limit: 4MB"
|
||||
for FILE in ${FILES[@]}; do
|
||||
echo "- $(du --si $FILE)"
|
||||
done
|
||||
echo "ERROR Firefox rejects extension uploads that contain files larger than 4MB"
|
||||
# Invoke failure
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build extension
|
||||
run: npm run ${{ matrix.npm_command }}
|
||||
working-directory: browser-source/apps/browser
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.6.0",
|
||||
"version": "2025.6.1",
|
||||
"scripts": {
|
||||
"build": "npm run build:chrome",
|
||||
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.6.0",
|
||||
"version": "2025.6.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
@@ -56,7 +56,8 @@
|
||||
"unlimitedStorage",
|
||||
"webNavigation",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
"webRequestBlocking",
|
||||
"notifications"
|
||||
],
|
||||
"__safari__permissions": [
|
||||
"<all_urls>",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.6.0",
|
||||
"version": "2025.6.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/cli",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.6.0",
|
||||
"version": "2025.6.1",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -51,7 +51,7 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
|
||||
let request_data = match request_parser::parse_request(data) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
println!("[SSH Agent] Error while parsing request: {}", e);
|
||||
println!("[SSH Agent] Error while parsing request: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -178,7 +178,7 @@ impl BitwardenDesktopAgent {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[SSH Agent Native Module] Error while parsing key: {}", e);
|
||||
eprintln!("[SSH Agent Native Module] Error while parsing key: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,10 +234,9 @@ fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Err
|
||||
Ok(key) => match key.public_key().to_bytes() {
|
||||
Ok(_) => Ok(key),
|
||||
Err(e) => Err(anyhow::Error::msg(format!(
|
||||
"Failed to parse public key: {}",
|
||||
e
|
||||
"Failed to parse public key: {e}"
|
||||
))),
|
||||
},
|
||||
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {}", e))),
|
||||
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +65,10 @@ impl BitwardenDesktopAgent {
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"[SSH Agent Native Module] Starting SSH Agent server on {:?}",
|
||||
ssh_path
|
||||
);
|
||||
println!("[SSH Agent Native Module] Starting SSH Agent server on {ssh_path:?}");
|
||||
let sockname = std::path::Path::new(&ssh_path);
|
||||
if let Err(e) = std::fs::remove_file(sockname) {
|
||||
println!(
|
||||
"[SSH Agent Native Module] Could not remove existing socket file: {}",
|
||||
e
|
||||
);
|
||||
println!("[SSH Agent Native Module] Could not remove existing socket file: {e}");
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return;
|
||||
}
|
||||
@@ -85,10 +79,7 @@ impl BitwardenDesktopAgent {
|
||||
// Only the current user should be able to access the socket
|
||||
if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600))
|
||||
{
|
||||
println!(
|
||||
"[SSH Agent Native Module] Could not set socket permissions: {}",
|
||||
e
|
||||
);
|
||||
println!("[SSH Agent Native Module] Could not set socket permissions: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,10 +103,7 @@ impl BitwardenDesktopAgent {
|
||||
println!("[SSH Agent Native Module] SSH Agent server exited");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[SSH Agent Native Module] Error while starting agent server: {}",
|
||||
e
|
||||
);
|
||||
eprintln!("[SSH Agent Native Module] Error while starting agent server: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -214,8 +214,7 @@ impl MacOSProviderClient {
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
cb.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {}",
|
||||
e
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ pub mod sshagent {
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e);
|
||||
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {e}");
|
||||
let _ = auth_response_tx_arc
|
||||
.lock()
|
||||
.await
|
||||
@@ -246,7 +246,7 @@ pub mod sshagent {
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e);
|
||||
println!("[SSH Agent Native Module] calling UI callback could not create promise: {e}");
|
||||
let _ = auth_response_tx_arc
|
||||
.lock()
|
||||
.await
|
||||
|
||||
@@ -80,8 +80,7 @@ mod objc {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Error: Failed to convert ObjCString to Rust string during commandReturn: {}",
|
||||
e
|
||||
"Error: Failed to convert ObjCString to Rust string during commandReturn: {e}"
|
||||
);
|
||||
|
||||
return false;
|
||||
@@ -91,10 +90,7 @@ mod objc {
|
||||
match context.send(value) {
|
||||
Ok(_) => 0,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Error: Failed to return ObjCString from ObjC code to Rust code: {}",
|
||||
e
|
||||
);
|
||||
println!("Error: Failed to return ObjCString from ObjC code to Rust code: {e}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
|
||||
loggers.push(simplelog::WriteLogger::new(file_level, config, file));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Can't create file: {}", e);
|
||||
eprintln!("Can't create file: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = CombinedLogger::init(loggers) {
|
||||
eprintln!("Failed to initialize logger: {}", e);
|
||||
eprintln!("Failed to initialize logger: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.6.1",
|
||||
"version": "2025.7.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -209,7 +209,7 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
}
|
||||
|
||||
request.credentialId = Array.from(
|
||||
parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId),
|
||||
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -336,12 +336,12 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
response: Fido2AuthenticatorGetAssertionResult,
|
||||
): autofill.PasskeyAssertionResponse {
|
||||
return {
|
||||
userHandle: Array.from(response.selectedCredential.userHandle),
|
||||
userHandle: Array.from(new Uint8Array(response.selectedCredential.userHandle)),
|
||||
rpId: request.rpId,
|
||||
signature: Array.from(response.signature),
|
||||
signature: Array.from(new Uint8Array(response.signature)),
|
||||
clientDataHash: request.clientDataHash,
|
||||
authenticatorData: Array.from(response.authenticatorData),
|
||||
credentialId: Array.from(response.selectedCredential.id),
|
||||
authenticatorData: Array.from(new Uint8Array(response.authenticatorData)),
|
||||
credentialId: Array.from(new Uint8Array(response.selectedCredential.id)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,15 @@ export class WindowMain {
|
||||
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
|
||||
});
|
||||
|
||||
// Persist zoom changes immediately when user zooms in/out or resets zoom
|
||||
// We can't depend on higher level web events (like close) to do this
|
||||
// because locking the vault resets window state.
|
||||
this.win.webContents.on("zoom-changed", async () => {
|
||||
const newZoom = this.win.webContents.zoomFactor;
|
||||
this.windowStates[mainWindowSizeKey].zoomFactor = newZoom;
|
||||
await this.desktopSettingsService.setWindow(this.windowStates[mainWindowSizeKey]);
|
||||
});
|
||||
|
||||
if (this.windowStates[mainWindowSizeKey].isMaximized) {
|
||||
this.win.maximize();
|
||||
}
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.6.1",
|
||||
"version": "2025.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.6.1",
|
||||
"version": "2025.7.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.6.1",
|
||||
"version": "2025.7.0",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -388,6 +388,13 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener {
|
||||
}
|
||||
|
||||
async viewCipher(cipher: CipherView) {
|
||||
if (cipher.decryptionFailure) {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: [cipher.id as CipherId],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.shouldReprompt(cipher, "view")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
|
||||
<bit-nav-group
|
||||
icon="bwi-filter"
|
||||
*ngIf="organization.useRiskInsights"
|
||||
*ngIf="organization.useRiskInsights && organization.canAccessReports"
|
||||
[text]="'accessIntelligence' | i18n"
|
||||
route="access-intelligence"
|
||||
>
|
||||
|
||||
@@ -79,6 +79,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "access-intelligence",
|
||||
canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)],
|
||||
loadChildren: () =>
|
||||
import("../../dirt/access-intelligence/access-intelligence.module").then(
|
||||
(m) => m.AccessIntelligenceModule,
|
||||
|
||||
@@ -9,7 +9,9 @@ const routes: Routes = [
|
||||
{ path: "", pathMatch: "full", redirectTo: "risk-insights" },
|
||||
{
|
||||
path: "risk-insights",
|
||||
canActivate: [organizationPermissionsGuard((org) => org.useRiskInsights)],
|
||||
canActivate: [
|
||||
organizationPermissionsGuard((org) => org.useRiskInsights && org.canAccessReports),
|
||||
],
|
||||
component: RiskInsightsComponent,
|
||||
data: {
|
||||
titleId: "RiskInsights",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
45
libs/common/src/auth/utils/assert-non-nullish.util.ts
Normal file
45
libs/common/src/auth/utils/assert-non-nullish.util.ts
Normal 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}` : ""}`);
|
||||
}
|
||||
}
|
||||
46
libs/common/src/auth/utils/assert-truthy.util.ts
Normal file
46
libs/common/src/auth/utils/assert-truthy.util.ts
Normal 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}` : ""}`);
|
||||
}
|
||||
}
|
||||
2
libs/common/src/auth/utils/index.ts
Normal file
2
libs/common/src/auth/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { assertTruthy } from "./assert-truthy.util";
|
||||
export { assertNonNullish } from "./assert-non-nullish.util";
|
||||
@@ -70,7 +70,7 @@ export class Fido2AuthenticatorError extends Error {
|
||||
}
|
||||
|
||||
export interface PublicKeyCredentialDescriptor {
|
||||
id: Uint8Array;
|
||||
id: ArrayBuffer;
|
||||
transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[];
|
||||
type: "public-key";
|
||||
}
|
||||
@@ -155,9 +155,9 @@ export interface Fido2AuthenticatorGetAssertionParams {
|
||||
|
||||
export interface Fido2AuthenticatorGetAssertionResult {
|
||||
selectedCredential: {
|
||||
id: Uint8Array;
|
||||
userHandle?: Uint8Array;
|
||||
id: ArrayBuffer;
|
||||
userHandle?: ArrayBuffer;
|
||||
};
|
||||
authenticatorData: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
authenticatorData: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
}
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
import { LogLevelType } from "../enums/log-level-type.enum";
|
||||
|
||||
export abstract class LogService {
|
||||
abstract debug(message?: any, ...optionalParams: any[]): void;
|
||||
abstract info(message?: any, ...optionalParams: any[]): void;
|
||||
abstract warning(message?: any, ...optionalParams: any[]): void;
|
||||
abstract error(message?: any, ...optionalParams: any[]): void;
|
||||
abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void;
|
||||
}
|
||||
export { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum LogLevelType {
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
export { LogLevel as LogLevelType } from "@bitwarden/logging";
|
||||
|
||||
@@ -31,22 +31,35 @@ export type TimeoutManager = {
|
||||
class SignalRLogger implements ILogger {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
redactMessage(message: string): string {
|
||||
const ACCESS_TOKEN_TEXT = "access_token=";
|
||||
// Redact the access token from the logs if it exists.
|
||||
const accessTokenIndex = message.indexOf(ACCESS_TOKEN_TEXT);
|
||||
if (accessTokenIndex !== -1) {
|
||||
return message.substring(0, accessTokenIndex + ACCESS_TOKEN_TEXT.length) + "[REDACTED]";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
log(logLevel: LogLevel, message: string): void {
|
||||
const redactedMessage = `[SignalR] ${this.redactMessage(message)}`;
|
||||
|
||||
switch (logLevel) {
|
||||
case LogLevel.Critical:
|
||||
this.logService.error(message);
|
||||
this.logService.error(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
this.logService.error(message);
|
||||
this.logService.error(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
this.logService.warning(message);
|
||||
this.logService.warning(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Information:
|
||||
this.logService.info(message);
|
||||
this.logService.info(redactedMessage);
|
||||
break;
|
||||
case LogLevel.Debug:
|
||||
this.logService.debug(message);
|
||||
this.logService.debug(redactedMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { interceptConsole, restoreConsole } from "../../../spec";
|
||||
import { ConsoleLogService } from "@bitwarden/logging";
|
||||
|
||||
import { ConsoleLogService } from "./console-log.service";
|
||||
import { interceptConsole, restoreConsole } from "../../../spec";
|
||||
|
||||
describe("ConsoleLogService", () => {
|
||||
const error = new Error("this is an error");
|
||||
|
||||
@@ -1,59 +1 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { LogService as LogServiceAbstraction } from "../abstractions/log.service";
|
||||
import { LogLevelType } from "../enums/log-level-type.enum";
|
||||
|
||||
export class ConsoleLogService implements LogServiceAbstraction {
|
||||
protected timersMap: Map<string, [number, number]> = new Map();
|
||||
|
||||
constructor(
|
||||
protected isDev: boolean,
|
||||
protected filter: (level: LogLevelType) => boolean = null,
|
||||
) {}
|
||||
|
||||
debug(message?: any, ...optionalParams: any[]) {
|
||||
if (!this.isDev) {
|
||||
return;
|
||||
}
|
||||
this.write(LogLevelType.Debug, message, ...optionalParams);
|
||||
}
|
||||
|
||||
info(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevelType.Info, message, ...optionalParams);
|
||||
}
|
||||
|
||||
warning(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevelType.Warning, message, ...optionalParams);
|
||||
}
|
||||
|
||||
error(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevelType.Error, message, ...optionalParams);
|
||||
}
|
||||
|
||||
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
|
||||
if (this.filter != null && this.filter(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case LogLevelType.Debug:
|
||||
// eslint-disable-next-line
|
||||
console.log(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevelType.Info:
|
||||
// eslint-disable-next-line
|
||||
console.log(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevelType.Warning:
|
||||
// eslint-disable-next-line
|
||||
console.warn(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevelType.Error:
|
||||
// eslint-disable-next-line
|
||||
console.error(message, ...optionalParams);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
export { ConsoleLogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("credential-id-utils", () => {
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
]),
|
||||
]).buffer,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("credential-id-utils", () => {
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
]),
|
||||
]).buffer,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
import { guidToRawFormat } from "./guid-utils";
|
||||
|
||||
export function parseCredentialId(encodedCredentialId: string): Uint8Array {
|
||||
export function parseCredentialId(encodedCredentialId: string): ArrayBuffer {
|
||||
try {
|
||||
if (encodedCredentialId.startsWith("b64.")) {
|
||||
return Fido2Utils.stringToBuffer(encodedCredentialId.slice(4));
|
||||
}
|
||||
|
||||
return guidToRawFormat(encodedCredentialId);
|
||||
return guidToRawFormat(encodedCredentialId).buffer;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -18,13 +18,16 @@ export function parseCredentialId(encodedCredentialId: string): Uint8Array {
|
||||
/**
|
||||
* Compares two credential IDs for equality.
|
||||
*/
|
||||
export function compareCredentialIds(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) {
|
||||
export function compareCredentialIds(a: ArrayBuffer, b: ArrayBuffer): boolean {
|
||||
if (a.byteLength !== b.byteLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
const viewA = new Uint8Array(a);
|
||||
const viewB = new Uint8Array(b);
|
||||
|
||||
for (let i = 0; i < viewA.length; i++) {
|
||||
if (viewA[i] !== viewB[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ async function getPrivateKeyFromFido2Credential(
|
||||
const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue);
|
||||
return await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
keyBuffer,
|
||||
new Uint8Array(keyBuffer),
|
||||
{
|
||||
name: fido2Credential.keyAlgorithm,
|
||||
namedCurve: fido2Credential.keyCurve,
|
||||
|
||||
@@ -127,9 +127,9 @@ export class Fido2ClientService<ParentWindowReference>
|
||||
}
|
||||
|
||||
const userId = Fido2Utils.stringToBuffer(params.user.id);
|
||||
if (userId.length < 1 || userId.length > 64) {
|
||||
if (userId.byteLength < 1 || userId.byteLength > 64) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})`,
|
||||
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.byteLength})`,
|
||||
);
|
||||
throw new TypeError("Invalid 'user.id' length");
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ export class Fido2Utils {
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
static stringToBuffer(str: string): Uint8Array {
|
||||
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
|
||||
static stringToBuffer(str: string): ArrayBuffer {
|
||||
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str)).buffer;
|
||||
}
|
||||
|
||||
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { any, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { Account } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { GlobalStateProvider } from "../global-state.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { GlobalState } from "../global-state";
|
||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { SingleUserState } from "../user-state";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Observable, combineLatest, of } from "rxjs";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
import { UserKeyDefinition } from "../user-key-definition";
|
||||
import { CombinedState, SingleUserState } from "../user-state";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../spec/fake-account-service";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { KeyDefinition } from "../key-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
import { StateEventRegistrarService } from "../state-event-registrar.service";
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
} from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
import { StorageKey } from "../../../types/state";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { DebugOptions } from "../key-definition";
|
||||
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
||||
import { AbstractStorageService } from "@bitwarden/storage-core";
|
||||
|
||||
export async function getStoredValue<T>(
|
||||
key: string,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageServiceProvider,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { StateDefinition } from "./state-definition";
|
||||
import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service";
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
StorageServiceProvider,
|
||||
} from "@bitwarden/storage-core";
|
||||
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { STATE_LOCK_EVENT } from "./state-event-registrar.service";
|
||||
import { StateEventRunnerService } from "./state-event-runner.service";
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { StorageServiceProvider } from "@bitwarden/storage-core";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { StorageServiceProvider } from "../services/storage-service.provider";
|
||||
|
||||
import { GlobalState } from "./global-state";
|
||||
import { GlobalStateProvider } from "./global-state.provider";
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Opaque } from "type-fest";
|
||||
|
||||
export type Guid = Opaque<string, "Guid">;
|
||||
|
||||
export type UserId = Opaque<string, "UserId">;
|
||||
// Convenience re-export of UserId from it's original location, any library that
|
||||
// wants to be lower level than common should instead import it from user-core.
|
||||
export { UserId } from "@bitwarden/user-core";
|
||||
export type OrganizationId = Opaque<string, "OrganizationId">;
|
||||
export type CollectionId = Opaque<string, "CollectionId">;
|
||||
export type ProviderId = Opaque<string, "ProviderId">;
|
||||
|
||||
@@ -72,6 +72,7 @@ export default {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
A11yTitleDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormFieldModule,
|
||||
@@ -88,7 +89,6 @@ export default {
|
||||
TextFieldModule,
|
||||
BadgeModule,
|
||||
],
|
||||
declarations: [A11yTitleDirective],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { userEvent, getByText } from "@storybook/test";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@@ -15,6 +8,7 @@ import { ButtonModule } from "../button";
|
||||
import { CheckboxModule } from "../checkbox";
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { FormFieldModule } from "../form-field";
|
||||
import { trimValidator, forbiddenCharacters } from "../form-field/bit-validators";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { MultiSelectModule } from "../multi-select";
|
||||
import { RadioButtonModule } from "../radio-button";
|
||||
@@ -48,13 +42,19 @@ export default {
|
||||
required: "required",
|
||||
checkboxRequired: "Option is required",
|
||||
inputRequired: "Input is required.",
|
||||
inputEmail: "Input is not an email-address.",
|
||||
inputEmail: "Input is not an email address.",
|
||||
inputForbiddenCharacters: (char) =>
|
||||
`The following characters are not allowed: "${char}"`,
|
||||
inputMinValue: (min) => `Input value must be at least ${min}.`,
|
||||
inputMaxValue: (max) => `Input value must not exceed ${max}.`,
|
||||
inputMinLength: (min) => `Input value must be at least ${min} characters long.`,
|
||||
inputMaxLength: (max) => `Input value must not exceed ${max} characters in length.`,
|
||||
inputTrimValidator: `Input must not contain only whitespace.`,
|
||||
multiSelectPlaceholder: "-- Type to Filter --",
|
||||
multiSelectLoading: "Retrieving options...",
|
||||
multiSelectNotFound: "No items found",
|
||||
multiSelectClearAll: "Clear all",
|
||||
fieldsNeedAttention: "__$1__ field(s) above need your attention.",
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -72,7 +72,7 @@ export default {
|
||||
const fb = new FormBuilder();
|
||||
const exampleFormObj = fb.group({
|
||||
name: ["", [Validators.required]],
|
||||
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
|
||||
email: ["", [Validators.required, Validators.email, forbiddenCharacters(["#"])]],
|
||||
country: [undefined as string | undefined, [Validators.required]],
|
||||
groups: [],
|
||||
terms: [false, [Validators.requiredTrue]],
|
||||
@@ -80,14 +80,6 @@ const exampleFormObj = fb.group({
|
||||
age: [null, [Validators.min(0), Validators.max(150)]],
|
||||
});
|
||||
|
||||
// Custom error message, `message` is shown as the error message
|
||||
function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const forbidden = nameRe.test(control.value);
|
||||
return forbidden ? { forbiddenName: { message: "forbiddenName" } } : null;
|
||||
};
|
||||
}
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const FullExample: Story = {
|
||||
@@ -177,3 +169,95 @@ export const FullExample: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const showValidationsFormObj = fb.group({
|
||||
required: ["", [Validators.required]],
|
||||
whitespace: [" ", trimValidator],
|
||||
email: ["example?bad-email", [Validators.email]],
|
||||
minLength: ["Hello", [Validators.minLength(8)]],
|
||||
maxLength: ["Hello there", [Validators.maxLength(8)]],
|
||||
minValue: [9, [Validators.min(10)]],
|
||||
maxValue: [15, [Validators.max(10)]],
|
||||
forbiddenChars: ["Th!$ value cont#in$ forbidden char$", forbiddenCharacters(["#", "!", "$"])],
|
||||
});
|
||||
|
||||
export const Validations: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: showValidationsFormObj,
|
||||
submit: () => showValidationsFormObj.markAllAsTouched(),
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj" (ngSubmit)="submit()">
|
||||
<bit-form-field>
|
||||
<bit-label>Required validation</bit-label>
|
||||
<input bitInput formControlName="required" />
|
||||
<bit-hint>This field is required. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Email validation</bit-label>
|
||||
<input bitInput type="email" formControlName="email" />
|
||||
<bit-hint>This field contains a malformed email address. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Min length validation</bit-label>
|
||||
<input bitInput formControlName="minLength" />
|
||||
<bit-hint>Value must be at least 8 characters. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Max length validation</bit-label>
|
||||
<input bitInput formControlName="maxLength" />
|
||||
<bit-hint>Value must be less then 8 characters. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Min number value validation</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="minValue"
|
||||
/>
|
||||
<bit-hint>Value must be greater than 10. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Max number value validation</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="maxValue"
|
||||
/>
|
||||
<bit-hint>Value must be less than than 10. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Forbidden characters validation</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
formControlName="forbiddenChars"
|
||||
/>
|
||||
<bit-hint>Value must not contain '#', '!' or '$'. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>White space validation</bit-label>
|
||||
<input bitInput formControlName="whitespace" />
|
||||
<bit-hint>This input contains only white space. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<button type="submit" bitButton buttonType="primary">Submit</button>
|
||||
<bit-error-summary [formGroup]="formObj"></bit-error-summary>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
const submitButton = getByText(canvas, "Submit");
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -142,8 +142,20 @@ If a checkbox group has more than 4 options a
|
||||
|
||||
<Canvas of={checkboxStories.Default} />
|
||||
|
||||
## Validation messages
|
||||
|
||||
These are examples of our default validation error messages:
|
||||
|
||||
<Canvas of={formStories.Validations} />
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Icon Buttons in Form Fields
|
||||
|
||||
When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle`
|
||||
directive to provide a label for screenreaders. Typically, the label should follow this pattern:
|
||||
`{Action} {field label}`, i.e. "Copy username".
|
||||
|
||||
### Required Fields
|
||||
|
||||
- Use "(required)" in the label of each required form field styled the same as the field's helper
|
||||
@@ -152,12 +164,6 @@ If a checkbox group has more than 4 options a
|
||||
helper text.
|
||||
- **Example:** "Billing Email is required if owned by a business".
|
||||
|
||||
### Icon Buttons in Form Fields
|
||||
|
||||
When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle`
|
||||
directive to provide a label for screenreaders. Typically, the label should follow this pattern:
|
||||
`{Action} {field label}`, i.e. "Copy username".
|
||||
|
||||
### Form Field Errors
|
||||
|
||||
- When a resting field is filled out, validation is triggered when the user de-focuses the field
|
||||
|
||||
5
libs/logging/README.md
Normal file
5
libs/logging/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# logging
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Logging primitives
|
||||
3
libs/logging/eslint.config.mjs
Normal file
3
libs/logging/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/logging/jest.config.js
Normal file
10
libs/logging/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "logging",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/logging",
|
||||
};
|
||||
11
libs/logging/package.json
Normal file
11
libs/logging/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/logging",
|
||||
"version": "0.0.1",
|
||||
"description": "Logging primitives",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "platform"
|
||||
}
|
||||
33
libs/logging/project.json
Normal file
33
libs/logging/project.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "logging",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/logging/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/logging",
|
||||
"main": "libs/logging/src/index.ts",
|
||||
"tsConfig": "libs/logging/tsconfig.lib.json",
|
||||
"assets": ["libs/logging/*.md"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/logging/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/logging/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
libs/logging/src/console-log.service.ts
Normal file
57
libs/logging/src/console-log.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { LogLevel } from "./log-level";
|
||||
import { LogService } from "./log.service";
|
||||
|
||||
export class ConsoleLogService implements LogService {
|
||||
protected timersMap: Map<string, [number, number]> = new Map();
|
||||
|
||||
constructor(
|
||||
protected isDev: boolean,
|
||||
protected filter: ((level: LogLevel) => boolean) | null = null,
|
||||
) {}
|
||||
|
||||
debug(message?: any, ...optionalParams: any[]) {
|
||||
if (!this.isDev) {
|
||||
return;
|
||||
}
|
||||
this.write(LogLevel.Debug, message, ...optionalParams);
|
||||
}
|
||||
|
||||
info(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevel.Info, message, ...optionalParams);
|
||||
}
|
||||
|
||||
warning(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevel.Warning, message, ...optionalParams);
|
||||
}
|
||||
|
||||
error(message?: any, ...optionalParams: any[]) {
|
||||
this.write(LogLevel.Error, message, ...optionalParams);
|
||||
}
|
||||
|
||||
write(level: LogLevel, message?: any, ...optionalParams: any[]) {
|
||||
if (this.filter != null && this.filter(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
// eslint-disable-next-line
|
||||
console.log(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevel.Info:
|
||||
// eslint-disable-next-line
|
||||
console.log(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
// eslint-disable-next-line
|
||||
console.warn(message, ...optionalParams);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
// eslint-disable-next-line
|
||||
console.error(message, ...optionalParams);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
libs/logging/src/index.ts
Normal file
3
libs/logging/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LogService } from "./log.service";
|
||||
export { LogLevel } from "./log-level";
|
||||
export { ConsoleLogService } from "./console-log.service";
|
||||
8
libs/logging/src/log-level.ts
Normal file
8
libs/logging/src/log-level.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum LogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
9
libs/logging/src/log.service.ts
Normal file
9
libs/logging/src/log.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { LogLevel } from "./log-level";
|
||||
|
||||
export abstract class LogService {
|
||||
abstract debug(message?: any, ...optionalParams: any[]): void;
|
||||
abstract info(message?: any, ...optionalParams: any[]): void;
|
||||
abstract warning(message?: any, ...optionalParams: any[]): void;
|
||||
abstract error(message?: any, ...optionalParams: any[]): void;
|
||||
abstract write(level: LogLevel, message?: any, ...optionalParams: any[]): void;
|
||||
}
|
||||
8
libs/logging/src/logging.spec.ts
Normal file
8
libs/logging/src/logging.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("logging", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
13
libs/logging/tsconfig.json
Normal file
13
libs/logging/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/logging/tsconfig.lib.json
Normal file
10
libs/logging/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
16
libs/logging/tsconfig.spec.json
Normal file
16
libs/logging/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../..//dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/intercept-console.ts"
|
||||
]
|
||||
}
|
||||
6
libs/user-core/README.md
Normal file
6
libs/user-core/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# user-core
|
||||
|
||||
Owned by: auth
|
||||
|
||||
The very basic concept that constitutes a user, this needs to be very low level to facilitate
|
||||
Platform keeping their own code low level.
|
||||
3
libs/user-core/eslint.config.mjs
Normal file
3
libs/user-core/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/user-core/jest.config.js
Normal file
10
libs/user-core/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: "user-core",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/libs/user-core",
|
||||
};
|
||||
10
libs/user-core/package.json
Normal file
10
libs/user-core/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@bitwarden/user-core",
|
||||
"version": "0.0.0",
|
||||
"description": "The very basic concept that constitutes a user, this needs to be very low level to facilitate Platform keeping their own code low level.",
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "auth"
|
||||
}
|
||||
27
libs/user-core/project.json
Normal file
27
libs/user-core/project.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "user-core",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/user-core/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/user-core",
|
||||
"main": "libs/user-core/src/index.ts",
|
||||
"tsConfig": "libs/user-core/tsconfig.lib.json",
|
||||
"assets": ["libs/user-core/*.md"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/user-core/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
libs/user-core/src/index.ts
Normal file
9
libs/user-core/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
/**
|
||||
* The main identifier for a user. It is a string that should be in valid guid format.
|
||||
*
|
||||
* You should avoid `as UserId`-ing strings as much as possible and instead retrieve the {@see UserId} from
|
||||
* a valid source instead.
|
||||
*/
|
||||
export type UserId = Opaque<string, "UserId">;
|
||||
13
libs/user-core/tsconfig.json
Normal file
13
libs/user-core/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/user-core/tsconfig.lib.json
Normal file
10
libs/user-core/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/user-core/tsconfig.spec.json
Normal file
10
libs/user-core/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
@@ -243,6 +244,7 @@ export default {
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag: () => Promise.resolve(false),
|
||||
getFeatureFlag$: () => new BehaviorSubject(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -253,6 +255,12 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PolicyService,
|
||||
useValue: {
|
||||
policiesByType$: new BehaviorSubject([]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
componentWrapperDecorator(
|
||||
|
||||
@@ -3,18 +3,24 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testin
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } 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 { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { SelectComponent } from "@bitwarden/components";
|
||||
|
||||
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
|
||||
import {
|
||||
CipherFormConfig,
|
||||
OptionalInitialValues,
|
||||
} from "../../abstractions/cipher-form-config.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
import { ItemDetailsSectionComponent } from "./item-details-section.component";
|
||||
@@ -48,6 +54,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
let fixture: ComponentFixture<ItemDetailsSectionComponent>;
|
||||
let cipherFormProvider: MockProxy<CipherFormContainer>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
|
||||
const getInitialCipherView = jest.fn(() => null);
|
||||
@@ -66,12 +74,19 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
compare: (a: string, b: string) => a.localeCompare(b),
|
||||
} as Intl.Collator;
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: CipherFormContainer, useValue: cipherFormProvider },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: AccountService, useValue: { activeAccount$ } },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -369,7 +384,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
expect(collectionSelect).toBeNull();
|
||||
});
|
||||
|
||||
it("should enable/show collection control when an organization is selected", async () => {
|
||||
it("should enable/show collection control when an organization is selected", fakeAsync(() => {
|
||||
component.config.organizationDataOwnershipDisabled = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
@@ -378,12 +393,12 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
];
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
tick();
|
||||
|
||||
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
||||
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const collectionSelect = fixture.nativeElement.querySelector(
|
||||
"bit-multi-select[formcontrolname='collectionIds']",
|
||||
@@ -391,7 +406,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true);
|
||||
expect(collectionSelect).not.toBeNull();
|
||||
});
|
||||
}));
|
||||
|
||||
it("should set collectionIds to originalCipher collections on first load", async () => {
|
||||
component.config.mode = "clone";
|
||||
@@ -488,6 +503,9 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
|
||||
});
|
||||
});
|
||||
@@ -548,4 +566,27 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
expect(label).toBe("org1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultCollectionId", () => {
|
||||
it("returns matching default when flag & policy match", async () => {
|
||||
const def = createMockCollection("def1", "Def", "orgA");
|
||||
component.config.collections = [def] as CollectionView[];
|
||||
component.config.initialValues = { collectionIds: [] } as OptionalInitialValues;
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy]));
|
||||
|
||||
const id = await (component as any).getDefaultCollectionId("orgA");
|
||||
expect(id).toEqual("def1");
|
||||
});
|
||||
|
||||
it("returns undefined when no default found", async () => {
|
||||
component.config.collections = [createMockCollection("c1", "C1", "orgB")] as CollectionView[];
|
||||
component.config.initialValues = { collectionIds: [] } as OptionalInitialValues;
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy]));
|
||||
|
||||
const result = await (component as any).getDefaultCollectionId("orgA");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,19 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { concatMap, map } from "rxjs";
|
||||
import { concatMap, firstValueFrom, map } 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 { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
@@ -124,6 +128,8 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
private i18nService: I18nService,
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private policyService: PolicyService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
|
||||
this.itemDetailsForm.valueChanges
|
||||
@@ -200,30 +206,61 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
if (prefillCipher) {
|
||||
await this.initFromExistingCipher(prefillCipher);
|
||||
} else {
|
||||
const orgId = this.initialValues?.organizationId;
|
||||
this.itemDetailsForm.setValue({
|
||||
name: this.initialValues?.name || "",
|
||||
organizationId: this.initialValues?.organizationId || this.defaultOwner,
|
||||
organizationId: orgId || this.defaultOwner,
|
||||
folderId: this.initialValues?.folderId || null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
});
|
||||
await this.updateCollectionOptions(this.initialValues?.collectionIds || []);
|
||||
await this.updateCollectionOptions(this.initialValues?.collectionIds);
|
||||
}
|
||||
|
||||
if (!this.allowOwnershipChange) {
|
||||
this.itemDetailsForm.controls.organizationId.disable();
|
||||
}
|
||||
|
||||
this.itemDetailsForm.controls.organizationId.valueChanges
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
concatMap(async () => {
|
||||
await this.updateCollectionOptions();
|
||||
}),
|
||||
concatMap(async () => await this.updateCollectionOptions()),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default collection IDs for the selected organization.
|
||||
* Returns null if any of the following apply:
|
||||
* - the feature flag is disabled
|
||||
* - no org is currently selected
|
||||
* - the selected org doesn't have the "no private data policy" enabled
|
||||
*/
|
||||
private async getDefaultCollectionId(orgId?: OrganizationId) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
const isFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.CreateDefaultLocation,
|
||||
);
|
||||
if (!isFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const selectedOrgHasPolicyEnabled = (
|
||||
await firstValueFrom(
|
||||
this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId),
|
||||
)
|
||||
).find((p) => p.organizationId);
|
||||
if (!selectedOrgHasPolicyEnabled) {
|
||||
return;
|
||||
}
|
||||
const defaultUserCollection = this.collections.find(
|
||||
(c) => c.organizationId === orgId && c.type === CollectionTypes.DefaultUserCollection,
|
||||
);
|
||||
// If the user was added after the policy was enabled as they will not have any private data
|
||||
// and will not have a default collection.
|
||||
return defaultUserCollection?.id;
|
||||
}
|
||||
|
||||
private async initFromExistingCipher(prefillCipher: CipherView) {
|
||||
const { name, folderId, collectionIds } = prefillCipher;
|
||||
|
||||
@@ -332,6 +369,11 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
// Non-admins can only select assigned collections that are not read only. (Non-AC)
|
||||
return c.assigned && !c.readOnly;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIsDefaultCollection = a.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
|
||||
const bIsDefaultCollection = b.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
|
||||
return aIsDefaultCollection - bIsDefaultCollection;
|
||||
})
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
@@ -349,10 +391,17 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startingSelection.length > 0) {
|
||||
if (startingSelection.filter(Boolean).length > 0) {
|
||||
collectionsControl.setValue(
|
||||
this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)),
|
||||
);
|
||||
} else {
|
||||
const defaultCollectionId = await this.getDefaultCollectionId(orgId);
|
||||
if (defaultCollectionId) {
|
||||
collectionsControl.setValue(
|
||||
this.collectionOptions.filter((c) => c.id === defaultCollectionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -197,11 +197,11 @@
|
||||
},
|
||||
"apps/browser": {
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.6.0"
|
||||
"version": "2025.6.1"
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@bitwarden/cli",
|
||||
"version": "2025.6.0",
|
||||
"version": "2025.6.1",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"dependencies": {
|
||||
"@koa/multer": "3.1.0",
|
||||
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.6.1",
|
||||
"version": "2025.7.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
@@ -353,6 +353,11 @@
|
||||
"version": "0.0.0",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"libs/logging": {
|
||||
"name": "@bitwarden/logging",
|
||||
"version": "0.0.1",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"libs/node": {
|
||||
"name": "@bitwarden/node",
|
||||
"version": "0.0.0",
|
||||
@@ -423,6 +428,11 @@
|
||||
"version": "0.0.0",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"libs/user-core": {
|
||||
"name": "@bitwarden/user-core",
|
||||
"version": "0.0.0",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"libs/vault": {
|
||||
"name": "@bitwarden/vault",
|
||||
"version": "0.0.0",
|
||||
@@ -4583,6 +4593,10 @@
|
||||
"resolved": "libs/key-management-ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/logging": {
|
||||
"resolved": "libs/logging",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/node": {
|
||||
"resolved": "libs/node",
|
||||
"link": true
|
||||
@@ -4632,6 +4646,10 @@
|
||||
"resolved": "libs/ui/common",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/user-core": {
|
||||
"resolved": "libs/user-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitwarden/vault": {
|
||||
"resolved": "libs/vault",
|
||||
"link": true
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@bitwarden/importer-ui": ["./libs/importer/src/components"],
|
||||
"@bitwarden/key-management": ["./libs/key-management/src"],
|
||||
"@bitwarden/key-management-ui": ["./libs/key-management-ui/src"],
|
||||
"@bitwarden/logging": ["libs/logging/src"],
|
||||
"@bitwarden/node/*": ["./libs/node/src/*"],
|
||||
"@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"],
|
||||
"@bitwarden/platform": ["./libs/platform/src"],
|
||||
@@ -46,6 +47,7 @@
|
||||
"@bitwarden/storage-test-utils": ["libs/storage-test-utils/src/index.ts"],
|
||||
"@bitwarden/ui-common": ["./libs/ui/common/src"],
|
||||
"@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"],
|
||||
"@bitwarden/user-core": ["libs/user-core/src/index.ts"],
|
||||
"@bitwarden/vault": ["./libs/vault/src"],
|
||||
"@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"],
|
||||
"@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"],
|
||||
|
||||
Reference in New Issue
Block a user