mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 22:44:11 +00:00
Merge branch 'main' into vault/PM-12049
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;
|
||||
@@ -136,6 +139,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" },
|
||||
@@ -202,6 +206,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() {
|
||||
@@ -281,6 +290,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;
|
||||
}
|
||||
}
|
||||
@@ -603,6 +613,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;
|
||||
@@ -327,6 +328,10 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
|
||||
async checkPassword() {
|
||||
if (
|
||||
this.cipher.login == null ||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
|
||||
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
|
||||
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
|
||||
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
|
||||
import { PaymentRequest } from "../../../billing/models/request/payment.request";
|
||||
@@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction {
|
||||
getLicense: (id: string, installationId: string) => Promise<unknown>;
|
||||
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
|
||||
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
|
||||
createWithoutPayment: (
|
||||
request: OrganizationNoPaymentMethodCreateRequest,
|
||||
) => Promise<OrganizationResponse>;
|
||||
createLicense: (data: FormData) => Promise<OrganizationResponse>;
|
||||
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
|
||||
updatePayment: (id: string, request: PaymentRequest) => Promise<void>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum ProviderType {
|
||||
Msp = 0,
|
||||
Reseller = 1,
|
||||
MultiOrganizationEnterprise = 2,
|
||||
}
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import { PaymentMethodType, PlanType } from "../../../billing/enums";
|
||||
import { InitiationPath } from "../../../models/request/reference-event.request";
|
||||
import { PaymentMethodType } from "../../../billing/enums";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
|
||||
import { OrganizationKeysRequest } from "./organization-keys.request";
|
||||
|
||||
export class OrganizationCreateRequest {
|
||||
name: string;
|
||||
businessName: string;
|
||||
billingEmail: string;
|
||||
planType: PlanType;
|
||||
key: string;
|
||||
keys: OrganizationKeysRequest;
|
||||
export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest {
|
||||
paymentMethodType: PaymentMethodType;
|
||||
paymentToken: string;
|
||||
additionalSeats: number;
|
||||
maxAutoscaleSeats: number;
|
||||
additionalStorageGb: number;
|
||||
premiumAccessAddon: boolean;
|
||||
collectionName: string;
|
||||
taxIdNumber: string;
|
||||
billingAddressLine1: string;
|
||||
billingAddressLine2: string;
|
||||
billingAddressCity: string;
|
||||
billingAddressState: string;
|
||||
billingAddressPostalCode: string;
|
||||
billingAddressCountry: string;
|
||||
useSecretsManager: boolean;
|
||||
additionalSmSeats: number;
|
||||
additionalServiceAccounts: number;
|
||||
isFromSecretsManagerTrial: boolean;
|
||||
initiationPath: InitiationPath;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
|
||||
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
|
||||
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
|
||||
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
|
||||
import { PaymentRequest } from "../../../billing/models/request/payment.request";
|
||||
@@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
return new OrganizationResponse(r);
|
||||
}
|
||||
|
||||
async createWithoutPayment(
|
||||
request: OrganizationNoPaymentMethodCreateRequest,
|
||||
): Promise<OrganizationResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/create-without-payment",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
// Forcing a sync will notify organization service that they need to repull
|
||||
await this.syncService.fullSync(true);
|
||||
return new OrganizationResponse(r);
|
||||
}
|
||||
|
||||
async createLicense(data: FormData): Promise<OrganizationResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
|
||||
@@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction {
|
||||
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
purchaseSubscriptionNoPaymentMethod: (
|
||||
subscription: SubscriptionInformation,
|
||||
) => Promise<OrganizationResponse>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request";
|
||||
import { InitiationPath } from "../../../models/request/reference-event.request";
|
||||
import { PlanType } from "../../enums";
|
||||
|
||||
export class OrganizationNoPaymentMethodCreateRequest {
|
||||
name: string;
|
||||
businessName: string;
|
||||
billingEmail: string;
|
||||
planType: PlanType;
|
||||
key: string;
|
||||
keys: OrganizationKeysRequest;
|
||||
additionalSeats: number;
|
||||
maxAutoscaleSeats: number;
|
||||
additionalStorageGb: number;
|
||||
premiumAccessAddon: boolean;
|
||||
collectionName: string;
|
||||
taxIdNumber: string;
|
||||
billingAddressLine1: string;
|
||||
billingAddressLine2: string;
|
||||
billingAddressCity: string;
|
||||
billingAddressState: string;
|
||||
billingAddressPostalCode: string;
|
||||
billingAddressCountry: string;
|
||||
useSecretsManager: boolean;
|
||||
additionalSmSeats: number;
|
||||
additionalServiceAccounts: number;
|
||||
isFromSecretsManagerTrial: boolean;
|
||||
initiationPath: InitiationPath;
|
||||
}
|
||||
@@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
isEligibleForSelfHost: boolean;
|
||||
isManaged: boolean;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
isSubscriptionUnpaid: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
|
||||
this.isManaged = this.getResponseProperty("IsManaged");
|
||||
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
||||
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { SubscriptionSuspensionResponse } from "@bitwarden/common/billing/models/response/subscription-suspension.response";
|
||||
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
|
||||
|
||||
@@ -13,6 +15,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
|
||||
taxInformation?: TaxInfoResponse;
|
||||
cancelAt?: string;
|
||||
suspension?: SubscriptionSuspensionResponse;
|
||||
providerType: ProviderType;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -34,6 +37,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
|
||||
if (suspension != null) {
|
||||
this.suspension = new SubscriptionSuspensionResponse(suspension);
|
||||
}
|
||||
this.providerType = this.getResponseProperty("providerType");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +48,8 @@ export class ProviderPlanResponse extends BaseResponse {
|
||||
purchasedSeats: number;
|
||||
cost: number;
|
||||
cadence: string;
|
||||
type: PlanType;
|
||||
productTier: ProductTierType;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -53,5 +59,7 @@ export class ProviderPlanResponse extends BaseResponse {
|
||||
this.purchasedSeats = this.getResponseProperty("PurchasedSeats");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
this.cadence = this.getResponseProperty("Cadence");
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.productTier = this.getResponseProperty("ProductTier");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SubscriptionInformation,
|
||||
} from "../abstractions/organization-billing.service";
|
||||
import { PlanType } from "../enums";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
|
||||
|
||||
interface OrganizationKeys {
|
||||
encryptedKey: EncString;
|
||||
@@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
return response;
|
||||
}
|
||||
|
||||
async purchaseSubscriptionNoPaymentMethod(
|
||||
subscription: SubscriptionInformation,
|
||||
): Promise<OrganizationResponse> {
|
||||
const request = new OrganizationNoPaymentMethodCreateRequest();
|
||||
|
||||
const organizationKeys = await this.makeOrganizationKeys();
|
||||
|
||||
this.setOrganizationKeys(request, organizationKeys);
|
||||
|
||||
this.setOrganizationInformation(request, subscription.organization);
|
||||
|
||||
this.setPlanInformation(request, subscription.plan);
|
||||
|
||||
const response = await this.organizationApiService.createWithoutPayment(request);
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async makeOrganizationKeys(): Promise<OrganizationKeys> {
|
||||
const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>();
|
||||
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key);
|
||||
@@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
}
|
||||
|
||||
private setOrganizationInformation(
|
||||
request: OrganizationCreateRequest,
|
||||
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
|
||||
information: OrganizationInformation,
|
||||
): void {
|
||||
request.name = information.name;
|
||||
@@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
request.initiationPath = information.initiationPath;
|
||||
}
|
||||
|
||||
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
|
||||
private setOrganizationKeys(
|
||||
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
|
||||
keys: OrganizationKeys,
|
||||
): void {
|
||||
request.key = keys.encryptedKey.encryptedString;
|
||||
request.keys = new OrganizationKeysRequest(
|
||||
keys.publicKey,
|
||||
@@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
}
|
||||
|
||||
private setPlanInformation(
|
||||
request: OrganizationCreateRequest,
|
||||
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
|
||||
information: PlanInformation,
|
||||
): void {
|
||||
request.planType = information.type;
|
||||
|
||||
@@ -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,9 @@ 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",
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
SecurityTasks = "security-tasks",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -72,6 +77,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 +87,9 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.AccessIntelligence]: FALSE,
|
||||
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
|
||||
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: 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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,36 @@ describe("Fido2 Utils", () => {
|
||||
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
|
||||
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
|
||||
|
||||
describe("bufferSourceToUint8Array(..)", () => {
|
||||
it("should convert an ArrayBuffer", () => {
|
||||
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
|
||||
const out = Fido2Utils.bufferSourceToUint8Array(buffer);
|
||||
expect(out).toEqual(new Uint8Array(asciiHelloWorldArray));
|
||||
});
|
||||
it("should convert an ArrayBuffer slice", () => {
|
||||
const buffer = new Uint8Array(asciiHelloWorldArray).buffer.slice(8);
|
||||
const out = Fido2Utils.bufferSourceToUint8Array(buffer);
|
||||
expect(out).toEqual(new Uint8Array([114, 108, 100])); // 8th byte onwards
|
||||
});
|
||||
it("should pass through an Uint8Array", () => {
|
||||
const typedArray = new Uint8Array(asciiHelloWorldArray);
|
||||
const out = Fido2Utils.bufferSourceToUint8Array(typedArray);
|
||||
expect(out).toEqual(new Uint8Array(asciiHelloWorldArray));
|
||||
});
|
||||
it("should preserve the view of TypedArray", () => {
|
||||
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
|
||||
const input = new Uint8Array(buffer, 8, 1);
|
||||
const out = Fido2Utils.bufferSourceToUint8Array(input);
|
||||
expect(out).toEqual(new Uint8Array([114]));
|
||||
});
|
||||
it("should convert different TypedArrays", () => {
|
||||
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
|
||||
const input = new Uint16Array(buffer, 8, 1);
|
||||
const out = Fido2Utils.bufferSourceToUint8Array(input);
|
||||
expect(out).toEqual(new Uint8Array([114, 108]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromBufferToB64(...)", () => {
|
||||
it("should convert an ArrayBuffer to a b64 string", () => {
|
||||
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
export class Fido2Utils {
|
||||
static bufferToString(bufferSource: BufferSource): string {
|
||||
let buffer: Uint8Array;
|
||||
if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) {
|
||||
buffer = new Uint8Array(bufferSource as ArrayBuffer);
|
||||
} else {
|
||||
buffer = new Uint8Array(bufferSource.buffer);
|
||||
}
|
||||
|
||||
return Fido2Utils.fromBufferToB64(buffer)
|
||||
return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
@@ -18,12 +11,10 @@ export class Fido2Utils {
|
||||
}
|
||||
|
||||
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
|
||||
if (bufferSource instanceof Uint8Array) {
|
||||
return bufferSource;
|
||||
} else if (Fido2Utils.isArrayBuffer(bufferSource)) {
|
||||
if (Fido2Utils.isArrayBuffer(bufferSource)) {
|
||||
return new Uint8Array(bufferSource);
|
||||
} else {
|
||||
return new Uint8Array(bufferSource.buffer);
|
||||
return new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -373,7 +373,11 @@ describe("UserStateSubject", () => {
|
||||
singleUserId$.next(SomeUser);
|
||||
await awaitAsync();
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
|
||||
expect(state.nextMock).toHaveBeenCalledWith({
|
||||
foo: "next",
|
||||
// FIXME: don't leak this detail into the test
|
||||
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
|
||||
@@ -394,7 +398,13 @@ describe("UserStateSubject", () => {
|
||||
await awaitAsync();
|
||||
|
||||
const encrypted = { foo: "encrypt(next)" };
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
|
||||
expect(state.nextMock).toHaveBeenCalledWith({
|
||||
id: null,
|
||||
secret: encrypted,
|
||||
disclosed: null,
|
||||
// FIXME: don't leak this detail into the test
|
||||
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies dynamic constraints", async () => {
|
||||
|
||||
@@ -43,6 +43,23 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
|
||||
|
||||
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
|
||||
|
||||
// FIXME: The subject should always repeat the value when it's own `next` method is called.
|
||||
//
|
||||
// Chrome StateService only calls `next` when the underlying values changes. When enforcing,
|
||||
// say, a minimum constraint, any value beneath the minimum becomes the minimum. This prevents
|
||||
// invalid data received in sequence from calling `next` because the state provider doesn't
|
||||
// emit.
|
||||
//
|
||||
// The hack is pretty simple. Insert arbitrary data into the saved data to ensure
|
||||
// that it *always* changes.
|
||||
//
|
||||
// Any real fix will be fairly complex because it needs to recognize *fast* when it
|
||||
// is waiting. Alternatively, the kludge could become a format properly fed by random noise.
|
||||
//
|
||||
// NOTE: this only matters for plaintext objects; encrypted fields change with every
|
||||
// update b/c their IVs change.
|
||||
const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$";
|
||||
|
||||
/**
|
||||
* Adapt a state provider to an rxjs subject.
|
||||
*
|
||||
@@ -420,8 +437,25 @@ export class UserStateSubject<
|
||||
private inputSubscription: Unsubscribable;
|
||||
private outputSubscription: Unsubscribable;
|
||||
|
||||
private counter = 0;
|
||||
|
||||
private onNext(value: unknown) {
|
||||
this.state.update(() => value).catch((e: any) => this.onError(e));
|
||||
this.state
|
||||
.update(() => {
|
||||
if (typeof value === "object") {
|
||||
// related: ALWAYS_UPDATE_KLUDGE FIXME
|
||||
const counter = this.counter++;
|
||||
if (counter > Number.MAX_SAFE_INTEGER) {
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
const kludge = value as any;
|
||||
kludge[ALWAYS_UPDATE_KLUDGE] = counter;
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
.catch((e: any) => this.onError(e));
|
||||
}
|
||||
|
||||
private onError(value: any) {
|
||||
|
||||
@@ -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";
|
||||
@@ -1570,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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
const boundariesHint = this.i18nService.t(
|
||||
"generatorBoundariesHint",
|
||||
constraints.numWords.min,
|
||||
constraints.numWords.max,
|
||||
constraints.numWords.min?.toString(),
|
||||
constraints.numWords.max?.toString(),
|
||||
);
|
||||
this.numWordsBoundariesHint.next(boundariesHint);
|
||||
});
|
||||
|
||||
@@ -171,10 +171,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
|
||||
this.minNumber.valueChanges
|
||||
.pipe(
|
||||
map((value) => [value, value > 0] as const),
|
||||
tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)),
|
||||
tap(([value, checkNumbers]) => (lastMinNumber = checkNumbers ? value : lastMinNumber)),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false }));
|
||||
.subscribe(([, checkNumbers]) => this.numbers.setValue(checkNumbers, { emitEvent: false }));
|
||||
|
||||
let lastMinSpecial = 1;
|
||||
this.special.valueChanges
|
||||
@@ -188,10 +188,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
|
||||
this.minSpecial.valueChanges
|
||||
.pipe(
|
||||
map((value) => [value, value > 0] as const),
|
||||
tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)),
|
||||
tap(([value, checkSpecial]) => (lastMinSpecial = checkSpecial ? value : lastMinSpecial)),
|
||||
takeUntil(this.destroyed$),
|
||||
)
|
||||
.subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false }));
|
||||
.subscribe(([, checkSpecial]) => this.special.setValue(checkSpecial, { emitEvent: false }));
|
||||
|
||||
// `onUpdated` depends on `settings` because the UserStateSubject is asynchronous;
|
||||
// subscribing directly to `this.settings.valueChanges` introduces a race condition.
|
||||
|
||||
@@ -1163,7 +1163,11 @@ describe("CredentialGeneratorService", () => {
|
||||
await awaitAsync();
|
||||
const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser));
|
||||
|
||||
expect(result).toEqual({ foo: "next value" });
|
||||
expect(result).toEqual({
|
||||
foo: "next value",
|
||||
// FIXME: don't leak this detail into the test
|
||||
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the user to become available", async () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -22,6 +22,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({
|
||||
@@ -39,6 +40,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
||||
ItemHistoryV2Component,
|
||||
CustomFieldV2Component,
|
||||
CardDetailsComponent,
|
||||
SshKeyViewComponent,
|
||||
ViewIdentitySectionsComponent,
|
||||
LoginCredentialsViewComponent,
|
||||
AutofillOptionsViewComponent,
|
||||
@@ -99,6 +101,10 @@ 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 (
|
||||
|
||||
@@ -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