diff --git a/.changes/settings-parental-lock-pin-dialog.md b/.changes/settings-parental-lock-pin-dialog.md new file mode 100644 index 000000000..4f8c05d8b --- /dev/null +++ b/.changes/settings-parental-lock-pin-dialog.md @@ -0,0 +1,6 @@ +--- +type: fix +area: settings +--- + +The parental PIN dialog now names what it will do: Save PIN, Confirm, Unlock or Turn off, next to a Cancel button. When the repeated PIN differs, the dialog says so under the field as you type, and pressing Enter shows the error instead of doing nothing. The error shake is skipped when the system asks for reduced motion. diff --git a/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts b/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts new file mode 100644 index 000000000..5980d6292 --- /dev/null +++ b/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts @@ -0,0 +1,267 @@ +import { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + test, +} from './electron-test-fixtures'; + +/** + * The parental-lock PIN dialog (Settings → Parental lock): + * + * 1. Setting a PIN, a repeat that differs is shown on the repeat field as + * soon as it is as long as the PIN, marked `aria-invalid` and placed + * in the field's live region. Enter is refused visibly — the field + * shakes, except under reduced motion — instead of being swallowed by + * a disabled submit button; fixing the repeat saves. + * 2. Every flow names its own submit verb: Save PIN, Confirm (current PIN + * before a change), Unlock, Turn off; the dismiss button is Cancel. + */ + +const PIN = '2468'; +const THEMES = [ + { dark: false, color: 'rgb(179, 38, 30)' }, + { dark: true, color: 'rgb(255, 180, 171)' }, +] as const; + +function pinDialog(page: Page) { + // The newest one: a flow can open the next prompt as one closes. + const dialog = page + .locator('mat-dialog-container', { + has: page.locator('app-parental-lock-pin-dialog'), + }) + .last(); + return { + dialog, + pin: dialog.getByTestId('parental-lock-pin'), + confirm: dialog.getByTestId('parental-lock-pin-confirm'), + submit: dialog.getByTestId('parental-lock-pin-submit'), + cancel: dialog.getByRole('button', { name: 'Cancel', exact: true }), + mismatch: dialog.getByTestId('parental-lock-pin-mismatch'), + }; +} + +async function openParentalSettings(page: Page): Promise { + await openSettings(page); + await openSettingsSection(page, 'parental'); + return page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); +} + +/** + * Presses Enter in `input` and reports the field's animation at the moment + * the shake class lands (a MutationObserver sees it synchronously, well + * inside the 400ms the class stays on). + */ +async function pressEnterAndCatchShake(input: Locator): Promise { + const field = input.locator('xpath=ancestor::mat-form-field'); + await expect(field).not.toHaveClass(/pin-dialog__field--shake/); + await field.evaluate((element) => { + const target = element as HTMLElement & { shakeAnimation?: string }; + delete target.shakeAnimation; + const observer = new MutationObserver(() => { + if (target.classList.contains('pin-dialog__field--shake')) { + target.shakeAnimation = getComputedStyle(target).animationName; + observer.disconnect(); + } + }); + observer.observe(target, { attributeFilter: ['class'] }); + }); + await input.press('Enter'); + await expect + .poll(() => + field.evaluate( + (element) => + (element as HTMLElement & { shakeAnimation?: string }) + .shakeAnimation ?? null + ) + ) + .not.toBeNull(); + return field.evaluate( + (element) => + (element as HTMLElement & { shakeAnimation?: string }) + .shakeAnimation ?? null + ); +} + +test.describe('Electron parental-lock PIN dialog', () => { + test('@settings @parental @electron shows, announces and refuses a mismatched repeat, then saves the fixed PIN', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + const toggle = await openParentalSettings(page); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + + const { dialog, pin, confirm, submit, cancel, mismatch } = + pinDialog(page); + await expect(pin).toBeFocused(); + await expect(submit).toHaveText('Save PIN'); + await expect(cancel).toBeVisible(); + await expect(pin).toHaveAttribute('autocomplete', 'new-password'); + await expect(confirm).toHaveAttribute( + 'autocomplete', + 'new-password' + ); + + // Enter after the PIN moves on to the empty repeat; Save + // with the repeat still empty is refused. + await pin.fill(PIN); + await pin.press('Enter'); + await expect(confirm).toBeFocused(); + await expect(mismatch).toBeHidden(); + await submit.click(); + await expect(mismatch).toBeVisible(); + await expect(dialog).toBeVisible(); + + await confirm.fill(PIN.slice(0, 3)); + await expect(mismatch).toBeHidden(); + await expect(confirm).toHaveAttribute('aria-invalid', 'false'); + + // As long as the PIN and different: shown before any submit. + await confirm.fill('2469'); + await expect(mismatch).toHaveText('The two PINs do not match.'); + await expect(confirm).toHaveAttribute('aria-invalid', 'true'); + const errorId = await mismatch.getAttribute('id'); + expect(await confirm.getAttribute('aria-describedby')).toContain( + errorId + ); + await expect( + mismatch.locator('xpath=ancestor::*[@aria-live="polite"]') + ).toHaveCount(1); + + // The parental-lock red in both themes, on the text and outline. + const outline = confirm + .locator('xpath=ancestor::mat-form-field') + .locator('.mdc-notched-outline__leading'); + for (const theme of THEMES) { + await page.evaluate( + (dark) => + document.body.classList.toggle('dark-theme', dark), + theme.dark + ); + await expect(mismatch).toHaveCSS('color', theme.color); + await expect(outline).toHaveCSS( + 'border-top-color', + theme.color + ); + } + + // Enter is refused with a shake, or without one under reduced + // motion; the dialog stays and nothing is saved. + await page.emulateMedia({ reducedMotion: 'reduce' }); + expect(await pressEnterAndCatchShake(confirm)).toBe('none'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + expect(await pressEnterAndCatchShake(confirm)).toContain( + 'pin-dialog-shake' + ); + // Refused again while it still shakes, the field shakes from + // the start: its class never comes off, so the animation is + // rewound. Held at 300ms, the second refusal must reset it. + const replayedAt = await dialog + .locator('form') + .evaluate(async (form: HTMLFormElement) => { + const nextFrame = () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ); + const field = form + .querySelector( + '[data-test-id="parental-lock-pin-confirm"]' + ) + ?.closest('mat-form-field') as HTMLElement; + form.requestSubmit(); + await nextFrame(); + const [shake] = field.getAnimations(); + shake.pause(); + shake.currentTime = 300; + form.requestSubmit(); + await nextFrame(); + return shake.currentTime; + }); + expect(replayedAt).toBe(0); + await expect(dialog).toBeVisible(); + await expect(mismatch).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + // Refused from the PIN field, focus moves to the repeat. + await pin.press('Enter'); + await expect(confirm).toBeFocused(); + + await confirm.fill(PIN); + await expect(mismatch).toBeHidden(); + await expect(confirm).toHaveAttribute('aria-invalid', 'false'); + await confirm.press('Enter'); + await expect(dialog).toBeHidden(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + } finally { + await closeElectronApp(app); + } + }); + + test('@settings @parental @electron names each PIN flow with its own submit verb', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + const toggle = await openParentalSettings(page); + const { dialog, pin, confirm, submit, cancel } = pinDialog(page); + + await toggle.click(); + await expect(submit).toHaveText('Save PIN'); + await pin.fill(PIN); + await confirm.fill(PIN); + await submit.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + // Change PIN: the current PIN is confirmed, the new one saved. + await page.getByTestId('parental-lock-change-pin').click(); + await expect(submit).toHaveText('Confirm'); + await expect(confirm).toHaveCount(0); + await pin.fill(PIN); + await pin.press('Enter'); + await expect(confirm).toBeVisible(); + await expect(submit).toHaveText('Save PIN'); + await cancel.click(); + await expect(dialog).toBeHidden(); + + await page.getByTestId('parental-lock-lock-now').click(); + await page.getByTestId('parental-lock-unlock').click(); + await expect(submit).toHaveText('Unlock'); + await expect(cancel).toBeVisible(); + // A wrong PIN keeps the field focused for the next try, although + // it is disabled while the PIN is checked. + await pin.fill('1357'); + await pin.press('Enter'); + await expect( + dialog.getByTestId('parental-lock-pin-error') + ).toHaveText('Wrong PIN. Try again.'); + await expect(pin).toHaveAttribute('aria-invalid', 'true'); + await expect(pin).toBeFocused(); + await pin.fill(PIN); + await pin.press('Enter'); + await expect(dialog).toBeHidden(); + await expect( + page.getByTestId('parental-lock-lock-now') + ).toBeVisible(); + + await toggle.click(); + await expect(submit).toHaveText('Turn off'); + await pin.fill(PIN); + await pin.press('Enter'); + await expect(dialog).toBeHidden(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web/src/app/services/parental-lock-prompt.service.spec.ts b/apps/web/src/app/services/parental-lock-prompt.service.spec.ts index c7f857ce8..fe8c14f1a 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.spec.ts @@ -24,10 +24,18 @@ describe('AppParentalLockPromptService', () => { ParentalLockPinDialogComponent: { open }, })) as never; - await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234'); + await expect( + service.requestPin({ + mode: 'set', + submitKey: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + }) + ).resolves.toBe('1234'); expect(open).toHaveBeenCalledWith( dialog, - expect.objectContaining({ mode: 'set' }) + expect.objectContaining({ + mode: 'set', + submitKey: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + }) ); }); diff --git a/apps/web/src/app/services/parental-lock-prompt.service.ts b/apps/web/src/app/services/parental-lock-prompt.service.ts index 4c4bec098..9d40f9c55 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.ts @@ -44,6 +44,7 @@ export class AppParentalLockPromptService implements ParentalLockPrompt { throttle: request.throttle, titleKey: request.titleKey, descriptionKey: request.descriptionKey, + submitKey: request.submitKey, } ); const pin = await firstValueFrom(dialogRef.afterClosed()); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index e4dff9e22..f09483744 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "لا توجد طريقة لاستعادة رمز PIN المنسي — لا يزيله إلا إعادة تعيين بيانات التطبيق.", "SAVE": "حفظ رمز PIN", "UNLOCK": "إلغاء القفل", + "CONFIRM": "تأكيد", + "TURN_OFF": "تعطيل", "CONFIRM_TITLE": "تأكيد رمز PIN للرقابة الأبوية", "CONFIRM_DESCRIPTION": "أدخل رمز PIN الحالي لتغيير هذا الإعداد." }, diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 56f24be99..7e672bb78 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "ماكايناش طريقة باش ترجع رمز PIN لي نسيتي — غير إعادة الضبط ديال بيانات التطبيق هي لي كتحيدو.", "SAVE": "حفظ رمز PIN", "UNLOCK": "حل القفل", + "CONFIRM": "أكّد", + "TURN_OFF": "طفّي", "CONFIRM_TITLE": "أكّد رمز PIN ديال الرقابة الأبوية", "CONFIRM_DESCRIPTION": "دخّل رمز PIN الحالي باش تبدّل هاد الإعداد." }, diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 1a5583ef1..bd06e3b88 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Забыты PIN аднавіць немагчыма — яго выдаляе толькі скід дадзеных праграмы.", "SAVE": "Захаваць PIN", "UNLOCK": "Разблакіраваць", + "CONFIRM": "Пацвердзіць", + "TURN_OFF": "Выключыць", "CONFIRM_TITLE": "Пацвердзіце бацькоўскі PIN", "CONFIRM_DESCRIPTION": "Увядзіце бягучы PIN, каб змяніць гэту наладу." }, diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 9c7170c7e..12ca756f3 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.", "SAVE": "PIN speichern", "UNLOCK": "Entsperren", + "CONFIRM": "Bestätigen", + "TURN_OFF": "Deaktivieren", "CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen", "CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern." }, diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 5a7d89dc5..d4065b7b7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Δεν υπάρχει τρόπος ανάκτησης ενός ξεχασμένου PIN — μόνο η επαναφορά των δεδομένων της εφαρμογής το αφαιρεί.", "SAVE": "Αποθήκευση PIN", "UNLOCK": "Ξεκλείδωμα", + "CONFIRM": "Επιβεβαίωση", + "TURN_OFF": "Απενεργοποίηση", "CONFIRM_TITLE": "Επιβεβαίωση γονικού PIN", "CONFIRM_DESCRIPTION": "Εισαγάγετε το τρέχον PIN για να αλλάξετε αυτή τη ρύθμιση." }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6544f35ee..401da3613 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", "UNLOCK": "Unlock", + "CONFIRM": "Confirm", + "TURN_OFF": "Turn off", "CONFIRM_TITLE": "Confirm parental PIN", "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4c340b517..0e460f3ae 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "No hay forma de recuperar un PIN olvidado: solo restablecer los datos de la aplicación lo elimina.", "SAVE": "Guardar PIN", "UNLOCK": "Desbloquear", + "CONFIRM": "Confirmar", + "TURN_OFF": "Desactivar", "CONFIRM_TITLE": "Confirmar el PIN parental", "CONFIRM_DESCRIPTION": "Introduce el PIN actual para cambiar este ajuste." }, diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 812cb01dd..54eb60961 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Il est impossible de récupérer un code PIN oublié — seule la réinitialisation des données de l'application le supprime.", "SAVE": "Enregistrer le code PIN", "UNLOCK": "Déverrouiller", + "CONFIRM": "Confirmer", + "TURN_OFF": "Désactiver", "CONFIRM_TITLE": "Confirmer le code PIN parental", "CONFIRM_DESCRIPTION": "Saisissez le code PIN actuel pour modifier ce réglage." }, diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 6027cebbf..aee8487dd 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Az elfelejtett PIN-kód nem állítható vissza — csak az alkalmazásadatok alaphelyzetbe állítása távolítja el.", "SAVE": "PIN-kód mentése", "UNLOCK": "Feloldás", + "CONFIRM": "Megerősítés", + "TURN_OFF": "Kikapcsolás", "CONFIRM_TITLE": "Szülői PIN-kód megerősítése", "CONFIRM_DESCRIPTION": "A beállítás módosításához adja meg a jelenlegi PIN-kódot." }, diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a978c7560..886d12389 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Non è possibile recuperare un PIN dimenticato: si rimuove solo reimpostando i dati dell'app.", "SAVE": "Salva PIN", "UNLOCK": "Sblocca", + "CONFIRM": "Conferma", + "TURN_OFF": "Disattiva", "CONFIRM_TITLE": "Conferma il PIN parentale", "CONFIRM_DESCRIPTION": "Inserisci il PIN attuale per modificare questa impostazione." }, diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e0ea48c88..fc86eae05 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "忘れた PIN を復元する方法はありません。PIN を削除するには、アプリのデータをリセットするしかありません。", "SAVE": "PIN を保存", "UNLOCK": "ロック解除", + "CONFIRM": "確認", + "TURN_OFF": "無効化", "CONFIRM_TITLE": "ペアレンタル PIN を確認", "CONFIRM_DESCRIPTION": "この設定を変更するには、現在の PIN を入力してください。" }, diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 5c2adf2b7..9b4f72484 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "잊어버린 PIN은 복구할 수 없습니다. 앱 데이터를 초기화해야만 PIN이 삭제됩니다.", "SAVE": "PIN 저장", "UNLOCK": "잠금 해제", + "CONFIRM": "확인", + "TURN_OFF": "끄기", "CONFIRM_TITLE": "보호자 PIN 확인", "CONFIRM_DESCRIPTION": "이 설정을 변경하려면 현재 PIN을 입력하세요." }, diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index aebe8cc35..81146e2d8 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Een vergeten pincode kan niet worden hersteld — alleen het resetten van de app-gegevens verwijdert hem.", "SAVE": "Pincode opslaan", "UNLOCK": "Ontgrendelen", + "CONFIRM": "Bevestigen", + "TURN_OFF": "Uitschakelen", "CONFIRM_TITLE": "Ouderpincode bevestigen", "CONFIRM_DESCRIPTION": "Voer de huidige pincode in om deze instelling te wijzigen." }, diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index dfa56de3d..27e2db41c 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Zapomnianego kodu PIN nie da się odzyskać — usuwa go tylko zresetowanie danych aplikacji.", "SAVE": "Zapisz kod PIN", "UNLOCK": "Odblokuj", + "CONFIRM": "Potwierdź", + "TURN_OFF": "Wyłącz", "CONFIRM_TITLE": "Potwierdź rodzicielski kod PIN", "CONFIRM_DESCRIPTION": "Wpisz obecny kod PIN, aby zmienić to ustawienie." }, diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 9c3fac933..afb27e027 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Não é possível recuperar um PIN esquecido — somente redefinir os dados do app o remove.", "SAVE": "Salvar PIN", "UNLOCK": "Desbloquear", + "CONFIRM": "Confirmar", + "TURN_OFF": "Desativar", "CONFIRM_TITLE": "Confirmar PIN parental", "CONFIRM_DESCRIPTION": "Digite o PIN atual para alterar esta configuração." }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1e6d37dc4..c8e73edd5 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.", "SAVE": "Сохранить PIN", "UNLOCK": "Разблокировать", + "CONFIRM": "Подтвердить", + "TURN_OFF": "Выключить", "CONFIRM_TITLE": "Подтвердите родительский PIN", "CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку." }, diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 863c33469..a4c3c71b9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Unutulan bir PIN'i kurtarmanın yolu yoktur — yalnızca uygulama verilerini sıfırlamak onu kaldırır.", "SAVE": "PIN'i kaydet", "UNLOCK": "Kilidi aç", + "CONFIRM": "Onayla", + "TURN_OFF": "Kapat", "CONFIRM_TITLE": "Ebeveyn PIN'ini onaylayın", "CONFIRM_DESCRIPTION": "Bu ayarı değiştirmek için mevcut PIN'i girin." }, diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index f860f0818..5b09ac96c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "忘记的 PIN 码无法找回——只有重置应用数据才能将其移除。", "SAVE": "保存 PIN 码", "UNLOCK": "解锁", + "CONFIRM": "确认", + "TURN_OFF": "关闭", "CONFIRM_TITLE": "确认家长 PIN 码", "CONFIRM_DESCRIPTION": "请输入当前 PIN 码以更改此设置。" }, diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ef606aab3..8e9cfad4f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "忘記的 PIN 碼無法復原——只有重設應用程式資料才能將其移除。", "SAVE": "儲存 PIN 碼", "UNLOCK": "解鎖", + "CONFIRM": "確認", + "TURN_OFF": "關閉", "CONFIRM_TITLE": "確認家長 PIN 碼", "CONFIRM_DESCRIPTION": "請輸入目前的 PIN 碼以變更此設定。" }, diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d9b871fb0..8ffda3a1d 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -416,8 +416,27 @@ so a cancelled or refused PIN leaves it showing the real state. so a child cannot switch the idle relock off for the next unlock. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. +- The PIN dialog's submit button names the flow: the service passes a + `submitKey` with every prompt — Unlock (`requestUnlock`), Save PIN (a new + PIN in `setupPin` and `changePin`), Confirm (the current PIN before a + change), Turn off (`disable`). The dismiss button is the shared Cancel. + Errors sit on their field as `mat-error` (an `errorStateMatcher` drives + it, so the input gets `aria-invalid` and the message lands in the field's + live region): a wrong PIN, the cooldown or a too-short PIN on the PIN + field; a mismatch on the repeat field, shown while typing once the repeat + is as long as the PIN. Submit is disabled only while busy or cooling + down, never for incomplete input, because Enter does nothing on a + disabled default button; `submit()` refuses instead, shakes the field + (no shake under `prefers-reduced-motion: reduce`) and focuses it — except + that Enter in the PIN field with the repeat still empty only moves focus + to the repeat (a `keydown.enter` handler: focus cannot tell Enter from a + click on Save, since WebKit does not focus a clicked button). Set + mode marks both inputs `autocomplete="new-password"`. The Electron + `parental-lock-pin-dialog.e2e.ts` covers the labels and the + mismatch → fix → save flow. - Styling uses app tokens only (the theme declares no `--mat-sys-*`); the - PIN error and the Stalker adult chip use a local red per theme. Keyboard + PIN errors, the fields' error outline and the Stalker adult chip use a + local red per theme. Keyboard focus on a lock row is a 2px inset `--app-selection-color` ring, and the lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts` diff --git a/libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts b/libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts new file mode 100644 index 000000000..e5cbd88e5 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts @@ -0,0 +1,72 @@ +import { + ParentalLockPinThrottle, + verifyParentalLockPin, +} from '@iptvnator/shared/interfaces'; +import { ParentalLockPromptRequest } from './parental-lock-prompt.token'; + +type PromptLabels = Pick< + ParentalLockPromptRequest, + 'titleKey' | 'descriptionKey' | 'submitKey' +>; + +/** + * The submit verb of each PIN flow, so the button says what it will do + * instead of a generic "Unlock". + */ +export const PARENTAL_LOCK_SUBMIT_KEYS = { + unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK', + save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM', + turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF', +} as const; + +/** Setting up or replacing the PIN: typed twice, saved on submit. */ +export const NEW_PIN_REQUEST: ParentalLockPromptRequest = { + mode: 'set', + submitKey: PARENTAL_LOCK_SUBMIT_KEYS.save, +}; + +/** Unlocks the session with the current PIN. */ +export function unlockPinRequest( + hash: string, + throttle: ParentalLockPinThrottle, + labels: Pick = {} +): ParentalLockPromptRequest { + return currentPinRequest(hash, throttle, { + submitKey: PARENTAL_LOCK_SUBMIT_KEYS.unlock, + ...labels, + }); +} + +/** + * Confirms the current PIN before a protected settings change; `submitKey` + * names that change (Confirm before a new PIN, Turn off). + */ +export function confirmPinRequest( + hash: string, + throttle: ParentalLockPinThrottle, + submitKey: string +): ParentalLockPromptRequest { + return currentPinRequest(hash, throttle, { + titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE', + descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION', + submitKey, + }); +} + +/** + * Asks for the current PIN, checked against `hash`. Every such prompt shares + * the service's `throttle`, so the cooldown survives a dismissed dialog. + */ +function currentPinRequest( + hash: string, + throttle: ParentalLockPinThrottle, + labels: PromptLabels +): ParentalLockPromptRequest { + return { + mode: 'unlock', + verify: (candidate) => verifyParentalLockPin(candidate, hash), + throttle, + ...labels, + }; +} diff --git a/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts b/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts index 2a3bdf3e8..451beb558 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts @@ -20,6 +20,11 @@ export interface ParentalLockPromptRequest { titleKey?: string; /** Optional translation key overriding the mode's default description. */ descriptionKey?: string; + /** + * Translation key of the flow's verb on the submit button ("Unlock", + * "Save PIN", "Turn off", …); the mode's default when absent. + */ + submitKey?: string; } /** diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 83f5bbc9f..8ccbddc61 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -559,6 +559,46 @@ describe('ParentalLockService', () => { expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock'); }); + it('labels each prompt with the verb of its flow', async () => { + prompt.requestPin.mockImplementation( + async (request: ParentalLockPromptRequest) => + request.mode === 'set' + ? '1234' + : (await request.verify?.('1234')) + ? '1234' + : null + ); + const service = await createService(); + const submitKeys = () => + prompt.requestPin.mock.calls.map( + ([request]: [ParentalLockPromptRequest]) => + `${request.mode}:${request.submitKey}` + ); + + await expect(service.setupPin()).resolves.toBe(true); + expect(submitKeys()).toEqual(['set:PARENTAL_LOCK.PIN_DIALOG.SAVE']); + + prompt.requestPin.mockClear(); + await expect(service.changePin()).resolves.toBe(true); + expect(submitKeys()).toEqual([ + 'unlock:PARENTAL_LOCK.PIN_DIALOG.CONFIRM', + 'set:PARENTAL_LOCK.PIN_DIALOG.SAVE', + ]); + + prompt.requestPin.mockClear(); + service.lock(); + await expect(service.requestUnlock()).resolves.toBe(true); + expect(submitKeys()).toEqual([ + 'unlock:PARENTAL_LOCK.PIN_DIALOG.UNLOCK', + ]); + + prompt.requestPin.mockClear(); + await expect(service.disable()).resolves.toBe(true); + expect(submitKeys()).toEqual([ + 'unlock:PARENTAL_LOCK.PIN_DIALOG.TURN_OFF', + ]); + }); + it('persists locks per portal, stamps the Xtream column and bumps the version', async () => { const service = await createService(); const versionBefore = service.version(); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index c291b6d8b..d98875df3 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -13,7 +13,6 @@ import { ParentalLockPlaylistLocks, ParentalLockStalkerCategoryType, ParentalLockXtreamCategoryType, - verifyParentalLockPin, } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; import { SettingsStore } from '../settings-store.service'; @@ -35,6 +34,12 @@ import { persistParentalLockEnabled, persistParentalLockRelockMinutes, } from './parental-lock-settings-writer'; +import { + confirmPinRequest, + NEW_PIN_REQUEST, + PARENTAL_LOCK_SUBMIT_KEYS, + unlockPinRequest, +} from './parental-lock-prompt-requests'; import { ParentalLockStorageService } from './parental-lock-storage'; /** @@ -242,12 +247,9 @@ export class ParentalLockService { if (!this.prompt || !hash) { return false; } - const pin = await this.prompt.requestPin({ - mode: 'unlock', - verify: (candidate) => verifyParentalLockPin(candidate, hash), - throttle: this.pinThrottle, - ...options, - }); + const pin = await this.prompt.requestPin( + unlockPinRequest(hash, this.pinThrottle, options) + ); if (pin === null) { return false; } @@ -276,7 +278,7 @@ export class ParentalLockService { // depends on. const needsPersist = this.settingsStore.parentalLockEnabled?.() !== true; - const pin = await this.prompt.requestPin({ mode: 'set' }); + const pin = await this.prompt.requestPin(NEW_PIN_REQUEST); if (pin === null) { return false; } @@ -306,10 +308,10 @@ export class ParentalLockService { if (!this.prompt || !this.hasPin()) { return false; } - if (!(await this.verifyCurrentPin())) { + if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.confirm))) { return false; } - const pin = await this.prompt.requestPin({ mode: 'set' }); + const pin = await this.prompt.requestPin(NEW_PIN_REQUEST); if (pin === null) { return false; } @@ -321,7 +323,7 @@ export class ParentalLockService { if (!this.enabled()) { return true; } - if (!(await this.verifyCurrentPin())) { + if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.turnOff))) { return false; } if (!(await this.persistEnabled(false))) { @@ -335,22 +337,18 @@ export class ParentalLockService { * Always asks for the PIN, unlocked session or not: changing the PIN or * switching the feature off must not be possible just because a parent * left the app unlocked. Unlike `requestUnlock()` this never short-cuts - * on `active`. + * on `active`. `submitKey` names the step the PIN confirms. */ - private async verifyCurrentPin(): Promise { + private async verifyCurrentPin(submitKey: string): Promise { await this.initialize(); await this.ensurePin(); const hash = this.pinHash(); if (!this.prompt || !hash) { return false; } - const pin = await this.prompt.requestPin({ - mode: 'unlock', - verify: (candidate) => verifyParentalLockPin(candidate, hash), - throttle: this.pinThrottle, - titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE', - descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION', - }); + const pin = await this.prompt.requestPin( + confirmPinRequest(hash, this.pinThrottle, submitKey) + ); return pin !== null; } diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html index 918addce9..0a7a63987 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html @@ -11,7 +11,7 @@ appearance="outline" subscriptSizing="dynamic" class="pin-dialog__field" - [class.pin-dialog__field--shake]="shake()" + [class.pin-dialog__field--shake]="shake() === 'pin'" > {{ 'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate }} @@ -33,6 +35,11 @@ | translate: { min: minLength, max: maxLength } }} + @if (pinErrorKey(); as errorKey) { + + {{ errorKey | translate: { min: minLength, max: maxLength } }} + + } @if (isSetMode()) { @@ -40,30 +47,30 @@ appearance="outline" subscriptSizing="dynamic" class="pin-dialog__field" + [class.pin-dialog__field--shake]="shake() === 'confirmation'" > {{ 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate }} + @if (mismatch()) { + + {{ 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH' | translate }} + + } - } - @if (error(); as errorKey) { - - } - - @if (isSetMode()) {

{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}

@@ -72,21 +79,16 @@ diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss index 9dcec7338..94a9e8232 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss @@ -1,7 +1,28 @@ // The theme declares no Material system colours, so the error colour is a // local token with a value per theme (>= 6:1 on the dialog surface in both). +// The fields' error state (message, outline, label, caret) takes it too. :host { --pin-dialog-error-color: #b3261e; + --mat-form-field-error-text-color: var(--pin-dialog-error-color); + --mat-form-field-outlined-error-outline-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-hover-outline-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-focus-outline-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-label-text-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-hover-label-text-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-focus-label-text-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-caret-color: var(--pin-dialog-error-color); } :host-context(.dark-theme) { @@ -37,12 +58,10 @@ .pin-dialog__field--shake { animation: pin-dialog-shake 0.4s ease; -} -.pin-dialog__error { - margin: 0; - color: var(--pin-dialog-error-color); - font-size: 0.8125rem; + @media (prefers-reduced-motion: reduce) { + animation: none; + } } .pin-dialog__note { diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts index 437a2c271..342acb8fb 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; @@ -6,48 +6,361 @@ import { createParentalLockPinThrottle, PARENTAL_LOCK_PIN_MAX_ATTEMPTS, } from '@iptvnator/shared/interfaces'; -import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component'; +import { + ParentalLockPinDialogComponent, + ParentalLockPinDialogData, +} from './parental-lock-pin-dialog.component'; -describe('ParentalLockPinDialogComponent cooldown', () => { - const throttle = createParentalLockPinThrottle(); - const verify = jest.fn(async () => false); +// Without a loader the translate pipe renders the key itself. +const KEYS = { + save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK', + confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM', + turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF', + mismatch: 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH', + hint: 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT', + wrongPin: 'PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN', +}; - function openDialog(): ParentalLockPinDialogComponent { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [ - ParentalLockPinDialogComponent, - NoopAnimationsModule, - TranslateModule.forRoot(), - ], - providers: [ - { - provide: MAT_DIALOG_DATA, - useValue: { mode: 'unlock', verify, throttle }, - }, - { provide: MatDialogRef, useValue: { close: jest.fn() } }, - ], +interface DialogHarness { + fixture: ComponentFixture; + component: ParentalLockPinDialogComponent; + close: jest.Mock; + query(testId: string): T | null; + type(testId: string, value: string): Promise; + /** Submits the form the way Enter in a field does. */ + pressEnter(): Promise; +} + +async function openDialog( + data: ParentalLockPinDialogData +): Promise { + TestBed.resetTestingModule(); + const close = jest.fn(); + TestBed.configureTestingModule({ + imports: [ + ParentalLockPinDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: { close } }, + ], + }); + const fixture = TestBed.createComponent(ParentalLockPinDialogComponent); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + const query = (testId: string) => + root.querySelector(`[data-test-id="${testId}"]`); + return { + fixture, + component: fixture.componentInstance, + close, + query, + async type(testId, value) { + const input = query(testId); + if (!input) throw new Error(`No input ${testId}`); + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + }, + async pressEnter() { + root.querySelector('form')?.dispatchEvent( + new Event('submit', { cancelable: true }) + ); + await fixture.whenStable(); + }, + }; +} + +function text(element: Element | null): string { + return element?.textContent?.trim() ?? ''; +} + +describe('ParentalLockPinDialogComponent', () => { + describe('submit labels', () => { + it.each([ + ['unlock', undefined, KEYS.unlock], + ['set', undefined, KEYS.save], + ['unlock', KEYS.confirm, KEYS.confirm], + ['unlock', KEYS.turnOff, KEYS.turnOff], + ['set', KEYS.save, KEYS.save], + ] as const)( + '%s mode with submitKey %s reads %s', + async (mode, submitKey, expected) => { + const dialog = await openDialog({ mode, submitKey }); + + expect(text(dialog.query('parental-lock-pin-submit'))).toBe( + expected + ); + const dismiss = dialog.fixture.nativeElement.querySelector( + 'mat-dialog-actions button[type="button"]' + ); + expect(text(dismiss)).toBe('CANCEL'); + } + ); + }); + + describe('set mode', () => { + it('asks password managers for a new password in both fields', async () => { + const dialog = await openDialog({ mode: 'set' }); + + expect( + dialog.query('parental-lock-pin')?.getAttribute('autocomplete') + ).toBe('new-password'); + expect( + dialog + .query('parental-lock-pin-confirm') + ?.getAttribute('autocomplete') + ).toBe('new-password'); }); - const fixture = TestBed.createComponent(ParentalLockPinDialogComponent); - fixture.detectChanges(); - return fixture.componentInstance; - } - it('keeps the cooldown when the prompt is dismissed and opened again', async () => { - const first = openDialog(); - for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { - first.onPinInput('0000'); - await first.submit(); - } - expect(first.inCooldown()).toBe(true); - expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS); + it('shows the mismatch once the repeat is as long as the PIN, then saves the fixed PIN', async () => { + const dialog = await openDialog({ mode: 'set' }); + const confirm = () => + dialog.query('parental-lock-pin-confirm'); - const reopened = openDialog(); - expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); - reopened.onPinInput('0000'); - await reopened.submit(); + await dialog.type('parental-lock-pin', '2468'); + await dialog.type('parental-lock-pin-confirm', '246'); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + expect(confirm()?.getAttribute('aria-invalid')).toBe('false'); - expect(reopened.inCooldown()).toBe(true); - expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS); + await dialog.type('parental-lock-pin-confirm', '2469'); + const error = dialog.query('parental-lock-pin-mismatch'); + expect(text(error)).toBe(KEYS.mismatch); + expect(confirm()?.getAttribute('aria-invalid')).toBe('true'); + // Announced: the error is the input's description. + expect(confirm()?.getAttribute('aria-describedby')).toContain( + error?.id + ); + + // Enter is refused visibly rather than swallowed. + expect( + dialog.query('parental-lock-pin-submit') + ?.disabled + ).toBe(false); + await dialog.pressEnter(); + expect(dialog.close).not.toHaveBeenCalled(); + expect(dialog.component.shake()).toBe('confirmation'); + expect(document.activeElement).toBe(confirm()); + + await dialog.type('parental-lock-pin-confirm', '2468'); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + expect(confirm()?.getAttribute('aria-invalid')).toBe('false'); + await dialog.pressEnter(); + expect(dialog.close).toHaveBeenCalledWith('2468'); + }); + + it('shows the mismatch for a short repeat when Enter is pressed', async () => { + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + await dialog.type('parental-lock-pin-confirm', '24'); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.close).not.toHaveBeenCalled(); + + // Editing the repeat hands the error back to the length rule. + await dialog.type('parental-lock-pin-confirm', '246'); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + }); + + it('moves on to an empty repeat on Enter in the PIN field', async () => { + const dialog = await openDialog({ mode: 'set' }); + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + cancelable: true, + }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin')?.dispatchEvent(enter); + await dialog.fixture.whenStable(); + + // Handled before the form sees it: no implicit submission. + expect(enter.defaultPrevented).toBe(true); + expect(document.activeElement).toBe( + dialog.query('parental-lock-pin-confirm') + ); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + expect(dialog.component.shake()).toBeNull(); + }); + + it('refuses an empty repeat on Save while the PIN field keeps focus', async () => { + // WebKit does not focus a clicked button, so focus says nothing + // about how the form was submitted. + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin')?.focus(); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.component.shake()).toBe('confirmation'); + expect(dialog.close).not.toHaveBeenCalled(); + }); + + it('refuses an empty repeat when Enter is pressed in it', async () => { + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin-confirm')?.focus(); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.component.shake()).toBe('confirmation'); + expect(dialog.close).not.toHaveBeenCalled(); + }); + + it('lets a second refusal shake for its full time', async () => { + const { component } = await openDialog({ mode: 'set' }); + jest.useFakeTimers(); + try { + component.onPinInput('24'); + await component.submit(); + expect(component.shake()).toBe('pin'); + + jest.advanceTimersByTime(300); + component.onPinInput('2468'); + component.onConfirmationInput('2469'); + await component.submit(); + expect(component.shake()).toBe('confirmation'); + + // The first refusal's timer must not end the second shake. + jest.advanceTimersByTime(150); + expect(component.shake()).toBe('confirmation'); + jest.advanceTimersByTime(250); + expect(component.shake()).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + + it('replays the shake when the same field is refused again', async () => { + const dialog = await openDialog({ mode: 'set' }); + const field = dialog + .query('parental-lock-pin') + ?.closest('mat-form-field'); + // jsdom has no Web Animations: stand in for the running shake. + const shake: Partial = { + animationName: '_ngcontent-x_pin-dialog-shake', + currentTime: 250, + }; + Object.defineProperty(field, 'getAnimations', { + value: () => [shake], + }); + + await dialog.type('parental-lock-pin', '24'); + await dialog.pressEnter(); + expect(shake.currentTime).toBe(250); + + await dialog.pressEnter(); + expect(dialog.component.shake()).toBe('pin'); + expect(shake.currentTime).toBe(0); + }); + + it('refuses a too-short PIN on Enter with an error on the PIN field', async () => { + const dialog = await openDialog({ mode: 'set' }); + const pin = () => + dialog.query('parental-lock-pin'); + + await dialog.type('parental-lock-pin', '24'); + await dialog.type('parental-lock-pin-confirm', '24'); + await dialog.pressEnter(); + + expect(dialog.close).not.toHaveBeenCalled(); + expect(text(dialog.query('parental-lock-pin-error'))).toBe( + KEYS.hint + ); + expect(pin()?.getAttribute('aria-invalid')).toBe('true'); + expect(document.activeElement).toBe(pin()); + + await dialog.type('parental-lock-pin', '246'); + expect(dialog.query('parental-lock-pin-error')).toBeNull(); + }); + }); + + describe('unlock mode', () => { + it('has no repeat field and leaves autocomplete off', async () => { + const dialog = await openDialog({ mode: 'unlock' }); + + expect(dialog.query('parental-lock-pin-confirm')).toBeNull(); + expect( + dialog.query('parental-lock-pin')?.getAttribute('autocomplete') + ).toBe('off'); + }); + + it('marks the PIN field invalid after a wrong PIN and unlocks with the right one', async () => { + const verify = jest.fn(async (pin: string) => pin === '2468'); + const dialog = await openDialog({ mode: 'unlock', verify }); + const pin = () => + dialog.query('parental-lock-pin'); + + await dialog.type('parental-lock-pin', '1357'); + await dialog.pressEnter(); + await dialog.fixture.whenStable(); + + expect(text(dialog.query('parental-lock-pin-error'))).toBe( + KEYS.wrongPin + ); + expect(pin()?.getAttribute('aria-invalid')).toBe('true'); + expect(dialog.close).not.toHaveBeenCalled(); + + await dialog.type('parental-lock-pin', '2468'); + expect(dialog.query('parental-lock-pin-error')).toBeNull(); + await dialog.pressEnter(); + expect(dialog.close).toHaveBeenCalledWith('2468'); + }); + + it('does not verify an incomplete PIN', async () => { + const verify = jest.fn(async () => true); + const dialog = await openDialog({ mode: 'unlock', verify }); + + await dialog.type('parental-lock-pin', '12'); + await dialog.pressEnter(); + + expect(verify).not.toHaveBeenCalled(); + expect(text(dialog.query('parental-lock-pin-error'))).toBe( + KEYS.hint + ); + }); + }); + + describe('cooldown', () => { + const throttle = createParentalLockPinThrottle(); + const verify = jest.fn(async () => false); + + it('keeps the cooldown when the prompt is dismissed and opened again', async () => { + const first = ( + await openDialog({ mode: 'unlock', verify, throttle }) + ).component; + for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { + first.onPinInput('0000'); + await first.submit(); + } + expect(first.inCooldown()).toBe(true); + expect(verify).toHaveBeenCalledTimes( + PARENTAL_LOCK_PIN_MAX_ATTEMPTS + ); + + const reopened = ( + await openDialog({ mode: 'unlock', verify, throttle }) + ).component; + expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); + reopened.onPinInput('0000'); + await reopened.submit(); + + expect(reopened.inCooldown()).toBe(true); + expect(verify).toHaveBeenCalledTimes( + PARENTAL_LOCK_PIN_MAX_ATTEMPTS + ); + }); }); }); diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index e9eafbfd2..42fb878d9 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, @@ -29,6 +30,8 @@ import { export type ParentalLockPinDialogMode = 'unlock' | 'set'; +type PinDialogField = 'pin' | 'confirmation'; + export interface ParentalLockPinDialogData { mode: ParentalLockPinDialogMode; /** Unlock only: whether the typed PIN is the right one. */ @@ -41,12 +44,18 @@ export interface ParentalLockPinDialogData { throttle?: ParentalLockPinThrottle; titleKey?: string; descriptionKey?: string; + /** The flow's verb on the submit button; defaults to the mode's. */ + submitKey?: string; } /** * PIN prompt for the parental lock. Pure UI: the caller supplies `verify` * for the unlock mode and receives the accepted PIN (or `undefined` when * dismissed) through the dialog result. `set` mode asks for the PIN twice. + * + * Submit stays enabled while the input is incomplete: implicit submission + * (Enter) does nothing on a disabled default button, so an invalid entry + * is refused by `submit()` with an error on the field instead. */ @Component({ selector: 'app-parental-lock-pin-dialog', @@ -70,18 +79,24 @@ export class ParentalLockPinDialogComponent { ); private readonly pinInput = viewChild>('pinInput'); + private readonly confirmationInput = + viewChild>('confirmationInput'); readonly minLength = PARENTAL_LOCK_PIN_MIN_LENGTH; readonly maxLength = PARENTAL_LOCK_PIN_MAX_LENGTH; readonly pin = signal(''); readonly confirmation = signal(''); readonly busy = signal(false); + /** Wrong PIN or cooldown, shown on the PIN field. */ readonly error = signal(null); - readonly shake = signal(false); + readonly shake = signal(null); readonly cooldownUntil = signal(0); + /** A submit was refused for this field; cleared when it is edited. */ + private readonly refused = signal(null); private readonly throttle = this.data.throttle ?? createParentalLockPinThrottle(); private cooldownTimer: number | null = null; + private shakeTimer: number | null = null; readonly isSetMode = computed(() => this.data.mode === 'set'); readonly titleKey = computed( @@ -98,14 +113,43 @@ export class ParentalLockPinDialogComponent { ? 'PARENTAL_LOCK.PIN_DIALOG.SET_DESCRIPTION' : 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK_DESCRIPTION') ); - readonly inCooldown = computed(() => this.cooldownUntil() > Date.now()); - readonly canSubmit = computed( + readonly submitKey = computed( () => - !this.busy() && - !this.inCooldown() && - isValidParentalLockPin(this.pin()) && - (!this.isSetMode() || this.confirmation() === this.pin()) + this.data.submitKey ?? + (this.isSetMode() + ? 'PARENTAL_LOCK.PIN_DIALOG.SAVE' + : 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK') ); + readonly inCooldown = computed(() => this.cooldownUntil() > Date.now()); + /** The message under the PIN field, if any. */ + readonly pinErrorKey = computed( + () => + this.error() ?? + (this.refused() === 'pin' && !isValidParentalLockPin(this.pin()) + ? 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT' + : null) + ); + /** + * Set mode: the repeat differs from the PIN. Shown while typing once the + * repeat is as long as the PIN, and at any length after a refused submit. + */ + readonly mismatch = computed(() => { + const confirmation = this.confirmation(); + const pin = this.pin(); + if (!this.isSetMode() || confirmation === pin) { + return false; + } + return ( + this.refused() === 'confirmation' || + (confirmation.length > 0 && confirmation.length >= pin.length) + ); + }); + readonly pinErrorState: ErrorStateMatcher = { + isErrorState: () => this.pinErrorKey() !== null, + }; + readonly confirmationErrorState: ErrorStateMatcher = { + isErrorState: () => this.mismatch(), + }; constructor() { // Reopened during a cooldown: the pause carries on where it was. @@ -132,24 +176,48 @@ export class ParentalLockPinDialogComponent { onPinInput(value: string): void { this.pin.set(value.replace(/\D/g, '').slice(0, this.maxLength)); this.error.set(null); + this.clearRefusal('pin'); } onConfirmationInput(value: string): void { this.confirmation.set( value.replace(/\D/g, '').slice(0, this.maxLength) ); - this.error.set(null); + this.clearRefusal('confirmation'); + } + + /** + * Set mode: Enter after a complete first PIN moves on to an empty repeat + * instead of submitting. Handled on the key, not in `submit()`: focus + * cannot tell Enter from a click on Save, as WebKit does not focus a + * clicked button. + */ + onPinEnter(event: Event): void { + if ( + this.isSetMode() && + !this.confirmation() && + isValidParentalLockPin(this.pin()) + ) { + event.preventDefault(); + this.confirmationInput()?.nativeElement.focus(); + } } async submit(): Promise { - if (!this.canSubmit()) { - if (this.isSetMode() && this.confirmation() !== this.pin()) { - this.fail('PARENTAL_LOCK.PIN_DIALOG.MISMATCH'); - } + if (this.busy() || this.inCooldown()) { return; } const pin = this.pin(); + if (!isValidParentalLockPin(pin)) { + this.error.set(null); + this.refuse('pin'); + return; + } if (this.isSetMode()) { + if (this.confirmation() !== pin) { + this.refuse('confirmation'); + return; + } this.dialogRef.close(pin); return; } @@ -165,10 +233,11 @@ export class ParentalLockPinDialogComponent { this.pin.set(''); if (this.throttle.recordFailure()) { this.startCooldown(this.throttle.cooldownUntil()); - this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); + this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); } else { - this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN'); + this.error.set('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN'); } + this.shakeField('pin'); } finally { this.busy.set(false); queueMicrotask(() => this.pinInput()?.nativeElement.focus()); @@ -179,10 +248,48 @@ export class ParentalLockPinDialogComponent { this.dialogRef.close(undefined); } - private fail(messageKey: string): void { - this.error.set(messageKey); - this.shake.set(true); - window.setTimeout(() => this.shake.set(false), 400); + /** Shows the field's error, shakes it and moves focus to it. */ + private refuse(field: PinDialogField): void { + this.refused.set(field); + this.shakeField(field); + this.fieldInput(field)?.focus(); + } + + private fieldInput(field: PinDialogField): HTMLInputElement | undefined { + return (field === 'pin' ? this.pinInput() : this.confirmationInput()) + ?.nativeElement; + } + + private clearRefusal(field: PinDialogField): void { + if (this.refused() === field) { + this.refused.set(null); + } + } + + private shakeField(field: PinDialogField): void { + // A refusal inside the previous one's 400ms shakes for its own + // full time; the earlier timer would otherwise end it early. + if (this.shakeTimer !== null) { + window.clearTimeout(this.shakeTimer); + } + if (this.shake() === field) { + // Same field again: its class stays on, so the running shake + // would only finish. Rewind it (none under reduced motion). + this.fieldInput(field) + ?.closest('mat-form-field') + ?.getAnimations?.() + .filter((animation) => + (animation as CSSAnimation).animationName?.includes( + 'pin-dialog-shake' + ) + ) + .forEach((animation) => (animation.currentTime = 0)); + } + this.shake.set(field); + this.shakeTimer = window.setTimeout(() => { + this.shakeTimer = null; + this.shake.set(null); + }, 400); } private startCooldown(until: number): void {