mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Fix login modal nsec error visibility
- Removed `required` attribute from nsec secret input in `components/login-modal.html` to prevent browser native validation from suppressing custom error UI.
- Updated `js/ui/loginModalController.js` to ensure `required` property is disabled in JavaScript logic.
- Added comprehensive unit tests in `tests/login-modal-controller.test.mjs` to verify:
- Custom error message is shown on empty submission.
- Required attributes remain disabled when toggling between unlock/import modes.
- NIP-46 handshake panel is correctly displayed (regression check).
- Verified fix with existing E2E test `tests/e2e/ui-modals.spec.ts` (NSEC error assertion passes).
This commit is contained in:
1 parent
3a556f2597
commit
8bbe3f623d
3 files changed
+278
-5
No files matched your search
@@ -204,7 +204,6 @@
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
data-nsec-secret
|
||||
required
|
||||
></textarea>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-text">
|
||||
<input type="checkbox" class="checkbox" data-nsec-remember />
|
||||
|
||||
@@ -937,14 +937,14 @@ export default class LoginModalController {
|
||||
passphraseFields.hidden = !shouldShow;
|
||||
if (passphraseInput instanceof HTMLInputElement) {
|
||||
passphraseInput.disabled = !shouldShow;
|
||||
passphraseInput.required = shouldShow;
|
||||
passphraseInput.required = false;
|
||||
if (!shouldShow) {
|
||||
passphraseInput.value = "";
|
||||
}
|
||||
}
|
||||
if (passphraseConfirm instanceof HTMLInputElement) {
|
||||
passphraseConfirm.disabled = !shouldShow;
|
||||
passphraseConfirm.required = shouldShow;
|
||||
passphraseConfirm.required = false;
|
||||
if (!shouldShow) {
|
||||
passphraseConfirm.value = "";
|
||||
}
|
||||
@@ -959,14 +959,14 @@ export default class LoginModalController {
|
||||
}
|
||||
if (unlockPassphrase instanceof HTMLInputElement) {
|
||||
unlockPassphrase.disabled = !usingUnlock;
|
||||
unlockPassphrase.required = usingUnlock;
|
||||
unlockPassphrase.required = false;
|
||||
if (!usingUnlock) {
|
||||
unlockPassphrase.value = "";
|
||||
}
|
||||
}
|
||||
if (secretField instanceof HTMLTextAreaElement) {
|
||||
secretField.disabled = usingUnlock;
|
||||
secretField.required = !usingUnlock;
|
||||
secretField.required = false;
|
||||
if (usingUnlock) {
|
||||
secretField.value = "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import test, { beforeEach, afterEach } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import LoginModalController from '../js/ui/loginModalController.js';
|
||||
|
||||
const loginModalHtml = await readFile(
|
||||
new URL('../components/login-modal.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
let dom;
|
||||
let container;
|
||||
let controller;
|
||||
|
||||
async function waitForAnimationFrame(window, cycles = 1) {
|
||||
for (let i = 0; i < cycles; i += 1) {
|
||||
await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
function createMatchMediaList(query, initialMatches = false) {
|
||||
let matches = Boolean(initialMatches);
|
||||
const listeners = new Set();
|
||||
|
||||
const notify = () => {
|
||||
const event = { matches, media: query, type: 'change' };
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
set matches(value) {
|
||||
matches = Boolean(value);
|
||||
},
|
||||
media: query,
|
||||
addEventListener(type, listener) {
|
||||
if (type === 'change' && typeof listener === 'function') {
|
||||
listeners.add(listener);
|
||||
}
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (type === 'change' && typeof listener === 'function') {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
},
|
||||
addListener(listener) {
|
||||
if (typeof listener === 'function') {
|
||||
listeners.add(listener);
|
||||
}
|
||||
},
|
||||
removeListener(listener) {
|
||||
if (typeof listener === 'function') {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
if (!event || event.type !== 'change') {
|
||||
return true;
|
||||
}
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {}
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setMatches(value) {
|
||||
matches = Boolean(value);
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body><div id="modalMount"></div></body></html>',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
pretendToBeVisual: true,
|
||||
},
|
||||
);
|
||||
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.HTMLElement = dom.window.HTMLElement;
|
||||
global.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
global.HTMLInputElement = dom.window.HTMLInputElement;
|
||||
global.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
global.HTMLFormElement = dom.window.HTMLFormElement;
|
||||
global.HTMLTemplateElement = dom.window.HTMLTemplateElement;
|
||||
global.Node = dom.window.Node;
|
||||
global.Element = dom.window.Element;
|
||||
global.MutationObserver = dom.window.MutationObserver;
|
||||
|
||||
window.requestAnimationFrame = (callback) => callback(0);
|
||||
global.requestAnimationFrame = window.requestAnimationFrame;
|
||||
|
||||
window.__matchMediaMocks = [];
|
||||
window.matchMedia = (query) => {
|
||||
const mql = createMatchMediaList(query, false);
|
||||
window.__matchMediaMocks.push(mql);
|
||||
return mql;
|
||||
};
|
||||
global.matchMedia = window.matchMedia;
|
||||
|
||||
container = document.getElementById('modalMount');
|
||||
container.innerHTML = loginModalHtml;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (controller) {
|
||||
controller.destroy();
|
||||
controller = null;
|
||||
}
|
||||
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
delete global.HTMLElement;
|
||||
delete global.HTMLButtonElement;
|
||||
delete global.HTMLInputElement;
|
||||
delete global.HTMLTextAreaElement;
|
||||
delete global.HTMLFormElement;
|
||||
delete global.HTMLTemplateElement;
|
||||
delete global.Node;
|
||||
delete global.Element;
|
||||
delete global.MutationObserver;
|
||||
delete global.requestAnimationFrame;
|
||||
delete global.matchMedia;
|
||||
|
||||
if (dom) {
|
||||
dom.window.close();
|
||||
dom = null;
|
||||
}
|
||||
});
|
||||
|
||||
function createController(options = {}) {
|
||||
const modalElement = container.querySelector('#loginModal');
|
||||
return new LoginModalController({
|
||||
modalElement,
|
||||
providers: options.providers || [],
|
||||
services: options.services || {},
|
||||
callbacks: options.callbacks || {},
|
||||
});
|
||||
}
|
||||
|
||||
test('LoginModalController shows custom error for empty nsec input', async (t) => {
|
||||
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
|
||||
const authService = { requestLogin: async () => {} };
|
||||
controller = createController({ providers, services: { authService } });
|
||||
|
||||
// Simulate clicking the nsec provider button
|
||||
// This requires us to inspect internal state or simulate events
|
||||
// But since `promptForNsecOptions` is async and returns a promise, we can call it if we can access the instance.
|
||||
// However, `handleProviderSelection` is what we usually trigger.
|
||||
|
||||
// Let's use internal methods to render providers first
|
||||
controller.initialize();
|
||||
|
||||
const providerButton = container.querySelector('[data-provider-id="nsec"]');
|
||||
assert.ok(providerButton, 'nsec provider button should be present');
|
||||
|
||||
// Trigger click on provider
|
||||
const clickEvent = new dom.window.Event('click', { bubbles: true });
|
||||
providerButton.dispatchEvent(clickEvent);
|
||||
|
||||
// Wait for the nsec form to appear
|
||||
await waitForAnimationFrame(window, 5);
|
||||
|
||||
const form = container.querySelector('[data-nsec-form]');
|
||||
assert.ok(form, 'nsec form should be present');
|
||||
|
||||
const secretInput = form.querySelector('[data-nsec-secret]');
|
||||
assert.ok(secretInput, 'secret input should be present');
|
||||
assert.ok(!secretInput.hasAttribute('required'), 'secret input should NOT have required attribute');
|
||||
assert.equal(secretInput.required, false, 'secret input property required should be false');
|
||||
|
||||
const submitButton = form.querySelector('[data-nsec-submit]');
|
||||
assert.ok(submitButton, 'submit button should be present');
|
||||
|
||||
const errorNode = form.querySelector('[data-nsec-error]');
|
||||
assert.ok(errorNode, 'error node should be present');
|
||||
assert.ok(errorNode.classList.contains('hidden'), 'error node should be initially hidden');
|
||||
|
||||
// Submit with empty form
|
||||
const submitEvent = new dom.window.Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(submitEvent);
|
||||
|
||||
// Wait for validation
|
||||
await waitForAnimationFrame(window, 2);
|
||||
|
||||
assert.ok(!errorNode.classList.contains('hidden'), 'error node should be visible after empty submit');
|
||||
assert.equal(errorNode.textContent, 'Paste an nsec, hex key, or mnemonic to continue.');
|
||||
});
|
||||
|
||||
test('LoginModalController does not set required attribute when toggling modes', async (t) => {
|
||||
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
|
||||
const storedMetadata = { hasEncryptedKey: true };
|
||||
const nostrClient = {
|
||||
getStoredSessionActorMetadata: () => storedMetadata
|
||||
};
|
||||
const authService = { requestLogin: async () => {} };
|
||||
|
||||
controller = createController({
|
||||
providers,
|
||||
services: { nostrClient, authService }
|
||||
});
|
||||
controller.initialize();
|
||||
|
||||
const providerButton = container.querySelector('[data-provider-id="nsec"]');
|
||||
providerButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
|
||||
await waitForAnimationFrame(window, 5);
|
||||
|
||||
const form = container.querySelector('[data-nsec-form]');
|
||||
const unlockRadio = form.querySelector('[data-nsec-mode="unlock"]');
|
||||
const importRadio = form.querySelector('[data-nsec-mode="import"]');
|
||||
const secretInput = form.querySelector('[data-nsec-secret]');
|
||||
const unlockPassphrase = form.querySelector('[data-nsec-unlock-passphrase]');
|
||||
|
||||
// Initially should be in unlock mode because hasEncryptedKey is true
|
||||
assert.ok(unlockRadio.checked);
|
||||
assert.equal(secretInput.disabled, true);
|
||||
assert.equal(secretInput.required, false);
|
||||
|
||||
// Unlock passphrase should be enabled but NOT required (because we want custom validation)
|
||||
assert.equal(unlockPassphrase.disabled, false);
|
||||
assert.equal(unlockPassphrase.required, false);
|
||||
|
||||
// Switch to import mode
|
||||
importRadio.click();
|
||||
unlockRadio.checked = false;
|
||||
importRadio.checked = true;
|
||||
importRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
|
||||
|
||||
await waitForAnimationFrame(window, 2);
|
||||
|
||||
// Secret input should be enabled and NOT required
|
||||
assert.equal(secretInput.disabled, false);
|
||||
assert.equal(secretInput.required, false);
|
||||
|
||||
// Unlock passphrase should be disabled
|
||||
assert.equal(unlockPassphrase.disabled, true);
|
||||
assert.equal(unlockPassphrase.required, false);
|
||||
});
|
||||
|
||||
test('LoginModalController shows NIP-46 handshake panel', async (t) => {
|
||||
const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }];
|
||||
const authService = { requestLogin: async () => {} };
|
||||
controller = createController({ providers, services: { authService } });
|
||||
controller.initialize();
|
||||
|
||||
const providerButton = container.querySelector('[data-provider-id="nip46"]');
|
||||
assert.ok(providerButton, 'nip46 provider button should be present');
|
||||
|
||||
// Trigger click on provider
|
||||
const clickEvent = new dom.window.Event('click', { bubbles: true });
|
||||
providerButton.dispatchEvent(clickEvent);
|
||||
|
||||
// Wait for the nip46 form to appear
|
||||
await waitForAnimationFrame(window, 5);
|
||||
|
||||
const form = container.querySelector('[data-nip46-form]');
|
||||
assert.ok(form, 'nip46 form should be present');
|
||||
|
||||
const handshakePanel = form.querySelector('[data-nip46-handshake-panel]');
|
||||
assert.ok(handshakePanel, 'handshake panel should be present');
|
||||
assert.ok(!handshakePanel.classList.contains('hidden'), 'handshake panel should not be hidden');
|
||||
});
|
||||
Reference in new issue
Block a user