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:
@@ -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;
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
44
libs/common/src/models/export/ssh-key.export.ts
Normal file
44
libs/common/src/models/export/ssh-key.export.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum CipherType {
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SshKey = 5,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
17
libs/common/src/vault/models/api/ssh-key.api.ts
Normal file
17
libs/common/src/vault/models/api/ssh-key.api.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
17
libs/common/src/vault/models/data/ssh-key.data.ts
Normal file
17
libs/common/src/vault/models/data/ssh-key.data.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
67
libs/common/src/vault/models/domain/ssh-key.spec.ts
Normal file
67
libs/common/src/vault/models/domain/ssh-key.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
70
libs/common/src/vault/models/domain/ssh-key.ts
Normal file
70
libs/common/src/vault/models/domain/ssh-key.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
41
libs/common/src/vault/models/view/ssh-key.view.ts
Normal file
41
libs/common/src/vault/models/view/ssh-key.view.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
21
libs/components/src/disclosure/disclosure.component.ts
Normal file
21
libs/components/src/disclosure/disclosure.component.ts
Normal 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++}`;
|
||||
}
|
||||
55
libs/components/src/disclosure/disclosure.mdx
Normal file
55
libs/components/src/disclosure/disclosure.mdx
Normal 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.
|
||||
29
libs/components/src/disclosure/disclosure.stories.ts
Normal file
29
libs/components/src/disclosure/disclosure.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
2
libs/components/src/disclosure/index.ts
Normal file
2
libs/components/src/disclosure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./disclosure-trigger-for.directive";
|
||||
export * from "./disclosure.component";
|
||||
@@ -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,
|
||||
|
||||
@@ -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 theme’s main `background`
|
||||
|
||||
<Story id="component-library-icon-button--main" />
|
||||
<Story of={stories.Main} />
|
||||
|
||||
### Muted
|
||||
|
||||
Used for low emphasis icon buttons appearing on the theme’s main `background`
|
||||
|
||||
<Story id="component-library-icon-button--muted" />
|
||||
<Story of={stories.Muted} />
|
||||
|
||||
### Contrast
|
||||
|
||||
Used on a theme’s 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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user