Frontend: single origins table; lockout guard in the domain editor

- Domain dialog submits one origins map (in-domain + related together);
  classification is derived, the submit-time split is gone
- Placeholder-row machinery deleted: an empty list now means 'nothing
  allowed'; new domains get a real pre-filled '*.{rp-id}' row that
  follows rp-id edits until touched
- Plain '*' is invalid; wildcards only within the domain
- Editing the domain in use: when no auth host is marked and the admin's
  current page origin would no longer be allowed to run ceremonies, Save
  is disabled with an explanatory error (mirrors the backend guard)
- Origin list display: single table with derived related badges; '*'
  sort special case removed
This commit is contained in:
2026-09-07 14:54:46 +00:00
parent b901a34615
commit 7726203382
3 changed files with 114 additions and 98 deletions
+78 -55
View File
@@ -19,8 +19,9 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
// case-insensitive)
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
// Block submit on hard errors: malformed entries, an over-cap related list
// (the server rejects the save), or validation still in flight.
// Block submit on hard errors: malformed entries, an over-cap related
// list (the server rejects the save), a save that would lock the admin
// out of the domain they are using, or validation still in flight.
// Connectivity and rp-id mismatch results are warnings only (entries may
// be hosted elsewhere, or a new domain whose DNS is not routed to this
// instance yet).
@@ -31,16 +32,17 @@ const isValidationInvalid = computed(() => {
if (d.originValidation?.some(bad)) return true
if (relatedEntries.value.length > 5) return true
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
if (lockoutWarning.value) return true
return false
})
// A single origins list holds two kinds of entries: sites on the rp-id
// domain form the in-domain sign-in allow-list; entries on other domain
// names are related origins (WebAuthn ROR). Classification is automatic
// from the hostname — the submit handler splits the two lists apart.
// from the hostname. A plain '*' is invalid (wildcards must sit under
// the rp-id) and never a related origin.
function isRelatedEntry(origin) {
const v = origin.trim()
if (v === '*' || v.startsWith('*.')) return false // wildcards are never related
if (origin.trim().startsWith('*.')) return false // wildcards are never related
const h = originHostname(origin)
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
}
@@ -74,6 +76,54 @@ function copyText(value, label) {
})
}
// --- Lockout prevention (editing the domain in use) ---
// When the admin edits the domain they are currently signed in on and no
// auth host is marked (with one, ceremonies move there and saving is
// always allowed), their current page origin must stay allowed to run
// passkey ceremonies — otherwise saving locks them out. Mirrors the
// backend check (Passkey.validate_origin): an in-domain origin matches a
// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the
// base domain and its subdomains over https — any scheme and port under
// localhost); a related row matches only on exact equality (https://host).
const lockoutWarning = computed(() => {
const d = props.dialog?.data
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
const rpId = dialogRpId.value
if (!rpId || rpId !== authStore.settings?.rp_id) return null
return pageOriginAllowed(d.origins || [], rpId) ? null : window.location.host
})
function pageOriginAllowed(rows, rpId) {
const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key
const inDomain = []
const related = []
for (const row of rows) {
if (!originHostname(row)) continue
const key = entryKey(row).toLowerCase()
if (!key) continue
const bucket = isRelatedEntry(row) ? related : inDomain
bucket.push(toUrl(key))
}
const probe = origin => {
let hostname
try { hostname = new URL(origin).hostname } catch { return false }
if (hostname === rpId || hostname.endsWith('.' + rpId)) {
if (inDomain.includes(origin)) return true
return inDomain.some(e => {
if (!e.startsWith('*.')) return false
const base = e.slice(2).replace(/\.+$/, '')
if (hostname !== base && !hostname.endsWith('.' + base)) return false
// Under localhost a wildcard matches any scheme and port
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://')
})
}
return related.includes(origin)
}
// The page scheme may be http (e.g. on localhost) — probe both
return probe(`https://${window.location.host}`) || probe(`http://${window.location.host}`)
}
function addOrigin() {
const d = props.dialog?.data
if (!d) return
@@ -83,7 +133,6 @@ function addOrigin() {
const onThisDomain = authStore.settings?.rp_id && dialogRpId.value === authStore.settings.rp_id
d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value)
d.originValidation.push(null)
d.originPlaceholders.push(false)
validateOrigin(d.origins.length - 1)
}
function removeOrigin(i) {
@@ -91,7 +140,6 @@ function removeOrigin(i) {
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
d.originPlaceholders.splice(i, 1)
}
}
function focusOriginStart(e) {
@@ -111,14 +159,14 @@ function isWellFormedDomain(value) {
}
function originHostname(origin) {
if (!origin.trim()) return null
if (origin.trim() === '*') return '*'
if (origin.trim().startsWith('*.')) {
const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase()
const v = origin.trim()
if (!v || v === '*') return null // a plain '*' is not a valid entry
if (v.startsWith('*.')) {
const base = v.slice(2).replace(/\.+$/, '').toLowerCase()
return isWellFormedDomain(base) ? base : null
}
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
return url.hostname || null
} catch {
return null
@@ -126,7 +174,6 @@ function originHostname(origin) {
}
function isWithinDomain(origin, rpId) {
if (origin.trim() === '*') return true
const hostname = originHostname(origin)
if (!hostname) return false
return hostname === rpId || hostname.endsWith('.' + rpId)
@@ -175,7 +222,6 @@ function onOriginInput(i, e) {
el.setSelectionRange(pos, pos)
}
d.origins[i] = value
d.originPlaceholders[i] = false
// Keep the auth-host mark on a renamed entry, unless it no longer
// qualifies (wildcards and related origins cannot be the auth host)
if (d.auth_host && oldKey === d.auth_host) {
@@ -193,7 +239,7 @@ function validateOrigin(i) {
d.originValidation[i] = 'invalid'
return
}
if (value.trim() === '*' || value.trim().startsWith('*.')) {
if (value.trim().startsWith('*.')) {
// Wildcards have no concrete site to probe, and are only allowed
// within the domain (related origins are individual hosts)
d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid'
@@ -231,19 +277,18 @@ async function testWellKnown() {
}
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
// Seed the default '*.<rp-id>' entry for a new domain once its rp-id is
// known, so the list always shows what is allowed ('*.x' = the domain and
// all its subdomains; https only, any scheme/port under localhost).
// Removing the last in-domain entry is blocked in the row menu, so the
// list never becomes empty afterwards.
// Seeding waits for a complete-looking rp-id (letters after the final dot)
// so mid-typing states like 'something.' don't seed a broken '*.something'.
// Prefill a new domain's list with the real '*.<rp-id>' row once its
// rp-id is known ('*.x' = the domain and all its subdomains over https,
// any scheme and port under localhost). The row follows rp-id edits while
// it is still the untouched prefilled row; once the admin edits it, it is
// left alone. Seeding waits for a complete-looking rp-id (letters after
// the final dot) so mid-typing states like 'something.' don't prefill a
// broken '*.something'.
function looksCompleteDomain(value) {
const host = (value || '').trim().replace(/\.$/, '')
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
}
// Tracks the auto-seeded entry so it can be corrected if it was seeded
// from an incomplete rp-id and the admin keeps typing.
// Tracks the prefilled row so rp-id edits can keep updating it.
let seededOrigin = null
watch(dialogRpId, rp => {
const d = props.dialog?.data
@@ -253,7 +298,6 @@ watch(dialogRpId, rp => {
if (!d.origins.length) {
d.origins.push(seed)
d.originValidation.push(null)
d.originPlaceholders.push(false)
seededOrigin = seed
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
d.origins[0] = seed
@@ -294,14 +338,12 @@ function setAuthHost(i) {
if (!d) return
let key = entryKey(d.origins[i])
let added = false
if (key === '*' || key.startsWith('*.')) {
if (key.startsWith('*.')) {
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
const base = key === '*' ? dialogRpId.value : key.slice(2)
key = 'auth.' + base
key = 'auth.' + key.slice(2)
if (!d.origins.some(o => entryKey(o) === key)) {
d.origins.push(key)
d.originValidation.push(null)
d.originPlaceholders.push(false)
added = true
}
}
@@ -325,34 +367,15 @@ function resortOrigins() {
const d = props.dialog?.data
if (!d) return
const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2
const pairs = d.origins.map((o, i) => [o, d.originValidation[i], d.originPlaceholders[i]])
const pairs = d.origins.map((o, i) => [o, d.originValidation[i]])
pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0]))
d.origins = pairs.map(p => p[0])
d.originValidation = pairs.map(p => p[1])
d.originPlaceholders = pairs.map(p => p[2])
}
const inDomainCount = computed(() =>
(props.dialog?.data?.origins || []).filter(o => originHostname(o) && isWithinDomain(o, dialogRpId.value)).length
)
// True while the list holds the synthesized row for an empty origins
// object (the rp-id and all subdomains, any scheme)
const hasPlaceholderRow = computed(() =>
!!props.dialog?.data?.originPlaceholders?.some(Boolean)
)
function canRemoveOrigin(i) {
const o = props.dialog?.data?.origins[i]
if (o === undefined) return false
// Never empty the in-domain list — that would silently mean the wildcard
// default on the server; keep at least one in-domain entry visible
return !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1
}
function onRemoveOrigin(i) {
const d = props.dialog?.data
if (!d || !canRemoveOrigin(i)) return
if (!d) return
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
removeOrigin(i)
openMenu.value = null
@@ -461,19 +484,19 @@ function onRemoveOrigin(i) {
<div v-if="openMenu === i" class="row-menu-popup">
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()"><span class="menu-icon">🔑</span>Remove auth host</button>
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)"><span class="menu-icon">🔑</span>Set as auth host</button>
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
<button type="button" @click="onRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
</div>
</div>
</div>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — wildcards are only allowed within the domain.</p>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.</p>
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
</div>
<p class="small muted">
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> is shorthand for <strong>*.{{ dialog.data.rp_id }}</strong>: the domain and all its subdomains over https (any scheme and port under localhost); list a full origin like <strong>http://localhost:8080</strong> for other exceptions.
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮).
Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> allows the whole domain over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> for other exceptions.
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). An empty list allows nothing of this domain (related origins still work).
</p>
<p v-if="hasPlaceholderRow" class="small muted">The <strong>*</strong> row is the default of a domain without configured origins: the domain and all its subdomains may sign in on any scheme. Saving keeps this default only while the row is left unedited.</p>
<p v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</p>
<template v-if="relatedEntries.length">
<p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p>