From 04d6768ba69828e714fe79304b5904f8d89eac7e Mon Sep 17 00:00:00 2001
From: Leo Vasanko
Date: Mon, 7 Sep 2026 16:50:31 +0000
Subject: [PATCH] =?UTF-8?q?Admin=20UI:=20origin=20editor=20fixes=20?=
=?UTF-8?q?=E2=80=94=20'*'=20expands=20only=20on=20typed=20input=20into=20?=
=?UTF-8?q?an=20empty=20field=20(second=20asterisk=20selected),=20empty=20?=
=?UTF-8?q?rows=20ignored,=20validation=20debounced=20to=20pause/blur,=20m?=
=?UTF-8?q?alformed=20hostnames=20rejected?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/admin/AdminDialogs.vue | 62 ++++++++++++++++++++++-------
1 file changed, 47 insertions(+), 15 deletions(-)
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. 🔗 means related host requiring WebAuthn ROR setup. 🔑 is the dedicated Paskia host for all account management.
- - 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.