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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user