1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 03:33:30 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Conner Turnbull
2026-02-02 08:42:36 -05:00
committed by GitHub
52 changed files with 485 additions and 651 deletions

View File

@@ -236,7 +236,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -399,7 +399,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -562,7 +562,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -827,7 +827,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -1032,14 +1032,14 @@ jobs:
- name: Cache Build
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Cache Safari
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@@ -1185,7 +1185,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -1272,14 +1272,14 @@ jobs:
- name: Get Build Cache
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Setup Safari Cache
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@@ -1409,7 +1409,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@@ -1547,14 +1547,14 @@ jobs:
- name: Get Build Cache
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Setup Safari Cache
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@@ -1692,7 +1692,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |

View File

@@ -65,7 +65,7 @@ jobs:
- name: Cache NPM
id: npm-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: "~/.npm"
key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }}

View File

@@ -109,7 +109,7 @@ describe("AttachmentsV2Component", () => {
});
it("passes the submit button to the cipher attachments component", () => {
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[0]
.componentInstance;
expect(cipherAttachment.submitBtn()).toEqual(submitBtn);

View File

@@ -918,6 +918,7 @@ dependencies = [
"oo7",
"pin-project",
"rand 0.9.2",
"rsa",
"scopeguard",
"secmem-proc",
"security-framework",
@@ -2115,10 +2116,11 @@ dependencies = [
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
dependencies = [
"byteorder",
"lazy_static",
"libm",
"num-integer",
@@ -2805,9 +2807,9 @@ dependencies = [
[[package]]
name = "rsa"
version = "0.9.10"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
dependencies = [
"const-oid",
"digest",

View File

@@ -50,7 +50,7 @@ oo7 = "=0.5.0"
pin-project = "=1.1.10"
pkcs8 = "=0.10.2"
rand = "=0.9.2"
rsa = "=0.9.10"
rsa = "=0.9.6"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
secmem-proc = "=0.3.7"

View File

@@ -31,6 +31,7 @@ futures = { workspace = true }
interprocess = { workspace = true, features = ["tokio"] }
memsec = { workspace = true, features = ["alloc_ext"] }
rand = { workspace = true }
rsa = "=0.9.6"
sha2 = { workspace = true }
ssh-key = { workspace = true, features = [
"encryption",
@@ -85,5 +86,8 @@ windows = { workspace = true, features = [
], optional = true }
windows-future = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[lints]
workspace = true

View File

@@ -307,3 +307,128 @@ fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Err
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use ssh_key::Signature;
use super::*;
// Test Ed25519 key (unencrypted OpenSSH format)
const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9gAAAJj79ujB+/bo
wQAAAAtzc2gtZWQyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9g
AAAEAgAQkLDKjON00XO+Y09BoIBuQsAXAx6HUhQoTEodVzig5iivf6TICxdizawaKSZS6G
nGZV/aEAZ3ZMrsrA3g32AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ==
-----END OPENSSH PRIVATE KEY-----";
// Test RSA 2048-bit key (unencrypted OpenSSH format)
const TEST_RSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAy0YUFvgBLMZXIKjsBfcdO6N2Kk2VmjSpxa2aFD1TrAcVyyIZ9v8o
slQITyFL4GCK5VCJX9bqXBwc9ml8G/zt21ue6nadeZLhp2iXeQ+VUxmola9HhaFvxSNqi0
MOJaWIfmisH4jt7Msdv4jwlDE5AkHAFig8wiwDgvSV3kmfhyPs38aq8Pa+wT3zBneGXT17
34OhH4nicuq+L0GcR9BJQ5+jXNQIgGdqd7sKa8JchPXLXAbTug2SfwRmKgiCM0L6JQ5NSQ
FdRHW/iz4ARacSkHP3w0pH6ZtAd8+glzvZn1KcXwrN/CYl3fqFwiwcQXIF0KDoOI/UyiKZ
uDE+DW5M1wAAA8g2Sf0XNkn9FwAAAAdzc2gtcnNhAAABAQDLRhQW+AEsxlcgqOwF9x07o3
YqTZWaNKnFrZoUPVOsBxXLIhn2/yiyVAhPIUvgYIrlUIlf1upcHBz2aXwb/O3bW57qdp15
kuGnaJd5D5VTGaiVr0eFoW/FI2qLQw4lpYh+aKwfiO3syx2/iPCUMTkCQcAWKDzCLAOC9J
XeSZ+HI+zfxqrw9r7BPfMGd4ZdPXvfg6EfieJy6r4vQZxH0ElDn6Nc1AiAZ2p3uwprwlyE
9ctcBtO6DZJ/BGYqCIIzQvolDk1JAV1Edb+LPgBFpxKQc/fDSkfpm0B3z6CXO9mfUpxfCs
38JiXd+oXCLBxBcgXQoOg4j9TKIpm4MT4NbkzXAAAAAwEAAQAAAQB9HWssIAYJGyNxlMeB
fHJfzOLkctCME7ITXCEkKAMiNVIyr5CvuKnB6XsbyXC8cG/NaV7EwLGLdDpXaOHdEDcO9z
u/MLcIp2GA+x2QhAjzFy3uw+4P0CfNfVkM0n8YqOR0edTHrC5Vu0daJt19OTbPrsyeVrHf
Cdw3dHfyU/p+4IMP9NRA5ZSmYuOacC7ZoZU7xeVBpeZ4KEzrO98iIWtscncaQv4AcaAehL
VpvZWG1QmRhdbooU2ce5KH3aFKiyszcMGPMzn4aTZS14ycLFzmrMSa+nYf+nHXmyR5KmBd
A5P6ZLtcpT1xw6CC/ItRsdD7E67bugG38lgQpzloHAsRAAAAgBVKGMFi+lP+HKYdSzPAQN
n3HxVuuZ5VIjM6Rq2SxfdyGKj5PH4+ofNGBrF5j1du1oqfPypMM/B75bkBNOlzn6TQcgyX
YlsVOF31aE1hRg8eN1BH2bc1DC43MyTHgunAFzIYfs1hbX8i+cMybzXSTDsIc/xvQHkJ2w
TrPuz7+MATAAAAgQDk6e4ywxrINaOcuDKmRQxTs7rlkJk/tX59OkkqD/gYLMBRMfeKeuFD
Y8M1f5vlDkGFD/Jy0RtTfEJh02VjKTrszaaGCDFHe9tt6DAHY457tzr856zsq5hKDFEU0+
jd+yE8QaloegGrcpujrxHnrpZx/7mA2qjQxLveHyCGWH3Q2wAAAIEA41N7DKxeb0doXai7
Sl8+RpZBoyCyNkexWKHAeATKb4abd+k5/EEoLAb6aKaGMzMPm+s82l0lozVreKvHdAdZsY
fq1lhaVvnRWZhN/DXf7Akgicrg/TLqHH9w6db0Vg5A+zHmbkUzZ4A30CYIgn4vzVv5YIq3
CmfliIQWtUylhrUAAAAQdGVzdEBleGFtcGxlLmNvbQECAw==
-----END OPENSSH PRIVATE KEY-----";
fn create_test_agent() -> (
BitwardenDesktopAgent,
tokio::sync::mpsc::Receiver<SshAgentUIRequest>,
tokio::sync::broadcast::Sender<(u32, bool)>,
) {
let (request_tx, request_rx) = tokio::sync::mpsc::channel::<SshAgentUIRequest>(16);
let (response_tx, response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(16);
let response_rx = Arc::new(Mutex::new(response_rx));
let agent = BitwardenDesktopAgent::new(request_tx, response_rx);
(agent, request_rx, response_tx)
}
#[tokio::test]
async fn test_agent_sign_with_ed25519_key() {
let (mut agent, _request_rx, _response_tx) = create_test_agent();
agent.is_running.store(true, Ordering::Relaxed);
let keys = vec![(
TEST_ED25519_KEY.to_string(),
"ed25519-key".to_string(),
"ed25519-uuid".to_string(),
)];
agent.set_keys(keys).expect("set_keys should succeed");
let keystore = agent.keystore.0.read().expect("RwLock is not poisoned");
assert_eq!(keystore.len(), 1);
let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key");
// Verify the key metadata
assert_eq!(ssh_key.name, "ed25519-key");
assert_eq!(ssh_key.cipher_uuid, "ed25519-uuid");
// Verify the key can sign data
let signing_key = ssh_key.private_key().expect("should have signing key");
let message = b"test message for ed25519";
let signature: Signature = signing_key.try_sign(message).expect("signing should work");
// Verify signature is non-empty and has expected algorithm
assert!(!signature.as_bytes().is_empty());
assert_eq!(signature.algorithm(), ssh_key::Algorithm::Ed25519);
}
#[tokio::test]
async fn test_agent_sign_with_rsa_key() {
let (mut agent, _request_rx, _response_tx) = create_test_agent();
agent.is_running.store(true, Ordering::Relaxed);
let keys = vec![(
TEST_RSA_KEY.to_string(),
"rsa-key".to_string(),
"rsa-uuid".to_string(),
)];
agent.set_keys(keys).expect("set_keys should succeed");
let keystore = agent.keystore.0.read().expect("RwLock is not poisoned");
assert_eq!(keystore.len(), 1);
let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key");
// Verify the key metadata
assert_eq!(ssh_key.name, "rsa-key");
assert_eq!(ssh_key.cipher_uuid, "rsa-uuid");
// Verify the key can sign data
let signing_key = ssh_key.private_key().expect("should have signing key");
let message = b"test message for rsa";
let signature: Signature = signing_key.try_sign(message).expect("signing should work");
// Verify signature is non-empty and has expected algorithm
assert!(!signature.as_bytes().is_empty());
assert_eq!(
signature.algorithm(),
ssh_key::Algorithm::Rsa {
hash: Some(ssh_key::HashAlg::Sha512)
}
);
}
}

View File

@@ -87,6 +87,10 @@ describe("DesktopSetInitialPasswordService", () => {
expect(sut).not.toBeFalsy();
});
/**
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
@@ -116,6 +120,8 @@ describe("DesktopSetInitialPasswordService", () => {
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as MasterPasswordSalt,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;

View File

@@ -54,6 +54,9 @@ export class DesktopSetInitialPasswordService
);
}
/**
* @deprecated To be removed in PM-28143
*/
override async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,

View File

@@ -518,6 +518,7 @@ export class VaultV2Component<C extends CipherViewLike>
}
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: this.cipherId as CipherId,
canEditCipher: this.cipher().edit,
});
const result = await firstValueFrom(dialogRef.closed).catch((): any => null);
if (

View File

@@ -90,6 +90,10 @@ describe("WebSetInitialPasswordService", () => {
expect(sut).not.toBeFalsy();
});
/**
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
@@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => {
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as MasterPasswordSalt,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;

View File

@@ -56,6 +56,9 @@ export class WebSetInitialPasswordService
);
}
/**
* @deprecated To be removed in PM-28143
*/
override async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,

View File

@@ -8,7 +8,7 @@
[ngClass]="{ 'tw-grayscale': disabled }"
>
<div class="tw-m-auto tw-size-20 tw-content-center">
<bit-svg [content]="icon" aria-hidden="true"></bit-svg>
<bit-svg [content]="icon" aria-hidden="true" class="tw-h-full"></bit-svg>
</div>
</div>
<bit-card-content [ngClass]="{ 'tw-grayscale': disabled }">

View File

@@ -3,8 +3,7 @@ import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import {
emailAndOtpRequiredEmailSent,
emailInvalid,
emailAndOtpRequired,
emailRequired,
otpInvalid,
passwordHashB64Invalid,
@@ -161,7 +160,7 @@ export class SendAuthComponent implements OnInit {
this.expiredAuthAttempts = 0;
if (emailRequired(response.error)) {
this.sendAuthType.set(AuthType.Email);
} else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) {
} else if (emailAndOtpRequired(response.error)) {
this.enterOtp.set(true);
} else if (otpInvalid(response.error)) {
this.toastService.showToast({

View File

@@ -925,6 +925,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: cipher.id as CipherId,
organizationId: cipher.organizationId as OrganizationId,
canEditCipher: cipher.edit,
});
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);

View File

@@ -63,6 +63,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected registerSdkService: RegisterSdkService,
) {}
/**
* @deprecated To be removed in PM-28143. When you remove this, also check for any objects/methods
* in this default service that are now un-used and can also be removed.
*/
async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,
@@ -333,6 +337,9 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
);
}
/**
* @deprecated To be removed in PM-28143
*/
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
@@ -410,6 +417,8 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
}
/**
* @deprecated To be removed in PM-28143
*
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
* Without this, immediately locking/unlocking the vault with the new password _may_ still fail

View File

@@ -124,6 +124,10 @@ describe("DefaultSetInitialPasswordService", () => {
expect(sut).not.toBeFalsy();
});
/**
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;

View File

@@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { HashPurpose } from "@bitwarden/common/platform/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
@@ -38,6 +39,7 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -76,6 +78,7 @@ export class SetInitialPasswordComponent implements OnInit {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private dialogService: DialogService,
private i18nService: I18nService,
private keyService: KeyService,
private logoutService: LogoutService,
private logService: LogService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
@@ -110,16 +113,72 @@ export class SetInitialPasswordComponent implements OnInit {
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
/**
* "KM flag" = EnableAccountEncryptionV2JitPasswordRegistration
* "Auth flag" = PM27086_UpdateAuthenticationApisForInputPassword (checked in InputPasswordComponent and
* passed through via PasswordInputResult)
*
* Flag unwinding for this specific `case` will depend on which flag gets unwound first:
* - If KM flag gets unwound first, remove all code (in this `case`) after the call
* to setInitialPasswordJitMPUserV2Encryption(), as the V2Encryption method is the
* end-goal for this `case`.
* - If Auth flag gets unwound first (in PM-28143), keep the KM code & early return,
* but unwind the auth flagging logic and then remove the method call marked with
* the "Default Scenario" comment.
*/
const accountEncryptionV2 = await this.configService.getFeatureFlag(
FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
);
// Scenario 1: KM flag ON
if (accountEncryptionV2) {
await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
return;
}
await this.setInitialPassword(passwordInputResult);
// Scenario 2: KM flag OFF, Auth flag ON
if (passwordInputResult.newApisWithInputPasswordFlagEnabled) {
/**
* If the Auth flag is enabled, it means the InputPasswordComponent will not emit a newMasterKey,
* newServerMasterKeyHash, and newLocalMasterKeyHash. So we must create them here and add them late
* to the PasswordInputResult before calling setInitialPassword().
*
* This is a temporary state. The end-goal will be to use KM's V2Encryption method above.
*/
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertTruthy(this.email, "email", ctx);
const newMasterKey = await this.keyService.makeMasterKey(
passwordInputResult.newPassword,
this.email.trim().toLowerCase(),
passwordInputResult.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
passwordInputResult.newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
passwordInputResult.newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.newMasterKey = newMasterKey;
passwordInputResult.newServerMasterKeyHash = newServerMasterKeyHash;
passwordInputResult.newLocalMasterKeyHash = newLocalMasterKeyHash;
await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the SetInitialPasswordComponent (just above)
return;
}
// Default Scenario: both flags OFF
await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the InputPasswordComponent (default)
break;
}
@@ -274,6 +333,9 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
/**
* @deprecated To be removed in PM-28143
*/
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);

View File

@@ -87,6 +87,8 @@ export interface InitializeJitPasswordCredentials {
*/
export abstract class SetInitialPasswordService {
/**
* @deprecated To be removed in PM-28143
*
* Sets an initial password for an existing authed user who is either:
* - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER}
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}

View File

@@ -10,7 +10,9 @@ import {
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit {
constructor(
private auditService: AuditService,
private cipherService: CipherService,
private configService: ConfigService,
private dialogService: DialogService,
private formBuilder: FormBuilder,
private i18nService: I18nService,
@@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit {
}
if (!this.email) {
throw new Error("Email is required to create master key.");
throw new Error("Email not found.");
}
// 1. Determine kdfConfig
@@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
throw new Error("userId not found.");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
throw new Error("KdfConfig not found.");
}
const salt =
@@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit {
? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId))
: this.masterPasswordService.emailToSalt(this.email);
if (salt == null) {
throw new Error("Salt is required to create master key.");
throw new Error("Salt not found.");
}
// 2. Verify current password is correct (if necessary)
@@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit {
return;
}
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
// 4. Build a PasswordInputResult object
const passwordInputResult: PasswordInputResult = {
newPassword,
kdfConfig: this.kdfConfig,
salt,
newPasswordHint,
newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = currentPassword;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit and return PasswordInputResult object
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
}
/*******************************************************************
* The following code (within this `try`) to be removed in PM-28143
*******************************************************************/
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,

View File

@@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials.
Specifically, it does the following:
The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting
an initial password or changing an existing password. Specifically, it does the following:
1. Displays form fields in the UI
2. Validates form fields
3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`,
`newServerMasterKeyHash`, etc.)
4. Emits the generated properties to the parent component
3. Emits values to the parent component
The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our
form UI and validation logic consistent. As such, it is intended for re-use in different set/change
@@ -30,7 +28,6 @@ those values as needed.
- [The InputPasswordFlow](#the-inputpasswordflow)
- [Use Cases](#use-cases)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
@@ -44,20 +41,20 @@ those values as needed.
**Required**
- `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine
which form input elements will be displayed in the UI and which cryptographic keys will be created
and emitted. [Click here](#the-inputpasswordflow) to learn more about the different
`InputPasswordFlow` options.
which form input elements will be displayed in the UI and which values will be emitted.
[Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow`
options.
**Optional (sometimes)**
These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs`
are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that
the `email` and/or `userId` is present in certain flows, while not present in other flows.
These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are
not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the
`email` and/or `userId` is present in certain flows, while not present in other flows.
- `email` - allows the `InputPasswordComponent` to generate a master key
- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed)
- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`,
verify that a current password is correct, and perform validation prior to user key rotation on
the parent
verify that a current password is correct, and perform validation prior to user key rotation (if
selected) on the parent
**Optional**
@@ -87,8 +84,7 @@ These `@Inputs` are truly optional.
## The `InputPasswordFlow`
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component. It is important for the dev to understand when to use
each flow.
logic of the component. It is important for the dev to understand when to use each flow.
### Use Cases
@@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset
password permission ("manage account recovery") from the start, which requires them to have/set
their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This is
@@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their
initial password, their account does not yet exist in the database, and will only be created
once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset
password persmission ("manage account recovery"), which now requires the user to have/set their
initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must now
have/set their initial password<br /><br />
@@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
Used in scenarios where we simply want to offer the user the ability to change their password:
- User clicks an org email invite link an logs in with their password which does not meet the org's
- User clicks an org email invite link and logs in with their password which does not meet the org's
policy requirements
- User logs in with password that does not meet the org's policy requirements
- User logs in after their password was reset via Account Recovery (and now they must change their
@@ -156,26 +154,10 @@ which form field UI elements get displayed.
<br />
### TypeScript - Credential Generation
- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`**
- These flows involve a user setting their password for the first time. Therefore on submit the
component will only generate new credentials (`newMasterKey`) and not current credentials
(`currentMasterKey`).<br /><br />
- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`**
- These flows both require the user to enter a current password along with a new password.
Therefore on submit the component will generate current credentials (`currentMasterKey`) along
with new credentials (`newMasterKey`).<br /><br />
- **`ChangePasswordDelegation`**
- This flow does not generate any credentials, but simply validates the new password and emits it
up to the parent.
<br />
### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser`
These two flows are similar in that they display the same form fields and only generate new
credentials, but we need to keep them separate for the following reasons:
These two flows are similar in that they display the same form fields, but we need to keep them
separate for the following reasons:
- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and
**thus NO active account `userId`**:
@@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons:
and **thus an active account `userId`**:
The presence or absence of an active account `userId` is important because it determines how we get
the correct `kdfConfig` prior to key generation:
the correct `kdfConfig`:
- If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG`
- If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the
@@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
checkbox)
- Checks that the new password adheres to any enforced master password policies that were
optionally passed down by the parent
2. Uses the form inputs to create cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.)
3. Emits those cryptographic properties up to the parent (along with other values defined in
`PasswordInputResult`) to be used by the parent as needed.
2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be
used by the parent as needed.
```typescript
export interface PasswordInputResult {
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig?: KdfConfig;
salt?: MasterPasswordSalt;
newPasswordHint?: string;
rotateUserKey?: boolean;
}
```

View File

@@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -59,6 +60,13 @@ export default {
getAllDecrypted: () => Promise.resolve([]),
},
},
// Can remove ConfigService from component and stories in PM-28143 (if it is no longer used)
{
provide: ConfigService,
useValue: {
getFeatureFlag: () => false, // default to false since flag does not effect UI
},
},
{
provide: KdfConfigService,
useValue: {

View File

@@ -10,6 +10,20 @@ export interface PasswordInputResult {
newPasswordHint?: string;
rotateUserKey?: boolean;
/**
* Temporary property that persists the flag state through the entire set/change password process.
* This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves.
*
* The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever
* require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off.
* Flagging for those 2 flows will be done via the ConfigService in their respective services.
*
* To be removed in PM-28143
*/
newApisWithInputPasswordFlagEnabled?: boolean;
// The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143
/** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */
currentMasterKey?: MasterKey;
/** @deprecated */

View File

@@ -64,14 +64,13 @@ describe("SendTokenService", () => {
"send_id_required",
"password_hash_b64_required",
"email_required",
"email_and_otp_required_otp_sent",
"email_and_otp_required",
"unknown",
];
const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [
"send_id_invalid",
"password_hash_b64_invalid",
"email_invalid",
"otp_invalid",
"otp_generation_failed",
"unknown",

View File

@@ -31,13 +31,6 @@ export function passwordHashB64Invalid(
return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid";
}
export type EmailInvalid = InvalidGrant & {
send_access_error_type: "email_invalid";
};
export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid {
return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid";
}
export type OtpInvalid = InvalidGrant & {
send_access_error_type: "otp_invalid";
};

View File

@@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq
return e.error === "invalid_request" && e.send_access_error_type === "email_required";
}
export type EmailAndOtpRequiredEmailSent = InvalidRequest & {
send_access_error_type: "email_and_otp_required_otp_sent";
export type EmailAndOtpRequired = InvalidRequest & {
send_access_error_type: "email_and_otp_required";
};
export function emailAndOtpRequiredEmailSent(
e: SendAccessTokenApiErrorResponse,
): e is EmailAndOtpRequiredEmailSent {
return (
e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent"
);
export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired {
return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required";
}
export type UnknownInvalidRequest = InvalidRequest & {

View File

@@ -18,6 +18,7 @@ export enum FeatureFlag {
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
SafariAccountSwitching = "pm-5594-safari-account-switching",
/* Autofill */
@@ -137,6 +138,7 @@ export const DefaultFeatureFlagValue = {
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
[FeatureFlag.SafariAccountSwitching]: FALSE,
/* Billing */

View File

@@ -16,9 +16,9 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DrawerService } from "../drawer/drawer.service";
import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
import { DrawerService } from "./drawer.service";
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
import { SimpleDialogOptions } from "./simple-dialog/types";

View File

@@ -1,27 +0,0 @@
import { CdkScrollable } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { hasScrolledFrom } from "../utils/has-scrolled-from";
/**
* Body container for `bit-drawer`
*/
@Component({
selector: "bit-drawer-body",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
host: {
class:
"tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
},
hostDirectives: [
{
directive: CdkScrollable,
},
],
template: ` <ng-content></ng-content> `,
})
export class DrawerBodyComponent {
protected hasScrolledFrom = hasScrolledFrom();
}

View File

@@ -1,28 +0,0 @@
import { Directive, inject } from "@angular/core";
import { DrawerComponent } from "./drawer.component";
/**
* Closes the ancestor drawer
*
* @example
*
* ```html
* <bit-drawer>
* <button type="button" bitButton bitDrawerClose>Close</button>
* </bit-drawer>
* ```
**/
@Directive({
selector: "button[bitDrawerClose]",
host: {
"(click)": "onClick()",
},
})
export class DrawerCloseDirective {
private drawer = inject(DrawerComponent, { optional: true });
protected onClick() {
this.drawer?.open.set(false);
}
}

View File

@@ -1,9 +0,0 @@
<header class="tw-flex tw-justify-between tw-items-center tw-gap-4">
<div class="tw-flex tw-items-center tw-gap-4 tw-overflow-auto">
<ng-content select="[slot=start]"></ng-content>
<h2 bitTypography="h3" noMargin class="tw-text-main tw-mb-0 tw-truncate" [attr.title]="title()">
{{ title() }}
</h2>
</div>
<button bitIconButton="bwi-close" type="button" bitDrawerClose [label]="'close' | i18n"></button>
</header>

View File

@@ -1,34 +0,0 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, HostBinding, input } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
import { IconButtonModule } from "../icon-button";
import { TypographyModule } from "../typography";
import { DrawerCloseDirective } from "./drawer-close.directive";
/**
* Header container for `bit-drawer`
**/
@Component({
selector: "bit-drawer-header",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DrawerCloseDirective, TypographyModule, IconButtonModule, I18nPipe],
templateUrl: "drawer-header.component.html",
host: {
class: "tw-block tw-ps-4 tw-pe-2 tw-py-2",
},
})
export class DrawerHeaderComponent {
/**
* The title to display
*/
readonly title = input.required<string>();
/** We don't want to set the HTML title attribute with `this.title` */
@HostBinding("attr.title")
protected get getTitle(): null {
return null;
}
}

View File

@@ -1,27 +0,0 @@
import { Portal } from "@angular/cdk/portal";
import { Directive, signal } from "@angular/core";
/**
* Host that renders a drawer
*
* @internal
*/
@Directive({
selector: "[bitDrawerHost]",
})
export class DrawerHostDirective {
private readonly _portal = signal<Portal<unknown> | undefined>(undefined);
/** The portal to display */
portal = this._portal.asReadonly();
open(portal: Portal<unknown>) {
this._portal.set(portal);
}
close(portal: Portal<unknown>) {
if (portal === this.portal()) {
this._portal.set(undefined);
}
}
}

View File

@@ -1,8 +0,0 @@
<ng-container *cdkPortal>
<section
[attr.role]="role()"
class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-full tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background"
>
<ng-content></ng-content>
</section>
</ng-container>

View File

@@ -1,75 +0,0 @@
import { CdkPortal, PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
input,
model,
viewChild,
} from "@angular/core";
import { DrawerService } from "./drawer.service";
/**
* A drawer is a panel of supplementary content that is adjacent to the page's main content.
*
* Drawers render in `bit-layout`. Drawers must be a descendant of `bit-layout`, but they do not need to be a direct descendant.
*/
@Component({
selector: "bit-drawer",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, PortalModule],
templateUrl: "drawer.component.html",
})
export class DrawerComponent {
private drawerHost = inject(DrawerService);
private readonly portal = viewChild.required(CdkPortal);
/**
* Whether or not the drawer is open.
*
* Note: Does not support implicit boolean transform due to Angular limitation. Must be bound explicitly `[open]="true"` instead of just `open`.
* https://github.com/angular/angular/issues/55166#issuecomment-2032150999
**/
readonly open = model<boolean>(false);
/**
* The ARIA role of the drawer.
*
* - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role)
* - For drawers that contain content that is complementary to the page's main content. (default)
* - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role)
* - For drawers that primary contain links to other content.
*/
readonly role = input<"complementary" | "navigation">("complementary");
constructor() {
effect(
() => {
this.open() ? this.drawerHost.open(this.portal()) : this.drawerHost.close(this.portal());
},
{
allowSignalWrites: true,
},
);
// Set `open` to `false` when another drawer is opened.
effect(
() => {
if (this.drawerHost.portal() !== this.portal()) {
this.open.set(false);
}
},
{
allowSignalWrites: true,
},
);
}
/** Toggle the drawer between open & closed */
toggle() {
this.open.update((prev) => !prev);
}
}

View File

@@ -1,122 +0,0 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./drawer.stories";
import { DrawerOpen as KitchenSink } from "../stories/kitchen-sink/kitchen-sink.stories";
<Meta of={stories} />
```ts
import { DrawerComponent } from "@bitwarden/components";
```
# Drawer
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
A drawer is a panel of supplementary content that is adjacent to the page's main content.
<Primary />
<Controls />
## Usage
A `bit-drawer` in a template will not render inline, but rather will render adjacent to the main
page content.
```html
<bit-drawer [open]="true">
<bit-drawer-header title="Hello Bitwaaaaaaaaaaaaaaaaaaaaaaaaarden!"></bit-drawer-header>
<bit-drawer-body>
<p>Lorem ipsum dolor...</p>
</bit-drawer-body>
</bit-drawer>
```
`bit-drawer` must be a descendant of `bit-layout`, but it does not need to be a direct descendant.
## Header and body
Header and body content can be provided with the `bit-drawer-header` and `bit-drawer-body`
components, respectively.
A title can be passed to the header by input:
`<bit-drawer-header title="Foobar"></bit-drawer-header>`
Custom content can be rendered before the title with the header's `start` slot:
```html
<bit-drawer-header title="Foobar">
<i slot="start" class="bwi bwi-key" aria-hidden="true"></i>
</bit-drawer-header>
```
## Opening and closing
`bit-drawer` opens when its `open` input is `true`:
```html
<bit-drawer [open]="true">...</bit-drawer>
```
Note: Model inputs do not support implicit boolean transformation (see Angular reasoning
[here](https://github.com/angular/angular/issues/55166#issuecomment-2032150999)). `open` must be
bound explicitly `<bit-drawer [open]="true">` instead of just `<bit-drawer open>`.
Buttons can be made to open/toggle drawers by referencing a template variable, or by manipulating
state that is bound to `open`:
```html
<button (click)="myDrawer.toggle()"></button> <bit-drawer #myDrawer>...</bit-drawer>
```
For convenience, close buttons can be created _inside_ the drawer with the `bitDrawerClose`
directive:
```html
<bit-drawer>
<button type="button" bitDrawerClose>Close</button>
</bit-drawer>
```
## Multiple Drawers
Only one drawer can be open at a time, and they do not stack. If a drawer is already open, opening
another will close and replace the one already open.
<Canvas of={stories.MultipleDrawers} />
## Headless
Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content.
<Canvas of={stories.Headless} />
## Accessibility
- The drawer should contain an h2 element. If you are using `bit-drawer-header`, this is created for
you via the `title` input:
```html
<bit-drawer>
<h2 bitTypography="h2">Hello world!</h2>
</bit-drawer>
<!-- or -->
<bit-drawer>
<bit-drawer-header title="Hello world!"></bit-drawer-header>
</bit-drawer>
```
- The ARIA role of the drawer can be set with the `role` attribute:
- [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role)
(default)
- For drawers that contain content that is complementary to the page's main content.
- [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role)
- For drawers that primary contain links to other content.
## Kitchen Sink
<Canvas of={KitchenSink} autoplay />

View File

@@ -1,12 +0,0 @@
import { NgModule } from "@angular/core";
import { DrawerBodyComponent } from "./drawer-body.component";
import { DrawerCloseDirective } from "./drawer-close.directive";
import { DrawerHeaderComponent } from "./drawer-header.component";
import { DrawerComponent } from "./drawer.component";
@NgModule({
imports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective],
exports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective],
})
export class DrawerModule {}

View File

@@ -1,128 +0,0 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { ButtonModule } from "../button";
import { CalloutModule } from "../callout";
import { LayoutComponent } from "../layout";
import { mockLayoutI18n } from "../layout/mocks";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { TypographyModule } from "../typography";
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { DrawerBodyComponent } from "./drawer-body.component";
import { DrawerHeaderComponent } from "./drawer-header.component";
import { DrawerComponent } from "./drawer.component";
import { DrawerModule } from "./drawer.module";
export default {
title: "Component Library/Drawer",
component: DrawerComponent,
subcomponents: {
DrawerHeaderComponent,
DrawerBodyComponent,
},
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
imports: [
RouterTestingModule,
LayoutComponent,
DrawerModule,
ButtonModule,
CalloutModule,
TypographyModule,
],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
...mockLayoutI18n,
close: "Close",
loading: "Loading",
});
},
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
} as Meta<DrawerComponent>;
type Story = StoryObj<DrawerComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-layout class="tw-text-main">
<p>The drawer is {{ open ? "open" : "closed" }}.<p>
<button type="button" bitButton (click)="drawer.toggle()">Toggle</button>
<!-- Note: bit-drawer does *not* need to be a direct descendant of bit-layout. -->
<bit-drawer [(open)]="open" #drawer>
<bit-drawer-header title="Hello Bitwaaaaaaaaaaaaaaaaaaaaaaaaarden!">
<i slot="start" class="bwi bwi-key" aria-hidden="true"></i>
</bit-drawer-header>
<bit-drawer-body>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</bit-drawer-body>
</bit-drawer>
</bit-layout>
`,
}),
args: {
open: true,
},
};
export const Headless: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-layout class="tw-text-main">
<p>The drawer is {{ open ? "open" : "closed" }}.<p>
<button type="button" bitButton (click)="drawer.toggle()">Toggle</button>
<bit-drawer [(open)]="open" #drawer>
<h2 bitTypography="h2"></h2>
Hello world!
</bit-drawer>
</bit-layout>
`,
}),
args: {
open: true,
},
};
export const MultipleDrawers: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-layout class="tw-text-main">
<button type="button" bitButton (click)="foo.toggle()">{{ !foo.open() ? "Open" : "Close" }} Foo</button>
<button type="button" bitButton (click)="bar.toggle()">{{ !bar.open() ? "Open" : "Close" }} Bar</button>
<bit-drawer #foo>
Foo
</bit-drawer>
<bit-drawer #bar [open]="true">
Bar
</bit-drawer>
</bit-layout>
`,
}),
};

View File

@@ -1,5 +0,0 @@
export * from "./drawer.module";
export * from "./drawer.component";
export * from "./drawer-body.component";
export * from "./drawer-close.directive";
export * from "./drawer-header.component";

View File

@@ -17,7 +17,6 @@ export * from "./container";
export * from "./copy-click";
export * from "./dialog";
export * from "./disclosure";
export * from "./drawer";
export * from "./form-field";
export * from "./header";
export * from "./icon-button";

View File

@@ -4,8 +4,7 @@ import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
import { DrawerService } from "../drawer/drawer.service";
import { DrawerService } from "../dialog/drawer.service";
import { LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
@@ -31,7 +30,6 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
"(document:keydown.tab)": "handleKeydown($event)",
class: "tw-block tw-h-screen",
},
hostDirectives: [DrawerHostDirective],
})
export class LayoutComponent {
protected sideNavService = inject(SideNavService);

View File

@@ -13,7 +13,6 @@ import { CalloutModule } from "../../callout";
import { CheckboxModule } from "../../checkbox";
import { ColorPasswordModule } from "../../color-password";
import { DialogModule } from "../../dialog";
import { DrawerModule } from "../../drawer";
import { FormControlModule } from "../../form-control";
import { FormFieldModule } from "../../form-field";
import { IconButtonModule } from "../../icon-button";
@@ -49,7 +48,6 @@ import { TypographyModule } from "../../typography";
ColorPasswordModule,
CommonModule,
DialogModule,
DrawerModule,
FormControlModule,
FormFieldModule,
FormsModule,
@@ -87,7 +85,6 @@ import { TypographyModule } from "../../typography";
ColorPasswordModule,
CommonModule,
DialogModule,
DrawerModule,
FormControlModule,
FormFieldModule,
FormsModule,

View File

@@ -54,11 +54,12 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService
return false;
}
// If we're in the browser extension, check if we're in a Chromium browser
if (
this.platformUtilsService.getClientType() === ClientType.Browser &&
!this.platformUtilsService.isChromium()
) {
// PRF unlock is only supported on Web and Chromium-based browser extensions
const clientType = this.platformUtilsService.getClientType();
if (clientType === ClientType.Browser && !this.platformUtilsService.isChromium()) {
return false;
}
if (clientType !== ClientType.Web && clientType !== ClientType.Browser) {
return false;
}

View File

@@ -38,14 +38,16 @@
</button>
}
</bit-item-action>
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
@if (cipher().edit) {
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
}
</ng-container>
</bit-item>
</li>
@@ -54,46 +56,48 @@
}
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
<bit-card>
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
{{ "addAttachment" | i18n }}
</label>
<div class="tw-relative">
<!-- Input elements are notoriously difficult to style, --->
<!-- The native `<input>` will be used for screen readers -->
<!-- Visual & keyboard users will interact with the styled button element -->
<input
#fileInput
class="tw-sr-only"
type="file"
id="file"
name="file"
aria-describedby="fileHelp"
tabindex="-1"
required
(change)="onFileChange($event)"
/>
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
<button
bitButton
buttonType="secondary"
type="button"
(click)="fileInput.click()"
class="tw-whitespace-nowrap"
>
{{ "chooseFile" | i18n }}
</button>
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
{{
this.attachmentForm.controls.file?.value
? this.attachmentForm.controls.file.value.name
: ("noFileChosen" | i18n)
}}
</p>
@if (cipher()?.edit) {
<bit-card>
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
{{ "addAttachment" | i18n }}
</label>
<div class="tw-relative">
<!-- Input elements are notoriously difficult to style, --->
<!-- The native `<input>` will be used for screen readers -->
<!-- Visual & keyboard users will interact with the styled button element -->
<input
#fileInput
class="tw-sr-only"
type="file"
id="file"
name="file"
aria-describedby="fileHelp"
tabindex="-1"
required
(change)="onFileChange($event)"
/>
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
<button
bitButton
buttonType="secondary"
type="button"
(click)="fileInput.click()"
class="tw-whitespace-nowrap"
>
{{ "chooseFile" | i18n }}
</button>
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
{{
this.attachmentForm.controls.file?.value
? this.attachmentForm.controls.file.value.name
: ("noFileChosen" | i18n)
}}
</p>
</div>
</div>
</div>
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
{{ "maxFileSizeSansPunctuation" | i18n }}
</p>
</bit-card>
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
{{ "maxFileSizeSansPunctuation" | i18n }}
</p>
</bit-card>
}
</form>

View File

@@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => {
username: "username",
password: "password",
},
edit: true,
} as CipherView;
const cipherDomain = {
@@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => {
let file: File;
beforeEach(() => {
const nonEditableCipherView = { ...cipherView, edit: false };
cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView);
fixture.detectChanges();
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
file = new File([""], "attachment.txt", { type: "text/plain" });
@@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => {
expect(emitSpy).toHaveBeenCalled();
});
});
describe("close", () => {
async function setup(): Promise<void> {
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
submitBtnFixture = TestBed.createComponent(ButtonComponent);
// Set organizationId BEFORE cipherId so the effect picks it up
fixture.componentRef.setInput("organizationId", organization.id);
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
await waitForInitialization();
const nonEditableCipherView = { ...cipherView, edit: false };
cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView);
fixture.detectChanges();
}
it('emits "onCloseButtonPress"', async () => {
await setup();
const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit");
await component.submit();
expect(emitSpy).toHaveBeenCalled();
});
});
});
describe("removeAttachment", () => {

View File

@@ -105,6 +105,8 @@ export class CipherAttachmentsComponent {
/** Emits after a file has been successfully removed */
readonly onRemoveSuccess = output<void>();
readonly onCloseButtonPress = output<void>();
protected readonly organization = signal<Organization | null>(null);
protected readonly cipher = signal<CipherView | null>(null);
@@ -154,7 +156,7 @@ export class CipherAttachmentsComponent {
// Update the initial state of the submit button
const btn = this.submitBtn();
if (btn) {
btn.disabled.set(!this.attachmentForm.valid);
btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true));
}
});
@@ -192,6 +194,12 @@ export class CipherAttachmentsComponent {
/** Save the attachments to the cipher */
submit = async () => {
//user can't edit cipher and will close the bit-dialog
if (!(this.cipher()?.edit ?? false)) {
this.onCloseButtonPress.emit();
return;
}
this.onUploadStarted.emit();
const file = this.attachmentForm.value.file;

View File

@@ -13,11 +13,12 @@
(onUploadSuccess)="uploadSuccessful()"
(onUploadFailed)="uploadFailed()"
(onRemoveSuccess)="removalSuccessful()"
(onCloseButtonPress)="closeButtonPressed()"
></app-cipher-attachments>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
{{ "upload" | i18n }}
{{ buttonText }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => {
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
});
it("closes the dialog with 'closed' result on closedButtonPressed", () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
component.closeButtonPressed();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed });
});
});

View File

@@ -3,6 +3,7 @@
import { CommonModule } from "@angular/common";
import { Component, HostListener, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
@@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm
export interface AttachmentsDialogParams {
cipherId: CipherId;
canEditCipher?: boolean;
admin?: boolean;
organizationId?: OrganizationId;
}
@@ -51,7 +53,9 @@ export class AttachmentsV2Component {
cipherId: CipherId;
admin: boolean = false;
organizationId?: OrganizationId;
canEditCipher: boolean;
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
buttonText: string;
private isUploading = false;
/**
@@ -62,10 +66,14 @@ export class AttachmentsV2Component {
constructor(
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
private i18nService: I18nService,
) {
this.cipherId = params.cipherId;
this.organizationId = params.organizationId;
this.admin = params.admin ?? false;
this.canEditCipher = params?.canEditCipher ?? false;
this.buttonText =
this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close");
}
/**
@@ -140,4 +148,10 @@ export class AttachmentsV2Component {
action: AttachmentDialogResult.Removed,
});
}
closeButtonPressed() {
this.dialogRef.close({
action: AttachmentDialogResult.Closed,
});
}
}

18
package-lock.json generated
View File

@@ -23,8 +23,8 @@
"@angular/platform-browser": "20.3.16",
"@angular/platform-browser-dynamic": "20.3.16",
"@angular/router": "20.3.16",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
"@bitwarden/sdk-internal": "0.2.0-main.470",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.506",
"@bitwarden/sdk-internal": "0.2.0-main.506",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
@@ -4982,10 +4982,9 @@
"link": true
},
"node_modules/@bitwarden/commercial-sdk-internal": {
"version": "0.2.0-main.470",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz",
"integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==",
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
"version": "0.2.0-main.506",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.506.tgz",
"integrity": "sha512-aRzcxOcj8vXxz0jN3q2xxj26zxBfjg3oRm5QXbWE7zXJ2PGrgxTaePca9pQYYpwgr7iufYMnZcq5dH+qttNEmA==",
"dependencies": {
"type-fest": "^4.41.0"
}
@@ -5087,10 +5086,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.470",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz",
"integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==",
"license": "GPL-3.0",
"version": "0.2.0-main.506",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.506.tgz",
"integrity": "sha512-BbTSU5Acx74Hr32zDj2kV8sbdclyvdIti5t6kXnCvJmA5dZbu+5j5Xw1luS9mGL9Vfi4w3OjVug/TiSxyhwLzQ==",
"dependencies": {
"type-fest": "^4.41.0"
}

View File

@@ -162,8 +162,8 @@
"@angular/platform-browser": "20.3.16",
"@angular/platform-browser-dynamic": "20.3.16",
"@angular/router": "20.3.16",
"@bitwarden/sdk-internal": "0.2.0-main.470",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.506",
"@bitwarden/sdk-internal": "0.2.0-main.506",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",