mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[PM-24304][PM-24305] - [Defect] Some fields are not disabled when editing an item from My Vault (#15982)
* disable all remaining form fields for editing personally owned My Items * fix failing tests * ensure collection field is also properly disabled * clean up logic * fix failing test * fix test * refactor variable to avoid using `is` prefix * directly reference parent form for status rather than subscribe to it * refactor subscription for form status changes * use observable as an Output * disable attachment button on desktop vault when the form * disable custom field components when custom fields already exist and parent form is disabled * disable attachments button in the browser when the edit form is disabled * grab icon button instance for disabled state --------- Co-authored-by: Nick Krantz <nick@livefront.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
<bit-item>
|
<bit-item>
|
||||||
<button bit-item-content type="button" (click)="openAttachments()">
|
<button
|
||||||
|
bit-item-content
|
||||||
|
type="button"
|
||||||
|
(click)="openAttachments()"
|
||||||
|
[disabled]="parentFormDisabled"
|
||||||
|
>
|
||||||
{{ "attachments" | i18n }}
|
{{ "attachments" | i18n }}
|
||||||
<span *ngIf="!canAccessAttachments" bitBadge variant="success" slot="default-trailing">
|
<span *ngIf="!canAccessAttachments" bitBadge variant="success" slot="default-trailing">
|
||||||
{{ "premium" | i18n }}
|
{{ "premium" | i18n }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { By } from "@angular/platform-browser";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { RouterTestingModule } from "@angular/router/testing";
|
import { RouterTestingModule } from "@angular/router/testing";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
@@ -15,6 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
|||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
|
import { CipherFormContainer } from "@bitwarden/vault";
|
||||||
|
|
||||||
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
|
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
|
||||||
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
|
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
|
||||||
@@ -62,6 +64,7 @@ describe("OpenAttachmentsComponent", () => {
|
|||||||
name: "Test User",
|
name: "Test User",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled");
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
openCurrentPagePopout.mockClear();
|
openCurrentPagePopout.mockClear();
|
||||||
@@ -70,6 +73,7 @@ describe("OpenAttachmentsComponent", () => {
|
|||||||
organizations$.mockClear();
|
organizations$.mockClear();
|
||||||
showFilePopoutMessage.mockClear();
|
showFilePopoutMessage.mockClear();
|
||||||
hasPremiumFromAnySource$.next(true);
|
hasPremiumFromAnySource$.next(true);
|
||||||
|
formStatusChange$.next("enabled");
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [OpenAttachmentsComponent, RouterTestingModule],
|
imports: [OpenAttachmentsComponent, RouterTestingModule],
|
||||||
@@ -84,6 +88,10 @@ describe("OpenAttachmentsComponent", () => {
|
|||||||
decrypt: jest.fn().mockResolvedValue(cipherView),
|
decrypt: jest.fn().mockResolvedValue(cipherView),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CipherFormContainer,
|
||||||
|
useValue: { formStatusChange$ },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: ToastService,
|
provide: ToastService,
|
||||||
useValue: { showToast },
|
useValue: { showToast },
|
||||||
@@ -147,6 +155,21 @@ describe("OpenAttachmentsComponent", () => {
|
|||||||
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
|
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables attachments when the edit form is disabled", () => {
|
||||||
|
formStatusChange$.next("disabled");
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
let button = fixture.debugElement.query(By.css("button"));
|
||||||
|
|
||||||
|
expect(button.nativeElement.disabled).toBe(true);
|
||||||
|
|
||||||
|
formStatusChange$.next("enabled");
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
button = fixture.debugElement.query(By.css("button"));
|
||||||
|
expect(button.nativeElement.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Free Orgs", () => {
|
describe("Free Orgs", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
component.cipherIsAPartOfFreeOrg = false;
|
component.cipherIsAPartOfFreeOrg = false;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
|||||||
import { CipherId } from "@bitwarden/common/types/guid";
|
import { CipherId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
|
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
|
||||||
|
import { CipherFormContainer } from "@bitwarden/vault";
|
||||||
|
|
||||||
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
|
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
|
||||||
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
|
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
|
||||||
@@ -41,6 +42,9 @@ export class OpenAttachmentsComponent implements OnInit {
|
|||||||
/** True when the cipher is a part of a free organization */
|
/** True when the cipher is a part of a free organization */
|
||||||
cipherIsAPartOfFreeOrg: boolean;
|
cipherIsAPartOfFreeOrg: boolean;
|
||||||
|
|
||||||
|
/** Tracks the disabled status of the edit cipher form */
|
||||||
|
parentFormDisabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
@@ -50,6 +54,7 @@ export class OpenAttachmentsComponent implements OnInit {
|
|||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private filePopoutUtilsService: FilePopoutUtilsService,
|
private filePopoutUtilsService: FilePopoutUtilsService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private cipherFormContainer: CipherFormContainer,
|
||||||
) {
|
) {
|
||||||
this.accountService.activeAccount$
|
this.accountService.activeAccount$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -61,6 +66,10 @@ export class OpenAttachmentsComponent implements OnInit {
|
|||||||
.subscribe((canAccessPremium) => {
|
.subscribe((canAccessPremium) => {
|
||||||
this.canAccessAttachments = canAccessPremium;
|
this.canAccessAttachments = canAccessPremium;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.cipherFormContainer.formStatusChange$.pipe(takeUntilDestroyed()).subscribe((status) => {
|
||||||
|
this.parentFormDisabled = status === "disabled";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
|
|||||||
@@ -35,9 +35,15 @@
|
|||||||
(cipherSaved)="savedCipher($event)"
|
(cipherSaved)="savedCipher($event)"
|
||||||
[beforeSubmit]="onSubmit"
|
[beforeSubmit]="onSubmit"
|
||||||
[submitBtn]="footer?.submitBtn"
|
[submitBtn]="footer?.submitBtn"
|
||||||
|
(formStatusChange$)="formStatusChanged($event)"
|
||||||
>
|
>
|
||||||
<bit-item slot="attachment-button">
|
<bit-item slot="attachment-button">
|
||||||
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
|
<button
|
||||||
|
bit-item-content
|
||||||
|
type="button"
|
||||||
|
(click)="openAttachmentsDialog()"
|
||||||
|
[disabled]="formDisabled"
|
||||||
|
>
|
||||||
<p class="tw-m-0">
|
<p class="tw-m-0">
|
||||||
{{ "attachments" | i18n }}
|
{{ "attachments" | i18n }}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
config: CipherFormConfig | null = null;
|
config: CipherFormConfig | null = null;
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
|
|
||||||
|
/** Tracks the disabled status of the edit cipher form */
|
||||||
|
protected formDisabled: boolean = false;
|
||||||
|
|
||||||
private organizations$: Observable<Organization[]> = this.accountService.activeAccount$.pipe(
|
private organizations$: Observable<Organization[]> = this.accountService.activeAccount$.pipe(
|
||||||
map((a) => a?.id),
|
map((a) => a?.id),
|
||||||
switchMap((id) => this.organizationService.organizations$(id)),
|
switchMap((id) => this.organizationService.organizations$(id)),
|
||||||
@@ -447,6 +450,10 @@ export class VaultV2Component<C extends CipherViewLike>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formStatusChanged(status: "disabled" | "enabled") {
|
||||||
|
this.formDisabled = status === "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
async openAttachmentsDialog() {
|
async openAttachmentsDialog() {
|
||||||
if (!this.userHasPremiumAccess) {
|
if (!this.userHasPremiumAccess) {
|
||||||
await this.premiumUpgradePromptService.promptForPremium();
|
await this.premiumUpgradePromptService.promptForPremium();
|
||||||
|
|||||||
@@ -16,9 +16,15 @@
|
|||||||
[submitBtn]="submitBtn"
|
[submitBtn]="submitBtn"
|
||||||
(formReady)="onFormReady()"
|
(formReady)="onFormReady()"
|
||||||
(cipherSaved)="onCipherSaved($event)"
|
(cipherSaved)="onCipherSaved($event)"
|
||||||
|
(formStatusChange$)="formStatusChanged($event)"
|
||||||
>
|
>
|
||||||
<bit-item slot="attachment-button">
|
<bit-item slot="attachment-button">
|
||||||
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
|
<button
|
||||||
|
[disabled]="attachmentsButtonDisabled"
|
||||||
|
bit-item-content
|
||||||
|
type="button"
|
||||||
|
(click)="openAttachmentsDialog()"
|
||||||
|
>
|
||||||
<p class="tw-m-0">
|
<p class="tw-m-0">
|
||||||
{{ "attachments" | i18n }}
|
{{ "attachments" | i18n }}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
protected canDelete = false;
|
protected canDelete = false;
|
||||||
|
|
||||||
|
protected attachmentsButtonDisabled = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DIALOG_DATA) protected params: VaultItemDialogParams,
|
@Inject(DIALOG_DATA) protected params: VaultItemDialogParams,
|
||||||
private dialogRef: DialogRef<VaultItemDialogResult>,
|
private dialogRef: DialogRef<VaultItemDialogResult>,
|
||||||
@@ -341,6 +343,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formStatusChanged(status: "disabled" | "enabled") {
|
||||||
|
this.attachmentsButtonDisabled = status === "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by the CipherFormComponent when the cipher is saved successfully.
|
* Called by the CipherFormComponent when the cipher is saved successfully.
|
||||||
* @param cipherView - The newly saved cipher.
|
* @param cipherView - The newly saved cipher.
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ export abstract class CipherFormContainer {
|
|||||||
abstract enableFormFields(): void;
|
abstract enableFormFields(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An observable that emits when the form status changes to enabled.
|
* An observable that emits when the form status changes between enabled/disabled.
|
||||||
* This can be used to disable child forms when the parent form is enabled.
|
* This can be used for child forms to react to changes in the form status.
|
||||||
*/
|
*/
|
||||||
formEnabled$: Observable<void>;
|
formStatusChange$: Observable<"enabled" | "disabled">;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
bitLink
|
bitLink
|
||||||
type="button"
|
type="button"
|
||||||
linkType="primary"
|
linkType="primary"
|
||||||
*ngIf="!hasCustomFields && !isPartialEdit"
|
*ngIf="!hasCustomFields && !isPartialEdit && allowNewField"
|
||||||
(click)="addCustomField()"
|
(click)="addCustomField()"
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
|
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ describe("AdditionalOptionsSectionComponent", () => {
|
|||||||
let passwordRepromptEnabled$: BehaviorSubject<boolean>;
|
let passwordRepromptEnabled$: BehaviorSubject<boolean>;
|
||||||
|
|
||||||
const getInitialCipherView = jest.fn(() => null);
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
|
const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled");
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
getInitialCipherView.mockClear();
|
getInitialCipherView.mockClear();
|
||||||
|
|
||||||
cipherFormProvider = mock<CipherFormContainer>({ getInitialCipherView });
|
cipherFormProvider = mock<CipherFormContainer>({ getInitialCipherView, formStatusChange$ });
|
||||||
passwordRepromptService = mock<PasswordRepromptService>();
|
passwordRepromptService = mock<PasswordRepromptService>();
|
||||||
passwordRepromptEnabled$ = new BehaviorSubject(true);
|
passwordRepromptEnabled$ = new BehaviorSubject(true);
|
||||||
passwordRepromptService.enabled$ = passwordRepromptEnabled$;
|
passwordRepromptService.enabled$ = passwordRepromptEnabled$;
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export class AdditionalOptionsSectionComponent implements OnInit {
|
|||||||
|
|
||||||
@Input() disableSectionMargin: boolean;
|
@Input() disableSectionMargin: boolean;
|
||||||
|
|
||||||
|
/** True when the form allows new fields to be added */
|
||||||
|
get allowNewField(): boolean {
|
||||||
|
return this.additionalOptionsForm.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cipherFormContainer: CipherFormContainer,
|
private cipherFormContainer: CipherFormContainer,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ describe("AutofillOptionsComponent", () => {
|
|||||||
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
|
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
|
||||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||||
const getInitialCipherView = jest.fn(() => null);
|
const getInitialCipherView = jest.fn(() => null);
|
||||||
|
const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled");
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
getInitialCipherView.mockClear();
|
getInitialCipherView.mockClear();
|
||||||
cipherFormContainer = mock<CipherFormContainer>({ getInitialCipherView });
|
cipherFormContainer = mock<CipherFormContainer>({ getInitialCipherView, formStatusChange$ });
|
||||||
liveAnnouncer = mock<LiveAnnouncer>();
|
liveAnnouncer = mock<LiveAnnouncer>();
|
||||||
platformUtilsService = mock<PlatformUtilsService>();
|
platformUtilsService = mock<PlatformUtilsService>();
|
||||||
domainSettingsService = mock<DomainSettingsService>();
|
domainSettingsService = mock<DomainSettingsService>();
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
return this.autofillOptionsForm.controls.uris.controls;
|
return this.autofillOptionsForm.controls.uris.controls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get isPartialEdit() {
|
||||||
|
return this.cipherFormContainer.config.mode === "partial-edit";
|
||||||
|
}
|
||||||
|
|
||||||
protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$.pipe(
|
protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$.pipe(
|
||||||
// The default match detection should only be shown when used on the browser
|
// The default match detection should only be shown when used on the browser
|
||||||
filter(() => this.platformUtilsService.getClientType() == ClientType.Browser),
|
filter(() => this.platformUtilsService.getClientType() == ClientType.Browser),
|
||||||
@@ -102,7 +106,7 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
|
|
||||||
this.autofillOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
|
this.autofillOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
|
||||||
this.cipherFormContainer.patchCipher((cipher) => {
|
this.cipherFormContainer.patchCipher((cipher) => {
|
||||||
cipher.login.uris = value.uris.map((uri: UriField) =>
|
cipher.login.uris = value.uris?.map((uri: UriField) =>
|
||||||
Object.assign(new LoginUriView(), {
|
Object.assign(new LoginUriView(), {
|
||||||
uri: uri.uri,
|
uri: uri.uri,
|
||||||
match: uri.matchDetection,
|
match: uri.matchDetection,
|
||||||
@@ -126,6 +130,15 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.uriOptions?.last?.focusInput();
|
this.uriOptions?.last?.focusInput();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.cipherFormContainer.formStatusChange$.pipe(takeUntilDestroyed()).subscribe((status) => {
|
||||||
|
// Disable adding new URIs when the cipher form is disabled
|
||||||
|
if (status === "disabled") {
|
||||||
|
this.autofillOptionsForm.disable();
|
||||||
|
} else if (!this.isPartialEdit) {
|
||||||
|
this.autofillOptionsForm.enable();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -136,7 +149,7 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
this.initNewCipher();
|
this.initNewCipher();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
if (this.isPartialEdit) {
|
||||||
this.autofillOptionsForm.disable();
|
this.autofillOptionsForm.disable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
/**
|
/**
|
||||||
* Emitted when the form is enabled
|
* Emitted when the form is enabled
|
||||||
*/
|
*/
|
||||||
private formEnabledSubject = new Subject<void>();
|
private formStatusChangeSubject = new Subject<"enabled" | "disabled">();
|
||||||
formEnabled$ = this.formEnabledSubject.asObservable();
|
@Output() formStatusChange$ = this.formStatusChangeSubject.asObservable();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The original cipher being edited or cloned. Null for add mode.
|
* The original cipher being edited or cloned. Null for add mode.
|
||||||
@@ -158,11 +158,12 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||||||
|
|
||||||
disableFormFields(): void {
|
disableFormFields(): void {
|
||||||
this.cipherForm.disable({ emitEvent: false });
|
this.cipherForm.disable({ emitEvent: false });
|
||||||
|
this.formStatusChangeSubject.next("disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
enableFormFields(): void {
|
enableFormFields(): void {
|
||||||
this.cipherForm.enable({ emitEvent: false });
|
this.cipherForm.enable({ emitEvent: false });
|
||||||
this.formEnabledSubject.next();
|
this.formStatusChangeSubject.next("enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
bitIconButton="bwi-pencil-square"
|
bitIconButton="bwi-pencil-square"
|
||||||
class="tw-self-center tw-mt-2"
|
class="tw-self-center tw-mt-2"
|
||||||
data-testid="edit-custom-field-button"
|
data-testid="edit-custom-field-button"
|
||||||
|
[disabled]="parentFormDisabled"
|
||||||
*ngIf="canEdit(field.value.type)"
|
*ngIf="canEdit(field.value.type)"
|
||||||
></button>
|
></button>
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
[label]="'reorderToggleButton' | i18n: field.value.name"
|
[label]="'reorderToggleButton' | i18n: field.value.name"
|
||||||
(keydown)="handleKeyDown($event, field.value.name, i)"
|
(keydown)="handleKeyDown($event, field.value.name, i)"
|
||||||
data-testid="reorder-toggle-button"
|
data-testid="reorder-toggle-button"
|
||||||
|
[disabled]="parentFormDisabled"
|
||||||
*ngIf="canEdit(field.value.type)"
|
*ngIf="canEdit(field.value.type)"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +115,8 @@
|
|||||||
bitLink
|
bitLink
|
||||||
linkType="primary"
|
linkType="primary"
|
||||||
(click)="openAddEditCustomFieldDialog()"
|
(click)="openAddEditCustomFieldDialog()"
|
||||||
*ngIf="!isPartialEdit"
|
data-testid="add-field-button"
|
||||||
|
*ngIf="!isPartialEdit && !parentFormDisabled"
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
|
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
|
||||||
{{ "addField" | i18n }}
|
{{ "addField" | i18n }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DebugElement } from "@angular/core";
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@@ -16,7 +17,12 @@ import {
|
|||||||
} from "@bitwarden/common/vault/enums";
|
} from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||||
import { DialogRef, BitPasswordInputToggleDirective, DialogService } from "@bitwarden/components";
|
import {
|
||||||
|
DialogRef,
|
||||||
|
BitPasswordInputToggleDirective,
|
||||||
|
DialogService,
|
||||||
|
BitIconButtonComponent,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
|
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
|
||||||
import { CipherFormContainer } from "../../cipher-form-container";
|
import { CipherFormContainer } from "../../cipher-form-container";
|
||||||
@@ -39,6 +45,7 @@ describe("CustomFieldsComponent", () => {
|
|||||||
let announce: jest.Mock;
|
let announce: jest.Mock;
|
||||||
let patchCipher: jest.Mock;
|
let patchCipher: jest.Mock;
|
||||||
let config: CipherFormConfig;
|
let config: CipherFormConfig;
|
||||||
|
const formStatusChange$ = new BehaviorSubject<"disabled" | "enabled">("enabled");
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
open = jest.fn();
|
open = jest.fn();
|
||||||
@@ -65,6 +72,7 @@ describe("CustomFieldsComponent", () => {
|
|||||||
registerChildForm: jest.fn(),
|
registerChildForm: jest.fn(),
|
||||||
config,
|
config,
|
||||||
getInitialCipherView: jest.fn(() => originalCipherView),
|
getInitialCipherView: jest.fn(() => originalCipherView),
|
||||||
|
formStatusChange$,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -552,4 +560,54 @@ describe("CustomFieldsComponent", () => {
|
|||||||
expect(editButtons).toHaveLength(3);
|
expect(editButtons).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parent form disabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
originalCipherView!.fields = mockFieldViews;
|
||||||
|
formStatusChange$.next("disabled");
|
||||||
|
component.ngOnInit();
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
formStatusChange$.next("enabled");
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables edit and reorder buttons", () => {
|
||||||
|
const reorderButtonQuery = By.directive(BitIconButtonComponent);
|
||||||
|
const editButtonQuery = By.directive(BitIconButtonComponent);
|
||||||
|
|
||||||
|
let reorderButton = fixture.debugElement.query(reorderButtonQuery);
|
||||||
|
let editButton = fixture.debugElement.query(editButtonQuery);
|
||||||
|
|
||||||
|
expect(reorderButton.componentInstance.disabled()).toBe(true);
|
||||||
|
expect(editButton.componentInstance.disabled()).toBe(true);
|
||||||
|
|
||||||
|
formStatusChange$.next("enabled");
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
reorderButton = fixture.debugElement.query(reorderButtonQuery);
|
||||||
|
editButton = fixture.debugElement.query(editButtonQuery);
|
||||||
|
|
||||||
|
expect(reorderButton.componentInstance.disabled()).toBe(false);
|
||||||
|
expect(editButton.componentInstance.disabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides add field button", () => {
|
||||||
|
const query = By.css('button[data-testid="add-field-button"]');
|
||||||
|
|
||||||
|
let addFieldButton = fixture.debugElement.query(query);
|
||||||
|
|
||||||
|
expect(addFieldButton).toBeNull();
|
||||||
|
|
||||||
|
formStatusChange$.next("enabled");
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
addFieldButton = fixture.debugElement.query(query);
|
||||||
|
|
||||||
|
expect(addFieldButton).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
|||||||
/** Emits when a new custom field should be focused */
|
/** Emits when a new custom field should be focused */
|
||||||
private focusOnNewInput$ = new Subject<void>();
|
private focusOnNewInput$ = new Subject<void>();
|
||||||
|
|
||||||
|
/** Tracks the disabled status of the edit cipher form */
|
||||||
|
protected parentFormDisabled: boolean = false;
|
||||||
|
|
||||||
disallowHiddenField?: boolean;
|
disallowHiddenField?: boolean;
|
||||||
|
|
||||||
destroyed$: DestroyRef;
|
destroyed$: DestroyRef;
|
||||||
@@ -133,6 +136,10 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
|||||||
// getRawValue ensures disabled fields are included
|
// getRawValue ensures disabled fields are included
|
||||||
this.updateCipher(this.fields.getRawValue());
|
this.updateCipher(this.fields.getRawValue());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.cipherFormContainer.formStatusChange$.pipe(takeUntilDestroyed()).subscribe((status) => {
|
||||||
|
this.parentFormDisabled = status === "disabled";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fields form array, referenced via a getter to avoid type-casting in multiple places */
|
/** Fields form array, referenced via a getter to avoid type-casting in multiple places */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
[attr.aria-checked]="itemDetailsForm.value.favorite"
|
[attr.aria-checked]="itemDetailsForm.value.favorite"
|
||||||
[label]="'favorite' | i18n"
|
[label]="'favorite' | i18n"
|
||||||
(click)="toggleFavorite()"
|
(click)="toggleFavorite()"
|
||||||
|
[disabled]="favoriteButtonDisabled"
|
||||||
></button>
|
></button>
|
||||||
</bit-section-header>
|
</bit-section-header>
|
||||||
<bit-card>
|
<bit-card>
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
|
|
||||||
protected userId: UserId;
|
protected userId: UserId;
|
||||||
|
|
||||||
|
protected favoriteButtonDisabled = false;
|
||||||
|
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
config: CipherFormConfig;
|
config: CipherFormConfig;
|
||||||
|
|
||||||
@@ -241,15 +243,19 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
/**
|
/**
|
||||||
* When the cipher does not belong to an organization but the user's organization
|
* When the cipher does not belong to an organization but the user's organization
|
||||||
* requires all ciphers to be owned by an organization, disable the entire form
|
* requires all ciphers to be owned by an organization, disable the entire form
|
||||||
* until the user selects an organization.
|
* until the user selects an organization. Once the organization is set, enable the form.
|
||||||
|
* Ensure to properly set the collections control state when the form is enabled.
|
||||||
*/
|
*/
|
||||||
private setFormState() {
|
private setFormState() {
|
||||||
if (this.config.originalCipher && !this.allowPersonalOwnership) {
|
if (this.config.originalCipher && !this.allowPersonalOwnership) {
|
||||||
if (this.itemDetailsForm.controls.organizationId.value === null) {
|
if (this.itemDetailsForm.controls.organizationId.value === null) {
|
||||||
this.cipherFormContainer.disableFormFields();
|
this.cipherFormContainer.disableFormFields();
|
||||||
this.itemDetailsForm.controls.organizationId.enable();
|
this.itemDetailsForm.controls.organizationId.enable();
|
||||||
|
this.favoriteButtonDisabled = true;
|
||||||
} else {
|
} else {
|
||||||
this.cipherFormContainer.enableFormFields();
|
this.cipherFormContainer.enableFormFields();
|
||||||
|
this.favoriteButtonDisabled = false;
|
||||||
|
this.setCollectionControlState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +311,6 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
|
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
|
||||||
const organization = this.organizations.find((o) => o.id === orgId);
|
|
||||||
const initializedWithCachedCipher = this.cipherFormContainer.initializedWithCachedCipher();
|
const initializedWithCachedCipher = this.cipherFormContainer.initializedWithCachedCipher();
|
||||||
|
|
||||||
// Configure form for clone mode.
|
// Configure form for clone mode.
|
||||||
@@ -327,9 +332,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
|
|
||||||
await this.updateCollectionOptions(prefillCollections);
|
await this.updateCollectionOptions(prefillCollections);
|
||||||
|
|
||||||
if (!organization?.canEditAllCiphers && !prefillCipher.canAssignToCollections) {
|
this.setCollectionControlState();
|
||||||
this.itemDetailsForm.controls.collectionIds.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.partialEdit) {
|
if (this.partialEdit) {
|
||||||
this.itemDetailsForm.disable();
|
this.itemDetailsForm.disable();
|
||||||
@@ -344,21 +347,33 @@ export class ItemDetailsSectionComponent implements OnInit {
|
|||||||
c.readOnly &&
|
c.readOnly &&
|
||||||
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
|
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When Owners/Admins access setting is turned on.
|
private setCollectionControlState() {
|
||||||
// Disable Collections Options if Owner/Admin does not have Edit/Manage permissions on item
|
const initialCipherView = this.cipherFormContainer.getInitialCipherView();
|
||||||
// Disable Collections Options if Custom user does not have Edit/Manage permissions on item
|
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
|
||||||
|
const organization = this.organizations.find((o) => o.id === orgId);
|
||||||
|
if (!organization || !initialCipherView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Disable the collection control if either of the following apply:
|
||||||
|
// 1. The organization does not allow editing all ciphers and the existing cipher cannot be assigned to
|
||||||
|
// collections
|
||||||
|
// 2. When Owners/Admins access setting is turned on.
|
||||||
|
// AND either:
|
||||||
|
// a. Disable Collections Options if Owner/Admin does not have Edit/Manage permissions on item
|
||||||
|
// b. Disable Collections Options if Custom user does not have Edit/Manage permissions on item
|
||||||
if (
|
if (
|
||||||
(organization?.allowAdminAccessToAllCollectionItems &&
|
(!organization.canEditAllCiphers && !initialCipherView.canAssignToCollections) ||
|
||||||
(!this.originalCipherView.viewPassword || !this.originalCipherView.edit)) ||
|
(organization.allowAdminAccessToAllCollectionItems &&
|
||||||
(organization?.type === OrganizationUserType.Custom &&
|
(!initialCipherView.viewPassword || !initialCipherView.edit)) ||
|
||||||
!this.originalCipherView.viewPassword)
|
(organization.type === OrganizationUserType.Custom && !initialCipherView.viewPassword)
|
||||||
) {
|
) {
|
||||||
this.itemDetailsForm.controls.collectionIds.disable();
|
this.itemDetailsForm.controls.collectionIds.disable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the collection options based on the selected organization.
|
* Updates the collection options based on the selected organization.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Component } from "@angular/core";
|
|||||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
@@ -47,6 +48,7 @@ describe("LoginDetailsSectionComponent", () => {
|
|||||||
getInitialCipherView.mockClear();
|
getInitialCipherView.mockClear();
|
||||||
cipherFormContainer = mock<CipherFormContainer>({
|
cipherFormContainer = mock<CipherFormContainer>({
|
||||||
getInitialCipherView,
|
getInitialCipherView,
|
||||||
|
formStatusChange$: new BehaviorSubject<"enabled" | "disabled">("enabled"),
|
||||||
website: "example.com",
|
website: "example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { DatePipe, NgIf } from "@angular/common";
|
import { DatePipe, NgIf } from "@angular/common";
|
||||||
import { Component, inject, OnInit, Optional } from "@angular/core";
|
import { Component, DestroyRef, inject, OnInit, Optional } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||||
import { map } from "rxjs";
|
import { map } from "rxjs";
|
||||||
@@ -81,6 +81,8 @@ export class LoginDetailsSectionComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
private existingFido2Credentials?: Fido2CredentialView[];
|
private existingFido2Credentials?: Fido2CredentialView[];
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
get hasPasskey(): boolean {
|
get hasPasskey(): boolean {
|
||||||
return this.existingFido2Credentials != null && this.existingFido2Credentials.length > 0;
|
return this.existingFido2Credentials != null && this.existingFido2Credentials.length > 0;
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,19 @@ export class LoginDetailsSectionComponent implements OnInit {
|
|||||||
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
||||||
this.loginDetailsForm.disable();
|
this.loginDetailsForm.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the form is enabled, ensure to disable password or TOTP
|
||||||
|
// for hidden password users
|
||||||
|
this.cipherFormContainer.formStatusChange$
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe((status) => {
|
||||||
|
if (status === "enabled") {
|
||||||
|
if (!this.viewHiddenFields) {
|
||||||
|
this.loginDetailsForm.controls.password.disable();
|
||||||
|
this.loginDetailsForm.controls.totp.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private initFromExistingCipher(existingLogin: LoginView) {
|
private initFromExistingCipher(existingLogin: LoginView) {
|
||||||
|
|||||||
@@ -98,9 +98,13 @@ export class SshKeySectionComponent implements OnInit {
|
|||||||
|
|
||||||
// Disable the form if the cipher form container is enabled
|
// Disable the form if the cipher form container is enabled
|
||||||
// to prevent user interaction
|
// to prevent user interaction
|
||||||
this.cipherFormContainer.formEnabled$
|
this.cipherFormContainer.formStatusChange$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(() => this.sshKeyForm.disable());
|
.subscribe((status) => {
|
||||||
|
if (status === "enabled") {
|
||||||
|
this.sshKeyForm.disable();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set form initial form values from the current cipher */
|
/** Set form initial form values from the current cipher */
|
||||||
|
|||||||
Reference in New Issue
Block a user