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

Merge branch 'main' into ac/pm-10324/add-bulk-delete-option-for-members

This commit is contained in:
Rui Tome
2024-11-08 11:37:46 +00:00
213 changed files with 6335 additions and 769 deletions

View File

@@ -14,7 +14,8 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { EventType } from "@bitwarden/common/enums";
import { ClientType, EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -36,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -71,6 +73,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
restorePromise: Promise<any>;
checkPasswordPromise: Promise<number>;
showPassword = false;
showPrivateKey = false;
showTotpSeed = false;
showCardNumber = false;
showCardCode = false;
@@ -134,6 +137,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
];
this.cardBrandOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "Visa", value: "Visa" },
@@ -200,6 +204,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled();
const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
if (this.platformUtilsService.getClientType() == ClientType.Desktop && sshKeysEnabled) {
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
}
ngOnDestroy() {
@@ -279,6 +288,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.cipher.identity = new IdentityView();
this.cipher.secureNote = new SecureNoteView();
this.cipher.secureNote.type = SecureNoteType.Generic;
this.cipher.sshKey = new SshKeyView();
this.cipher.reprompt = CipherRepromptType.None;
}
}
@@ -601,6 +611,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
toggleUriOptions(uri: LoginUriView) {
const u = uri as any;
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;

View File

@@ -60,6 +60,7 @@ export class ViewComponent implements OnDestroy, OnInit {
showPasswordCount: boolean;
showCardNumber: boolean;
showCardCode: boolean;
showPrivateKey: boolean;
canAccessPremium: boolean;
showPremiumRequiredTotp: boolean;
totpCode: string;
@@ -325,6 +326,10 @@ export class ViewComponent implements OnDestroy, OnInit {
}
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
async checkPassword() {
if (
this.cipher.login == null ||

View File

@@ -56,6 +56,8 @@ export enum EventType {
OrganizationUser_Restored = 1512,
OrganizationUser_ApprovedAuthRequest = 1513,
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515,
OrganizationUser_Left = 1516,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@@ -27,6 +27,8 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
AccountDeprovisioning = "pm-10308-account-deprovisioning",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption",
@@ -35,6 +37,7 @@ export enum FeatureFlag {
AccessIntelligence = "pm-13227-access-intelligence",
Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions",
LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split",
CriticalApps = "pm-14466-risk-insights-critical-application",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -72,6 +75,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
@@ -80,6 +85,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AccessIntelligence]: FALSE,
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export";
import { LoginExport } from "./login.export";
import { PasswordHistoryExport } from "./password-history.export";
import { SecureNoteExport } from "./secure-note.export";
import { SshKeyExport } from "./ssh-key.export";
import { safeGetString } from "./utils";
export class CipherExport {
@@ -27,6 +28,7 @@ export class CipherExport {
req.secureNote = null;
req.card = null;
req.identity = null;
req.sshKey = null;
req.reprompt = CipherRepromptType.None;
req.passwordHistory = [];
req.creationDate = null;
@@ -67,6 +69,8 @@ export class CipherExport {
case CipherType.Identity:
view.identity = IdentityExport.toView(req.identity);
break;
case CipherType.SshKey:
view.sshKey = SshKeyExport.toView(req.sshKey);
}
if (req.passwordHistory != null) {
@@ -108,6 +112,9 @@ export class CipherExport {
case CipherType.Identity:
domain.identity = IdentityExport.toDomain(req.identity);
break;
case CipherType.SshKey:
domain.sshKey = SshKeyExport.toDomain(req.sshKey);
break;
}
if (req.passwordHistory != null) {
@@ -132,6 +139,7 @@ export class CipherExport {
secureNote: SecureNoteExport;
card: CardExport;
identity: IdentityExport;
sshKey: SshKeyExport;
reprompt: CipherRepromptType;
passwordHistory: PasswordHistoryExport[] = null;
revisionDate: Date = null;
@@ -171,6 +179,9 @@ export class CipherExport {
case CipherType.Identity:
this.identity = new IdentityExport(o.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKeyExport(o.sshKey);
break;
}
if (o.passwordHistory != null) {

View File

@@ -0,0 +1,44 @@
import { SshKeyView as SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { EncString } from "../../platform/models/domain/enc-string";
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
import { safeGetString } from "./utils";
export class SshKeyExport {
static template(): SshKeyExport {
const req = new SshKeyExport();
req.privateKey = "";
req.publicKey = "";
req.keyFingerprint = "";
return req;
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;
return view;
}
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null;
domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null;
domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null;
return domain;
}
privateKey: string;
publicKey: string;
keyFingerprint: string;
constructor(o?: SshKeyView | SshKeyDomain) {
if (o == null) {
return;
}
this.privateKey = safeGetString(o.privateKey);
this.publicKey = safeGetString(o.publicKey);
this.keyFingerprint = safeGetString(o.keyFingerprint);
}
}

View File

@@ -584,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction {
}
putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any> {
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false);
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true);
}
postPurgeCiphers(
@@ -1886,7 +1886,7 @@ export class ApiService implements ApiServiceAbstraction {
});
if (flagEnabled("prereleaseBuild")) {
headers.set("Is-Prerelease", "true");
headers.set("Is-Prerelease", "1");
}
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);

View File

@@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* Used for Unassigned ciphers or when the user only has admin access to the cipher (not assigned normally).
* @param cipher
*/
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<void>;
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<Cipher>;
/**
* Bulk update collections for many ciphers with the server
* @param orgId

View File

@@ -3,4 +3,5 @@ export enum CipherType {
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5,
}

View File

@@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
case CipherType.Identity:
icon = "bwi-id-card";
break;
case CipherType.SshKey:
icon = "bwi-key";
break;
default:
break;
}

View File

@@ -0,0 +1,17 @@
import { BaseResponse } from "../../../models/response/base.response";
export class SshKeyApi extends BaseResponse {
privateKey: string;
publicKey: string;
keyFingerprint: string;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.privateKey = this.getResponseProperty("PrivateKey");
this.publicKey = this.getResponseProperty("PublicKey");
this.keyFingerprint = this.getResponseProperty("KeyFingerprint");
}
}

View File

@@ -11,6 +11,7 @@ import { IdentityData } from "./identity.data";
import { LoginData } from "./login.data";
import { PasswordHistoryData } from "./password-history.data";
import { SecureNoteData } from "./secure-note.data";
import { SshKeyData } from "./ssh-key.data";
export class CipherData {
id: string;
@@ -28,6 +29,7 @@ export class CipherData {
secureNote?: SecureNoteData;
card?: CardData;
identity?: IdentityData;
sshKey?: SshKeyData;
fields?: FieldData[];
attachments?: AttachmentData[];
passwordHistory?: PasswordHistoryData[];
@@ -72,6 +74,9 @@ export class CipherData {
case CipherType.Identity:
this.identity = new IdentityData(response.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKeyData(response.sshKey);
break;
default:
break;
}

View File

@@ -0,0 +1,17 @@
import { SshKeyApi } from "../api/ssh-key.api";
export class SshKeyData {
privateKey: string;
publicKey: string;
keyFingerprint: string;
constructor(data?: SshKeyApi) {
if (data == null) {
return;
}
this.privateKey = data.privateKey;
this.publicKey = data.publicKey;
this.keyFingerprint = data.keyFingerprint;
}
}

View File

@@ -19,6 +19,7 @@ import { Identity } from "./identity";
import { Login } from "./login";
import { Password } from "./password";
import { SecureNote } from "./secure-note";
import { SshKey } from "./ssh-key";
export class Cipher extends Domain implements Decryptable<CipherView> {
readonly initializerKey = InitializerKey.Cipher;
@@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
identity: Identity;
card: Card;
secureNote: SecureNote;
sshKey: SshKey;
attachments: Attachment[];
fields: Field[];
passwordHistory: Password[];
@@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
this.identity = new Identity(obj.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKey(obj.sshKey);
break;
default:
break;
}
@@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
model.identity = await this.identity.decrypt(this.organizationId, encKey);
break;
case CipherType.SshKey:
model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey);
break;
default:
break;
}
@@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
c.identity = this.identity.toIdentityData();
break;
case CipherType.SshKey:
c.sshKey = this.sshKey.toSshKeyData();
break;
default:
break;
}
@@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.SecureNote:
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
break;
case CipherType.SshKey:
domain.sshKey = SshKey.fromJSON(obj.sshKey);
break;
default:
break;
}

View File

@@ -0,0 +1,67 @@
import { mockEnc } from "../../../../spec";
import { SshKeyApi } from "../api/ssh-key.api";
import { SshKeyData } from "../data/ssh-key.data";
import { SshKey } from "./ssh-key";
describe("Sshkey", () => {
let data: SshKeyData;
beforeEach(() => {
data = new SshKeyData(
new SshKeyApi({
PrivateKey: "privateKey",
PublicKey: "publicKey",
KeyFingerprint: "keyFingerprint",
}),
);
});
it("Convert", () => {
const sshKey = new SshKey(data);
expect(sshKey).toEqual({
privateKey: { encryptedString: "privateKey", encryptionType: 0 },
publicKey: { encryptedString: "publicKey", encryptionType: 0 },
keyFingerprint: { encryptedString: "keyFingerprint", encryptionType: 0 },
});
});
it("Convert from empty", () => {
const data = new SshKeyData();
const sshKey = new SshKey(data);
expect(sshKey).toEqual({
privateKey: null,
publicKey: null,
keyFingerprint: null,
});
});
it("toSshKeyData", () => {
const sshKey = new SshKey(data);
expect(sshKey.toSshKeyData()).toEqual(data);
});
it("Decrypt", async () => {
const sshKey = Object.assign(new SshKey(), {
privateKey: mockEnc("privateKey"),
publicKey: mockEnc("publicKey"),
keyFingerprint: mockEnc("keyFingerprint"),
});
const expectedView = {
privateKey: "privateKey",
publicKey: "publicKey",
keyFingerprint: "keyFingerprint",
};
const loginView = await sshKey.decrypt(null);
expect(loginView).toEqual(expectedView);
});
describe("fromJSON", () => {
it("returns null if object is null", () => {
expect(SshKey.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,70 @@
import { Jsonify } from "type-fest";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import Domain from "../../../platform/models/domain/domain-base";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { SshKeyData } from "../data/ssh-key.data";
import { SshKeyView } from "../view/ssh-key.view";
export class SshKey extends Domain {
privateKey: EncString;
publicKey: EncString;
keyFingerprint: EncString;
constructor(obj?: SshKeyData) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(
this,
obj,
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
[],
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<SshKeyView> {
return this.decryptObj(
new SshKeyView(),
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
orgId,
encKey,
);
}
toSshKeyData(): SshKeyData {
const c = new SshKeyData();
this.buildDataModel(this, c, {
privateKey: null,
publicKey: null,
keyFingerprint: null,
});
return c;
}
static fromJSON(obj: Partial<Jsonify<SshKey>>): SshKey {
if (obj == null) {
return null;
}
const privateKey = EncString.fromJSON(obj.privateKey);
const publicKey = EncString.fromJSON(obj.publicKey);
const keyFingerprint = EncString.fromJSON(obj.keyFingerprint);
return Object.assign(new SshKey(), obj, {
privateKey,
publicKey,
keyFingerprint,
});
}
}

View File

@@ -7,6 +7,7 @@ import { IdentityApi } from "../api/identity.api";
import { LoginUriApi } from "../api/login-uri.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { Cipher } from "../domain/cipher";
import { AttachmentRequest } from "./attachment.request";
@@ -23,6 +24,7 @@ export class CipherRequest {
secureNote: SecureNoteApi;
card: CardApi;
identity: IdentityApi;
sshKey: SshKeyApi;
fields: FieldApi[];
passwordHistory: PasswordHistoryRequest[];
// Deprecated, remove at some point and rename attachments2 to attachments
@@ -93,6 +95,17 @@ export class CipherRequest {
this.secureNote = new SecureNoteApi();
this.secureNote.type = cipher.secureNote.type;
break;
case CipherType.SshKey:
this.sshKey = new SshKeyApi();
this.sshKey.privateKey =
cipher.sshKey.privateKey != null ? cipher.sshKey.privateKey.encryptedString : null;
this.sshKey.publicKey =
cipher.sshKey.publicKey != null ? cipher.sshKey.publicKey.encryptedString : null;
this.sshKey.keyFingerprint =
cipher.sshKey.keyFingerprint != null
? cipher.sshKey.keyFingerprint.encryptedString
: null;
break;
case CipherType.Card:
this.card = new CardApi();
this.card.cardholderName =

View File

@@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api";
import { IdentityApi } from "../api/identity.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { AttachmentResponse } from "./attachment.response";
import { PasswordHistoryResponse } from "./password-history.response";
@@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse {
card: CardApi;
identity: IdentityApi;
secureNote: SecureNoteApi;
sshKey: SshKeyApi;
favorite: boolean;
edit: boolean;
viewPassword: boolean;
@@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse {
this.secureNote = new SecureNoteApi(secureNote);
}
const sshKey = this.getResponseProperty("sshKey");
if (sshKey != null) {
this.sshKey = new SshKeyApi(sshKey);
}
const fields = this.getResponseProperty("Fields");
if (fields != null) {
this.fields = fields.map((f: any) => new FieldApi(f));

View File

@@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view";
import { LoginView } from "./login.view";
import { PasswordHistoryView } from "./password-history.view";
import { SecureNoteView } from "./secure-note.view";
import { SshKeyView } from "./ssh-key.view";
export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
@@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata {
identity = new IdentityView();
card = new CardView();
secureNote = new SecureNoteView();
sshKey = new SshKeyView();
attachments: AttachmentView[] = null;
fields: FieldView[] = null;
passwordHistory: PasswordHistoryView[] = null;
@@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata {
return this.card;
case CipherType.Identity:
return this.identity;
case CipherType.SshKey:
return this.sshKey;
default:
break;
}
@@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata {
case CipherType.SecureNote:
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
break;
case CipherType.SshKey:
view.sshKey = SshKeyView.fromJSON(obj.sshKey);
break;
default:
break;
}

View File

@@ -0,0 +1,41 @@
import { Jsonify } from "type-fest";
import { SshKey } from "../domain/ssh-key";
import { ItemView } from "./item.view";
export class SshKeyView extends ItemView {
privateKey: string = null;
publicKey: string = null;
keyFingerprint: string = null;
constructor(n?: SshKey) {
super();
if (!n) {
return;
}
}
get maskedPrivateKey(): string {
let lines = this.privateKey.split("\n").filter((l) => l.trim() !== "");
lines = lines.map((l, i) => {
if (i === 0 || i === lines.length - 1) {
return l;
}
return this.maskLine(l);
});
return lines.join("\n");
}
private maskLine(line: string): string {
return "•".repeat(32);
}
get subTitle(): string {
return this.keyFingerprint;
}
static fromJSON(obj: Partial<Jsonify<SshKeyView>>): SshKeyView {
return Object.assign(new SshKeyView(), obj);
}
}

View File

@@ -54,6 +54,7 @@ import { LoginUri } from "../models/domain/login-uri";
import { Password } from "../models/domain/password";
import { SecureNote } from "../models/domain/secure-note";
import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache";
import { SshKey } from "../models/domain/ssh-key";
import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.request";
import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request";
import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request";
@@ -880,9 +881,11 @@ export class CipherService implements CipherServiceAbstraction {
return new Cipher(updated[cipher.id as CipherId], cipher.localData);
}
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<void> {
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher> {
const request = new CipherCollectionsRequest(cipher.collectionIds);
await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
const data = new CipherData(response);
return new Cipher(data);
}
/**
@@ -1568,6 +1571,19 @@ export class CipherService implements CipherServiceAbstraction {
key,
);
return;
case CipherType.SshKey:
cipher.sshKey = new SshKey();
await this.encryptObjProperty(
model.sshKey,
cipher.sshKey,
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
key,
);
return;
default:
throw new Error("Unknown cipher type.");
}

View File

@@ -0,0 +1,27 @@
import { Directive, HostBinding, HostListener, Input } from "@angular/core";
import { DisclosureComponent } from "./disclosure.component";
@Directive({
selector: "[bitDisclosureTriggerFor]",
exportAs: "disclosureTriggerFor",
standalone: true,
})
export class DisclosureTriggerForDirective {
/**
* Accepts template reference for a bit-disclosure component instance
*/
@Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent;
@HostBinding("attr.aria-expanded") get ariaExpanded() {
return this.disclosure.open;
}
@HostBinding("attr.aria-controls") get ariaControls() {
return this.disclosure.id;
}
@HostListener("click") click() {
this.disclosure.open = !this.disclosure.open;
}
}

View File

@@ -0,0 +1,21 @@
import { Component, HostBinding, Input, booleanAttribute } from "@angular/core";
let nextId = 0;
@Component({
selector: "bit-disclosure",
standalone: true,
template: `<ng-content></ng-content>`,
})
export class DisclosureComponent {
/**
* Optionally init the disclosure in its opened state
*/
@Input({ transform: booleanAttribute }) open?: boolean = false;
@HostBinding("class") get classList() {
return this.open ? "" : "tw-hidden";
}
@HostBinding("id") id = `bit-disclosure-${nextId++}`;
}

View File

@@ -0,0 +1,55 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./disclosure.stories";
<Meta of={stories} />
```ts
import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components";
```
# Disclosure
The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to
create an accessible content area whose visibility is controlled by a trigger button.
To compose a disclosure and trigger:
1. Create a trigger component (see "Supported Trigger Components" section below)
2. Create a `bit-disclosure`
3. Set a template reference on the `bit-disclosure`
4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the
`bit-disclosure` template reference
5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently
expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to
being hidden.
```
<button
type="button"
bitIconButton="bwi-sliders"
[buttonType]="'muted'"
[bitDisclosureTriggerFor]="disclosureRef"
></button>
<bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
```
<Story of={stories.DisclosureWithIconButton} />
<br />
<br />
## Supported Trigger Components
This is the list of currently supported trigger components:
- Icon button `muted` variant
## Accessibility
The disclosure and trigger directive functionality follow the
[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for
accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button`
element must be used as the trigger for the disclosure. The `button` element must also have an
accessible label/title -- please follow the accessibility guidelines for whatever trigger component
you choose.

View File

@@ -0,0 +1,29 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { IconButtonModule } from "../icon-button";
import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive";
import { DisclosureComponent } from "./disclosure.component";
export default {
title: "Component Library/Disclosure",
component: DisclosureComponent,
decorators: [
moduleMetadata({
imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule],
}),
],
} as Meta<DisclosureComponent>;
type Story = StoryObj<DisclosureComponent>;
export const DisclosureWithIconButton: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitIconButton="bwi-sliders" [buttonType]="'muted'" [bitDisclosureTriggerFor]="disclosureRef">
</button>
<bit-disclosure #disclosureRef class="tw-text-main tw-block" open>click button to hide this content</bit-disclosure>
`,
}),
};

View File

@@ -0,0 +1,2 @@
export * from "./disclosure-trigger-for.directive";
export * from "./disclosure.component";

View File

@@ -52,10 +52,14 @@ const styles: Record<IconButtonType, string[]> = {
"tw-bg-transparent",
"!tw-text-muted",
"tw-border-transparent",
"aria-expanded:tw-bg-text-muted",
"aria-expanded:!tw-text-contrast",
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"aria-expanded:hover:tw-bg-secondary-700",
"aria-expanded:hover:tw-border-secondary-700",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
...focusRing,

View File

@@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the
[dialog](?path=/docs/component-library-dialogs--docs), and
[table](?path=/docs/component-library-table--docs).
<Story id="component-library-banner--premium" />
## Styles
There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the
@@ -40,48 +38,48 @@ button component styles.
Used for general icon buttons appearing on the themes main `background`
<Story id="component-library-icon-button--main" />
<Story of={stories.Main} />
### Muted
Used for low emphasis icon buttons appearing on the themes main `background`
<Story id="component-library-icon-button--muted" />
<Story of={stories.Muted} />
### Contrast
Used on a themes colored or contrasting backgrounds such as in the navigation or on toasts and
banners.
<Story id="component-library-icon-button--contrast" />
<Story of={stories.Contrast} />
### Danger
Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of
the dialog component.
<Story id="component-library-icon-button--danger" />
<Story of={stories.Danger} />
### Primary
Used in place of the main button component if no text is used. This allows the button to display
square.
<Story id="component-library-icon-button--primary" />
<Story of={stories.Primary} />
### Secondary
Used in place of the main button component if no text is used. This allows the button to display
square.
<Story id="component-library-icon-button--secondary" />
<Story of={stories.Secondary} />
### Light
Used on a background that is dark in both light theme and dark theme. Example: end user navigation
styles.
<Story id="component-library-icon-button--light" />
<Story of={stories.Light} />
**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus
indicator does not meet WCAG graphic contrast guidelines.
@@ -95,11 +93,11 @@ with less padding around the icon, such as in the navigation component.
### Small
<Story id="component-library-icon-button--small" />
<Story of={stories.Small} />
### Default
<Story id="component-library-icon-button--default" />
<Story of={stories.Default} />
## Accessibility

View File

@@ -23,7 +23,7 @@ type Story = StoryObj<BitIconButtonComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<div class="tw-space-x-4">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="main" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="muted" [size]="size">Button</button>
@@ -56,7 +56,7 @@ export const Small: Story = {
export const Primary: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
`,
}),
@@ -96,7 +96,7 @@ export const Muted: Story = {
export const Light: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<div class="tw-bg-background-alt2 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
</div>
@@ -110,7 +110,7 @@ export const Light: Story = {
export const Contrast: Story = {
render: (args) => ({
props: args,
template: `
template: /*html*/ `
<div class="tw-bg-primary-600 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
</div>

View File

@@ -13,6 +13,7 @@ export * from "./chip-select";
export * from "./color-password";
export * from "./container";
export * from "./dialog";
export * from "./disclosure";
export * from "./form-field";
export * from "./icon-button";
export * from "./icon";

View File

@@ -1,7 +1,7 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, HostBinding, Input, OnInit } from "@angular/core";
import type { SortFn } from "./table-data-source";
import type { SortDirection, SortFn } from "./table-data-source";
import { TableComponent } from "./table.component";
@Component({
@@ -19,12 +19,16 @@ export class SortableComponent implements OnInit {
*/
@Input() bitSortable: string;
private _default: boolean;
private _default: SortDirection | boolean = false;
/**
* Mark the column as the default sort column
*/
@Input() set default(value: boolean | "") {
this._default = coerceBooleanProperty(value);
@Input() set default(value: SortDirection | boolean | "") {
if (value === "desc" || value === "asc") {
this._default = value;
} else {
this._default = coerceBooleanProperty(value) ? "asc" : false;
}
}
/**
@@ -32,6 +36,11 @@ export class SortableComponent implements OnInit {
*
* @example
* fn = (a, b) => a.name.localeCompare(b.name)
*
* fn = (a, b, direction) => {
* const result = a.name.localeCompare(b.name)
* return direction === 'asc' ? result : -result;
* }
*/
@Input() fn: SortFn;
@@ -52,8 +61,18 @@ export class SortableComponent implements OnInit {
protected setActive() {
if (this.table.dataSource) {
const direction = this.isActive && this.direction === "asc" ? "desc" : "asc";
this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn };
const defaultDirection = this._default === "desc" ? "desc" : "asc";
const direction = this.isActive
? this.direction === "asc"
? "desc"
: "asc"
: defaultDirection;
this.table.dataSource.sort = {
column: this.bitSortable,
direction: direction,
fn: this.fn,
};
}
}

View File

@@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections";
import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs";
export type SortDirection = "asc" | "desc";
export type SortFn = (a: any, b: any) => number;
export type SortFn = (a: any, b: any, direction?: SortDirection) => number;
export type Sort = {
column?: string;
direction: SortDirection;
@@ -166,7 +166,7 @@ export class TableDataSource<T> extends DataSource<T> {
return data.sort((a, b) => {
// If a custom sort function is provided, use it instead of the default.
if (sort.fn) {
return sort.fn(a, b) * directionModifier;
return sort.fn(a, b, sort.direction) * directionModifier;
}
let valueA = this.sortingDataAccessor(a, column);

View File

@@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`.
We provide a simple component for displaying sortable column headers. The `bitSortable` component
wires up to the `TableDataSource` and will automatically sort the data when clicked and display an
indicator for which column is currently sorted. The dafault sorting can be specified by setting the
indicator for which column is currently sorted. The default sorting can be specified by setting the
`default`.
```html
@@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci
<th bitCell bitSortable="name" default>Name</th>
```
For default sorting in descending order, set default="desc"
```html
<th bitCell bitSortable="name" default="desc">Name</th>
```
It's also possible to define a custom sorting function by setting the `fn` input.
```ts
// Basic sort function
const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
// Direction aware sort function
const sortByName = (a: T, b: T, direction?: SortDirection) => {
const result = a.name.localeCompare(b.name);
return direction === "asc" ? result : -result;
};
```
### Filtering

View File

@@ -38,6 +38,7 @@ export class ImportSuccessDialogComponent implements OnInit {
let cards = 0;
let identities = 0;
let secureNotes = 0;
let sshKeys = 0;
this.data.ciphers.map((c) => {
switch (c.type) {
case CipherType.Login:
@@ -52,6 +53,9 @@ export class ImportSuccessDialogComponent implements OnInit {
case CipherType.Identity:
identities++;
break;
case CipherType.SshKey:
sshKeys++;
break;
default:
break;
}
@@ -70,6 +74,9 @@ export class ImportSuccessDialogComponent implements OnInit {
if (secureNotes > 0) {
list.push({ icon: "sticky-note", type: "typeSecureNote", count: secureNotes });
}
if (sshKeys > 0) {
list.push({ icon: "key", type: "typeSSHKey", count: sshKeys });
}
if (this.data.folders.length > 0) {
list.push({ icon: "folder", type: "folders", count: this.data.folders.length });
}

View File

@@ -12,11 +12,6 @@ import {
import { completeOnAccountSwitch } from "./util";
/** Splits an email into a username, subaddress, and domain named group.
* Subaddress is optional.
*/
export const DOMAIN_PARSER = new RegExp("[^@]+@(?<domain>.+)");
/** Options group for catchall emails */
@Component({
selector: "tools-catchall-settings",

View File

@@ -39,14 +39,12 @@
</div>
</bit-card>
<tools-password-settings
#passwordSettings
class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === 'password'"
[userId]="userId$ | async"
(onUpdated)="generate('password settings')"
/>
<tools-passphrase-settings
#passphraseSettings
class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === 'passphrase'"
[userId]="userId$ | async"
@@ -84,25 +82,21 @@
</bit-form-field>
</form>
<tools-catchall-settings
#catchallSettings
*ngIf="(showAlgorithm$ | async)?.id === 'catchall'"
[userId]="userId$ | async"
(onUpdated)="generate('catchall settings')"
/>
<tools-forwarder-settings
#forwarderSettings
*ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async"
/>
<tools-subaddress-settings
#subaddressSettings
*ngIf="(showAlgorithm$ | async)?.id === 'subaddress'"
[userId]="userId$ | async"
(onUpdated)="generate('subaddress settings')"
/>
<tools-username-settings
#usernameSettings
*ngIf="(showAlgorithm$ | async)?.id === 'username'"
[userId]="userId$ | async"
(onUpdated)="generate('username settings')"

View File

@@ -37,7 +37,6 @@
</div>
</bit-card>
<tools-password-settings
#passwordSettings
class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'password'"
[userId]="this.userId$ | async"
@@ -45,7 +44,6 @@
(onUpdated)="generate('password settings')"
/>
<tools-passphrase-settings
#passphraseSettings
class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'passphrase'"
[userId]="this.userId$ | async"

View File

@@ -59,25 +59,21 @@
</bit-form-field>
</form>
<tools-catchall-settings
#catchallSettings
*ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="this.userId$ | async"
(onUpdated)="generate('catchall settings')"
/>
<tools-forwarder-settings
#forwarderSettings
*ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async"
/>
<tools-subaddress-settings
#subaddressSettings
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="this.userId$ | async"
(onUpdated)="generate('subaddress settings')"
/>
<tools-username-settings
#usernameSettings
*ngIf="(algorithm$ | async)?.id === 'username'"
[userId]="this.userId$ | async"
(onUpdated)="generate('username settings')"

View File

@@ -2,6 +2,7 @@ import { Constraint } from "@bitwarden/common/tools/types";
import { sum } from "../util";
const Zero: Constraint<number> = { min: 0, max: 0 };
const AtLeastOne: Constraint<number> = { min: 1 };
const RequiresTrue: Constraint<boolean> = { requiredValue: true };
@@ -159,6 +160,7 @@ export {
enforceConstant,
readonlyTrueWhen,
fitLength,
Zero,
AtLeastOne,
RequiresTrue,
};

View File

@@ -1,6 +1,6 @@
import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data";
import { AtLeastOne } from "./constraints";
import { AtLeastOne, Zero } from "./constraints";
import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
describe("DynamicPasswordPolicyConstraints", () => {
@@ -207,7 +207,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber);
});
it("disables the minNumber constraint when the state's number flag is false", () => {
it("outputs the zero constraint when the state's number flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
@@ -216,7 +216,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minNumber).toBeUndefined();
expect(calibrated.constraints.minNumber).toEqual(Zero);
});
it("outputs the minSpecial constraint when the state's special flag is true", () => {
@@ -231,7 +231,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial);
});
it("disables the minSpecial constraint when the state's special flag is false", () => {
it("outputs the zero constraint when the state's special flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
@@ -240,23 +240,7 @@ describe("DynamicPasswordPolicyConstraints", () => {
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("copies the minimum length constraint", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
expect(calibrated.constraints.minSpecial).toEqual(Zero);
});
});
});

View File

@@ -7,7 +7,7 @@ import {
import { DefaultPasswordBoundaries } from "../data";
import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types";
import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints";
import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "./constraints";
import { PasswordPolicyConstraints } from "./password-policy-constraints";
/** Creates state constraints by blending policy and password settings. */
@@ -68,8 +68,8 @@ export class DynamicPasswordPolicyConstraints
...this.constraints,
minLowercase: maybe<number>(lowercase, this.constraints.minLowercase ?? AtLeastOne),
minUppercase: maybe<number>(uppercase, this.constraints.minUppercase ?? AtLeastOne),
minNumber: maybe<number>(number, this.constraints.minNumber),
minSpecial: maybe<number>(special, this.constraints.minSpecial),
minNumber: maybe<number>(number, this.constraints.minNumber) ?? Zero,
minSpecial: maybe<number>(special, this.constraints.minSpecial) ?? Zero,
};
// lower bound of length must always at least fit its sub-lengths

View File

@@ -8,6 +8,7 @@ import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.
import { IdentitySectionComponent } from "./components/identity/identity.component";
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component";
import { SshKeySectionComponent } from "./components/sshkey-section/sshkey-section.component";
/**
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
@@ -20,6 +21,7 @@ export type CipherForm = {
autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"];
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
identityDetails?: IdentitySectionComponent["identityForm"];
sshKeyDetails?: SshKeySectionComponent["sshKeyForm"];
customFields?: CustomFieldsComponent["customFieldsForm"];
};

View File

@@ -22,6 +22,12 @@
[disabled]="config.mode === 'partial-edit'"
></vault-card-details-section>
<vault-sshkey-section
*ngIf="config.cipherType === CipherType.SshKey"
[disabled]="config.mode === 'partial-edit'"
[originalCipherView]="originalCipherView"
></vault-sshkey-section>
<vault-additional-options-section></vault-additional-options-section>
<!-- Attachments are only available for existing ciphers -->

View File

@@ -42,6 +42,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details
import { IdentitySectionComponent } from "./identity/identity.component";
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component";
@Component({
selector: "vault-cipher-form",
@@ -65,6 +66,7 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta
ItemDetailsSectionComponent,
CardDetailsSectionComponent,
IdentitySectionComponent,
SshKeySectionComponent,
NgIf,
AdditionalOptionsSectionComponent,
LoginDetailsSectionComponent,

View File

@@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{ id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
fixture.detectChanges();
@@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
fixture.detectChanges();
@@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => true,
} as CollectionView,
];
@@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => {
expect(collectionHint).not.toBeNull();
});
it("should allow all collections to be altered when `config.admin` is true", async () => {
component.config.admin = true;
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: false,
canEditItems: (_org) => false,
} as CollectionView,
];
fixture.detectChanges();
await fixture.whenStable();
component.itemDetailsForm.controls.organizationId.setValue("org1");
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
});
});
describe("readonlyCollections", () => {
beforeEach(() => {
component.config.mode = "edit";
component.config.admin = true;
component.config.collections = [
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col1", "col2", "col3"],
favorite: true,
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
});
it("should not show collections as readonly when `config.admin` is true", async () => {
await component.ngOnInit();
fixture.detectChanges();
// Filters out all collections
expect(component["readOnlyCollections"]).toEqual([]);
// Non-admin, keep readonly collections
component.config.admin = false;
await component.ngOnInit();
fixture.detectChanges();
expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]);
});
});
});

View File

@@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit {
} else if (this.config.mode === "edit") {
this.readOnlyCollections = this.collections
.filter(
(c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId),
// When the configuration is set up for admins, they can alter read only collections
(c) =>
c.readOnly &&
!this.config.admin &&
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
)
.map((c) => c.name);
}
@@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit {
collectionsControl.disable();
this.showCollectionsControl = false;
return;
} else {
collectionsControl.enable();
this.showCollectionsControl = true;
}
const organization = this.organizations.find((o) => o.id === orgId);
this.collectionOptions = this.collections
.filter((c) => {
// If partial edit mode, show all org collections because the control is disabled.
return c.organizationId === orgId && (this.partialEdit || !c.readOnly);
// Filter criteria:
// - The collection belongs to the organization
// - When in partial edit mode, show all org collections because the control is disabled.
// - The user can edit items within the collection
// - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections
return (
c.organizationId === orgId &&
(this.partialEdit || c.canEditItems(organization) || this.config.admin)
);
})
.map((c) => ({
id: c.id,

View File

@@ -0,0 +1,30 @@
<bit-section [formGroup]="sshKeyForm">
<bit-section-header>
<h2 bitTypography="h6">
{{ "typeSshKey" | i18n }}
</h2>
</bit-section-header>
<bit-card>
<bit-form-field>
<bit-label>{{ "sshPrivateKey" | i18n }}</bit-label>
<input id="privateKey" bitInput formControlName="privateKey" type="password" />
<button
type="button"
bitIconButton
bitSuffix
data-testid="toggle-privateKey-visibility"
bitPasswordInputToggle
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "sshPublicKey" | i18n }}</bit-label>
<input id="publicKey" bitInput formControlName="publicKey" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "sshFingerprint" | i18n }}</bit-label>
<input id="keyFingerprint" bitInput formControlName="keyFingerprint" />
</bit-form-field>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,80 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
} from "@bitwarden/components";
import { CipherFormContainer } from "../../cipher-form-container";
@Component({
selector: "vault-sshkey-section",
templateUrl: "./sshkey-section.component.html",
standalone: true,
imports: [
CardComponent,
SectionComponent,
TypographyModule,
FormFieldModule,
ReactiveFormsModule,
SelectModule,
SectionHeaderComponent,
IconButtonModule,
JslibModule,
CommonModule,
],
})
export class SshKeySectionComponent implements OnInit {
/** The original cipher */
@Input() originalCipherView: CipherView;
/** True when all fields should be disabled */
@Input() disabled: boolean;
/**
* All form fields associated with the ssh key
*
* Note: `as` is used to assert the type of the form control,
* leaving as just null gets inferred as `unknown`
*/
sshKeyForm = this.formBuilder.group({
privateKey: null as string | null,
publicKey: null as string | null,
keyFingerprint: null as string | null,
});
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {}
ngOnInit() {
if (this.originalCipherView?.card) {
this.setInitialValues();
}
this.sshKeyForm.disable();
}
/** Set form initial form values from the current cipher */
private setInitialValues() {
const { privateKey, publicKey, keyFingerprint } = this.originalCipherView.sshKey;
this.sshKeyForm.setValue({
privateKey,
publicKey,
keyFingerprint,
});
}
}

View File

@@ -1,6 +1,7 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -17,6 +18,7 @@ function isSetEqual(a: Set<string>, b: Set<string>) {
export class DefaultCipherFormService implements CipherFormService {
private cipherService: CipherService = inject(CipherService);
private accountService: AccountService = inject(AccountService);
private apiService: ApiService = inject(ApiService);
async decryptCipher(cipher: Cipher): Promise<CipherView> {
const activeUserId = await firstValueFrom(
@@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService {
// Updating a cipher with collection changes is not supported with a single request currently
// First update the cipher with the original collectionIds
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
await this.cipherService.updateWithServer(encryptedCipher, config.admin);
await this.cipherService.updateWithServer(
encryptedCipher,
config.admin || originalCollectionIds.size === 0,
config.mode !== "clone",
);
// Then save the new collection changes separately
encryptedCipher.collectionIds = cipher.collectionIds;
savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
if (config.admin || originalCollectionIds.size === 0) {
// When using an admin config or the cipher was unassigned, update collections as an admin
savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);
} else {
savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
}
}
// Its possible the cipher was made no longer available due to collection assignment changes

View File

@@ -40,6 +40,9 @@
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
</app-view-identity-sections>
<!-- SshKEY SECTIONS -->
<app-sshkey-view *ngIf="hasSshKey" [sshKey]="cipher.sshKey"></app-sshkey-view>
<!-- ADDITIONAL OPTIONS -->
<ng-container *ngIf="cipher.notes">
<app-additional-options [notes]="cipher.notes"> </app-additional-options>

View File

@@ -21,6 +21,7 @@ import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.compone
import { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component";
import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component";
import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component";
@Component({
@@ -38,6 +39,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
ItemHistoryV2Component,
CustomFieldV2Component,
CardDetailsComponent,
SshKeyViewComponent,
ViewIdentitySectionsComponent,
LoginCredentialsViewComponent,
AutofillOptionsViewComponent,
@@ -95,9 +97,14 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
return this.cipher.login?.uris.length > 0;
}
get hasSshKey() {
return this.cipher.sshKey?.privateKey;
}
async loadCipherData() {
// Load collections if not provided and the cipher has collectionIds
if (
this.cipher.collectionIds &&
this.cipher.collectionIds.length > 0 &&
(!this.collections || this.collections.length === 0)
) {

View File

@@ -0,0 +1,50 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "typeSshKey" | i18n }}</h2>
</bit-section-header>
<bit-card class="[&_bit-form-field:last-of-type]:tw-mb-0">
<bit-form-field>
<bit-label>{{ "sshPrivateKey" | i18n }}</bit-label>
<input readonly bitInput [value]="sshKey.privateKey" aria-readonly="true" type="password" />
<button
bitSuffix
type="button"
bitIconButton
bitPasswordInputToggle
data-testid="toggle-privateKey"
></button>
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="sshKey.privateKey"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "sshPublicKey" | i18n }}</bit-label>
<input readonly bitInput [value]="sshKey.publicKey" aria-readonly="true" />
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="sshKey.publicKey"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "sshFingerprint" | i18n }}</bit-label>
<input readonly bitInput [value]="sshKey.keyFingerprint" aria-readonly="true" />
<button
bitIconButton="bwi-clone"
bitSuffix
type="button"
[appCopyClick]="sshKey.keyFingerprint"
showToast
[appA11yTitle]="'copyValue' | i18n"
></button>
</bit-form-field>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,35 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import {
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
import { OrgIconDirective } from "../../components/org-icon.directive";
@Component({
selector: "app-sshkey-view",
templateUrl: "sshkey-view.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
OrgIconDirective,
FormFieldModule,
IconButtonModule,
],
})
export class SshKeyViewComponent {
@Input() sshKey: SshKeyView;
}

View File

@@ -91,6 +91,12 @@ export class CopyCipherFieldDirective implements OnChanges {
return this.cipher.identity?.fullAddressForCopy;
case "secureNote":
return this.cipher.notes;
case "privateKey":
return this.cipher.sshKey?.privateKey;
case "publicKey":
return this.cipher.sshKey?.publicKey;
case "keyFingerprint":
return this.cipher.sshKey?.keyFingerprint;
default:
return null;
}

View File

@@ -15,10 +15,10 @@
bitIconButton="bwi-clone"
[appA11yTitle]="'copyPassword' | i18n"
appStopClick
(click)="copy(h.password)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
[appCopyClick]="h.password"
[valueLabel]="'password' | i18n"
showToast
></button>
</bit-item-action>
</ng-container>
</bit-item>

View File

@@ -3,14 +3,13 @@ import { By } from "@angular/platform-browser";
import { BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components";
import { ColorPasswordModule, ItemModule } from "@bitwarden/components";
import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component";
import { PasswordHistoryViewComponent } from "./password-history-view.component";
@@ -25,8 +24,6 @@ describe("PasswordHistoryViewComponent", () => {
organizationId: "222-444-555",
} as CipherView;
const copyToClipboard = jest.fn();
const showToast = jest.fn();
const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" });
const mockCipherService = {
get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }),
@@ -36,17 +33,13 @@ describe("PasswordHistoryViewComponent", () => {
beforeEach(async () => {
mockCipherService.get.mockClear();
mockCipherService.getKeyForCipherKeyDecryption.mockClear();
copyToClipboard.mockClear();
showToast.mockClear();
await TestBed.configureTestingModule({
imports: [ItemModule, ColorPasswordModule, JslibModule],
providers: [
{ provide: WINDOW, useValue: window },
{ provide: CipherService, useValue: mockCipherService },
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
{ provide: PlatformUtilsService },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: ToastService, useValue: { showToast } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
}).compileComponents();
@@ -80,18 +73,5 @@ describe("PasswordHistoryViewComponent", () => {
"bad-password-2",
]);
});
it("copies a password", () => {
const copyButton = fixture.debugElement.query(By.css("button"));
copyButton.nativeElement.click();
expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window });
expect(showToast).toHaveBeenCalledWith({
message: "passwordCopied",
title: "",
variant: "info",
});
});
});
});

View File

@@ -1,21 +1,14 @@
import { CommonModule } from "@angular/common";
import { OnInit, Inject, Component, Input } from "@angular/core";
import { OnInit, Component, Input } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import {
ToastService,
ItemModule,
ColorPasswordModule,
IconButtonModule,
} from "@bitwarden/components";
import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/components";
@Component({
selector: "vault-password-history-view",
@@ -33,29 +26,15 @@ export class PasswordHistoryViewComponent implements OnInit {
history: PasswordHistoryView[] = [];
constructor(
@Inject(WINDOW) private win: Window,
protected cipherService: CipherService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected accountService: AccountService,
protected toastService: ToastService,
) {}
async ngOnInit() {
await this.init();
}
/** Copies a password to the clipboard. */
copy(password: string) {
const copyOptions = this.win != null ? { window: this.win } : undefined;
this.platformUtilsService.copyToClipboard(password, copyOptions);
this.toastService.showToast({
variant: "info",
title: "",
message: this.i18nService.t("passwordCopied"),
});
}
/** Retrieve the password history for the given cipher */
protected async init() {
const cipher = await this.cipherService.get(this.cipherId);

View File

@@ -25,7 +25,10 @@ export type CopyAction =
| "phone"
| "address"
| "secureNote"
| "hiddenField";
| "hiddenField"
| "privateKey"
| "publicKey"
| "keyFingerprint";
type CopyActionInfo = {
/**
@@ -62,6 +65,9 @@ const CopyActions: Record<CopyAction, CopyActionInfo> = {
phone: { typeI18nKey: "phone", protected: true },
address: { typeI18nKey: "address", protected: true },
secureNote: { typeI18nKey: "note", protected: true },
privateKey: { typeI18nKey: "sshPrivateKey", protected: true },
publicKey: { typeI18nKey: "sshPublicKey", protected: true },
keyFingerprint: { typeI18nKey: "sshFingerprint", protected: true },
hiddenField: {
typeI18nKey: "value",
protected: true,