diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 773e390..ddfd741 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -144,21 +144,38 @@ async function addOrigin() { if (!d) return d.origins.push('') d.originValidation.push(null) - // An empty entry validates invalid, blocking save until it is filled - validateOrigin(d.origins.length - 1) await nextTick() originInputs.value[originInputs.value.length - 1]?.focus() } + +// Row validation runs after a short typing pause and immediately on +// blur, so no error indication appears mid-edit. Empty rows are ignored. +const originValidateTimers = new Map() + +function scheduleValidateOrigin(i) { + clearTimeout(originValidateTimers.get(i)) + originValidateTimers.set(i, setTimeout(() => { + originValidateTimers.delete(i) + validateOrigin(i) + }, 600)) +} + +function onOriginBlur(i) { + clearTimeout(originValidateTimers.get(i)) + originValidateTimers.delete(i) + validateOrigin(i) +} + function removeOrigin(i) { const d = props.dialog?.data if (d) { + // Row indices shift on removal — drop all pending validations + for (const t of originValidateTimers.values()) clearTimeout(t) + originValidateTimers.clear() d.origins.splice(i, 1) d.originValidation.splice(i, 1) } } -function focusOriginStart(e) { - e.target.setSelectionRange(0, 0) -} function isWellFormedDomain(value) { if (!value.trim()) return false @@ -197,7 +214,9 @@ function originHostname(origin) { } try { const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v) - return url.hostname || null + // The URL parser keeps malformed hostnames like '.localhost' or + // 'a..b.com' — reject anything that is not clean dot-separated labels + return url.hostname && isWellFormedDomain(url.hostname) ? url.hostname : null } catch { return null } @@ -237,19 +256,22 @@ async function validateOriginConnectivity(i) { } } -// A sole '*' or '**' expands to '**.' immediately, keeping the -// cursor where it was (before the inserted rp-id). +// A '*' typed into an empty field expands to '**.' with the second +// asterisk selected: typing on (e.g. '.') replaces the selection — +// yielding '*.' — while the rp-id stays at the end; Backspace +// deletes the second asterisk; doing nothing keeps the any-depth form. +// Only typed input into an empty field triggers this — never pasting or +// deleting (e.g. backspacing '**' down to '*' must not re-expand). function onOriginInput(i, e) { const d = props.dialog?.data if (!d) return const el = e.target const oldKey = entryKey(d.origins[i]) let value = el.value - if ((value === '*' || value === '**') && dialogRpId.value) { - const pos = el.selectionStart + if (value === '*' && dialogRpId.value && (e.inputType === 'insertText' || e.inputType === 'insertCompositionText')) { value = '**.' + dialogRpId.value el.value = value - el.setSelectionRange(pos, pos) + el.setSelectionRange(1, 2) } d.origins[i] = value // Keep the auth-host mark on a renamed entry, unless it no longer @@ -258,13 +280,19 @@ function onOriginInput(i, e) { const key = entryKey(value) d.auth_host = key && !key.startsWith('*') && !isRelatedEntry(value) ? key : '' } - validateOrigin(i) + d.originValidation[i] = null + scheduleValidateOrigin(i) } function validateOrigin(i) { const d = props.dialog?.data if (!d) return const value = d.origins[i] + // Empty rows are ignored — never errors, and skipped on save + if (!value || !value.trim()) { + d.originValidation[i] = null + return + } if (!originHostname(value)) { d.originValidation[i] = 'invalid' return @@ -349,7 +377,11 @@ function onDocumentClick(e) { if (openMenu.value !== null && !e.target.closest('.row-menu')) openMenu.value = null } onMounted(() => document.addEventListener('click', onDocumentClick)) -onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick)) +onBeforeUnmount(() => { + document.removeEventListener('click', onDocumentClick) + for (const t of originValidateTimers.values()) clearTimeout(t) + originValidateTimers.clear() +}) // Origins-dict key form of an entry (https:// omitted), also used for the // auth_host value. @@ -505,7 +537,7 @@ function onRemoveOrigin(i) { ref="originInputs" :value="dialog.data.origins[i]" @input="e => onOriginInput(i, e)" - @focus="focusOriginStart" + @blur="onOriginBlur(i)" class="origin-input" :class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }" /> @@ -525,7 +557,7 @@ function onRemoveOrigin(i) { Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: **.{{ dialog.data.rp_id }} allows the whole domain, *.{{ dialog.data.rp_id }} only a single subdomain level.

    -
  • Some entries are invalid — a bare '*' or '**' is not allowed, and wildcards only within the domain.
  • +
  • Some entries are invalid — check for typos in the hostname; a bare '*' or '**' is not allowed, and wildcards only within the domain.
  • Some sites are unreachable — make sure they are routed to this instance.
  • Some sites are reachable but do not serve this domain.
  • At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.