From 6df5a148f60058baf0fedf3255463926683a1f00 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 11:30:16 +0200 Subject: [PATCH] fix(import): mask the password again when an add form is cleared Clear erased the password but left the visibility toggle on, so the next password typed in the Xtream or Stalker form showed in plain text. Co-Authored-By: Claude Opus 5.5 --- .../stalker-portal-import.component.spec.ts | 18 ++++++++++++++++++ .../stalker-portal-import.component.ts | 5 +++++ .../xtream-code-import.component.spec.ts | 18 ++++++++++++++++++ .../xtream-code-import.component.ts | 6 ++++++ ...assword-visibility-toggle.directive.spec.ts | 18 ++++++++++++++++++ .../password-visibility-toggle.directive.ts | 5 +++++ 6 files changed, 70 insertions(+) diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index 3bbd447f4..8b4e0b34f 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -1140,6 +1140,24 @@ describe('StalkerPortalImportComponent form', () => { expect(toggle.getAttribute('aria-pressed')).toBe('true'); }); + it('masks the password again when the form is cleared', () => { + const { fixture, root, component } = render(); + const password = root.querySelector('#password') as HTMLInputElement; + const toggle = password + .closest('mat-form-field') + ?.querySelector('button') as HTMLButtonElement; + + toggle.click(); + fixture.detectChanges(); + expect(password.type).toBe('text'); + + component.clearForm(); + fixture.detectChanges(); + + expect(password.type).toBe('password'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + }); + it('shows a refusal inline under the portal URL and scrolls it into view', async () => { const scrollIntoView = jest.fn(); Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 116d3813e..e373891b4 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -149,6 +149,9 @@ export class StalkerPortalImportComponent { readonly feedback = signal(null); private readonly feedbackMessage = viewChild>('feedbackMessage'); + private readonly passwordToggle = viewChild( + PasswordVisibilityToggleDirective + ); /** Whether the device IDs are being generated from the MAC. */ readonly derivesDeviceIds = signal(false); @@ -347,6 +350,8 @@ export class StalkerPortalImportComponent { this.invalidatePendingDerivation(); this.derivesDeviceIds.set(false); this.feedback.set(null); + // A cleared form is a fresh entry: the next password starts masked. + this.passwordToggle()?.hide(); this.form.controls.deviceId1.enable(); this.form.controls.deviceId2.enable(); this.form.reset({ diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts index 1bc8d257b..72f4e8099 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts @@ -260,6 +260,24 @@ describe('XtreamCodeImportComponent form', () => { expect(toggle.getAttribute('aria-pressed')).toBe('false'); }); + it('masks the password again when the form is cleared', () => { + const { fixture, root, component } = render(); + const password = root.querySelector('#password') as HTMLInputElement; + const toggle = password + .closest('mat-form-field') + ?.querySelector('button') as HTMLButtonElement; + + toggle.click(); + fixture.detectChanges(); + expect(password.type).toBe('text'); + + component.clearForm(); + fixture.detectChanges(); + + expect(password.type).toBe('password'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + }); + it('shows a neutral server hint until the URL is invalid', () => { const { fixture, root, component } = render(); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 3df2ecf52..83870b92d 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -3,6 +3,7 @@ import { EventEmitter, Output, inject, + viewChild, ChangeDetectionStrategy, } from '@angular/core'; import { @@ -93,6 +94,9 @@ export class XtreamCodeImportComponent { readonly store = inject(Store); readonly connectionTest = createXtreamConnectionTestState(this.form); + private readonly passwordToggle = viewChild( + PasswordVisibilityToggleDirective + ); get isTestingConnection(): boolean { return this.connectionTest.testing(); @@ -103,6 +107,8 @@ export class XtreamCodeImportComponent { } clearForm(): void { + // A cleared form is a fresh entry: the next password starts masked. + this.passwordToggle()?.hide(); this.form.reset({ _id: createRandomId(), title: '', diff --git a/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.spec.ts b/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.spec.ts index f2770750f..618a77f49 100644 --- a/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.spec.ts +++ b/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.spec.ts @@ -56,6 +56,24 @@ describe('PasswordVisibilityToggleDirective', () => { expect(button.getAttribute('aria-pressed')).toBe('false'); }); + it('masks again on hide()', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + const button = root.querySelector('button') as HTMLButtonElement; + button.click(); + fixture.detectChanges(); + + fixture.debugElement + .query((node) => node.name === 'button') + .injector.get(PasswordVisibilityToggleDirective) + .hide(); + fixture.detectChanges(); + + expect(root.querySelector('input')?.type).toBe('password'); + expect(button.getAttribute('aria-pressed')).toBe('false'); + }); + it('does not submit the surrounding form', () => { const form = document.createElement('form'); const submit = jest.fn((event: Event) => event.preventDefault()); diff --git a/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.ts b/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.ts index 612e0dcdd..d1468d053 100644 --- a/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.ts +++ b/libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.ts @@ -29,4 +29,9 @@ export class PasswordVisibilityToggleDirective { toggle(): void { this.visible.update((visible) => !visible); } + + /** Masks the field again, e.g. when its form is cleared for a new entry. */ + hide(): void { + this.visible.set(false); + } }