Admin UI: origin editor fixes — '*' expands only on typed input into an empty field (second asterisk selected), empty rows ignored, validation debounced to pause/blur, malformed hostnames rejected

This commit is contained in:
2026-09-07 16:50:31 +00:00
parent d37ed9c7bb
commit 04d6768ba6
+47 -15
View File
@@ -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 '**.<rp-id>' immediately, keeping the
// cursor where it was (before the inserted rp-id).
// A '*' typed into an empty field expands to '**.<rp-id>' with the second
// asterisk selected: typing on (e.g. '.') replaces the selection —
// yielding '*.<rp-id>' — 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: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template>
</p>
<ul v-if="hasOriginDiagnostics" class="diag-list">
<li v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a bare '*' or '**' is not allowed, and wildcards only within the domain.</li>
<li v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — check for typos in the hostname; a bare '*' or '**' is not allowed, and wildcards only within the domain.</li>
<li v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</li>
<li v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</li>
<li v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</li>