Frontend: domain dialog fixes and dead-code removal

- Empty-origins default shows as a '*' placeholder row that is not
  persisted unless edited (open+save no longer tightens any-scheme to
  https-only)
- Foreign wildcards are flagged invalid instead of being classified as
  related origins; over-cap related list disables Save
- Single-label rp-ids accepted (matching backend validate_rp_id)
- Auth-host mark follows row edits; row menu state resets on dialog close
- rp-id/origin keys lowercased for classification and submit
- settings cache: stale in-flight responses no longer overwrite a forced
  refresh
- Remove the dead oidc-edit dialog path and other unused code; fix stale
  comments (realm→domain, '*' semantics, per-domain discovery URLs)
This commit is contained in:
LeoVasanko committed 2026-09-07 13:24:19 +00:00
1 parent 3ff41ca354
commit 0d1d18e8a7
8 files changed
+87 -110

No files matched your search

+51 -50
View File
@@ -10,34 +10,26 @@ const props = defineProps({
PERMISSION_ID_PATTERN: String
})
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
defineEmits(['submitDialog', 'closeDialog'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([])
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// The rp-id of the domain being edited in the 'domain-edit' dialog
const dialogRpId = computed(() => props.dialog?.data?.rp_id || '')
// (lowercased: classification compares against it, and hosts are
// case-insensitive)
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
// Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'domain-edit') {
if (!('originValidation' in props.dialog.data)) {
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
}
if (!('wellKnownCheck' in props.dialog.data)) {
props.dialog.data.wellKnownCheck = null
}
}
// Block submit on hard errors: malformed entries 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).
// Block submit on hard errors: malformed entries, an over-cap related list
// (the server rejects the save), 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).
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'domain-edit') return false
const d = props.dialog.data
const bad = v => v === 'invalid' || v === 'validating'
if (d.originValidation?.some(bad)) return true
if (relatedEntries.value.length > 5) return true
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
return false
})
@@ -47,6 +39,8 @@ const isValidationInvalid = computed(() => {
// names are related origins (WebAuthn ROR). Classification is automatic
// from the hostname — the submit handler splits the two lists apart.
function isRelatedEntry(origin) {
const v = origin.trim()
if (v === '*' || v.startsWith('*.')) return false // wildcards are never related
const h = originHostname(origin)
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
}
@@ -86,9 +80,10 @@ function addOrigin() {
// Prefill with the origin the admin is currently on when editing that
// very domain (so saving never locks them out), else with the rp-id
// (https default).
const onThisDomain = authStore.settings?.rp_id && d.rp_id === authStore.settings.rp_id
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) {
@@ -96,6 +91,7 @@ function removeOrigin(i) {
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
d.originPlaceholders.splice(i, 1)
}
}
function focusOriginStart(e) {
@@ -106,7 +102,9 @@ function isWellFormedDomain(value) {
if (!value.trim()) return false
try {
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
return url.hostname.includes('.') || url.hostname === 'localhost'
// Any DNS label sequence (matching backend validate_rp_id): labels of
// 1-63 alnum/hyphen chars, no leading/trailing hyphen, dot-separated
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(url.hostname)
} catch {
return false
}
@@ -116,7 +114,7 @@ function originHostname(origin) {
if (!origin.trim()) return null
if (origin.trim() === '*') return '*'
if (origin.trim().startsWith('*.')) {
const base = origin.trim().slice(2).replace(/\.+$/, '')
const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase()
return isWellFormedDomain(base) ? base : null
}
try {
@@ -168,6 +166,7 @@ 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 === '*' && dialogRpId.value) {
const pos = el.selectionStart
@@ -176,6 +175,13 @@ 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) {
const key = entryKey(value)
d.auth_host = key && !key.startsWith('*') && !isRelatedEntry(value) ? key : ''
}
validateOrigin(i)
}
@@ -188,7 +194,9 @@ function validateOrigin(i) {
return
}
if (value.trim() === '*' || value.trim().startsWith('*.')) {
d.originValidation[i] = null // wildcards have no concrete site to probe
// 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'
return
}
validateOriginConnectivity(i)
@@ -245,6 +253,7 @@ 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
@@ -256,6 +265,10 @@ watch(dialogRpId, rp => {
const openMenu = ref(null)
// The component stays mounted across dialogs; a closed dialog (including
// Escape in Modal) must not leave a row menu open
watch(() => props.dialog?.type, () => { openMenu.value = null })
// Close the popup on any click outside it (the toggle button stops
// propagation, so it never reaches this listener).
function onDocumentClick(e) {
@@ -288,6 +301,7 @@ function setAuthHost(i) {
if (!d.origins.some(o => entryKey(o) === key)) {
d.origins.push(key)
d.originValidation.push(null)
d.originPlaceholders.push(false)
added = true
}
}
@@ -311,14 +325,21 @@ 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]])
const pairs = d.origins.map((o, i) => [o, d.originValidation[i], d.originPlaceholders[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) && !isRelatedEntry(o)).length
(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) {
@@ -326,7 +347,7 @@ function canRemoveOrigin(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 isRelatedEntry(o) || !originHostname(o) || inDomainCount.value > 1
return !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1
}
function onRemoveOrigin(i) {
@@ -349,7 +370,6 @@ function onRemoveOrigin(i) {
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : `Edit Domain: ${dialog.data?.rp_id}` }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
@@ -445,6 +465,7 @@ function onRemoveOrigin(i) {
</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 === '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>
@@ -452,9 +473,10 @@ function onRemoveOrigin(i) {
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 ⋮).
</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>
<template v-if="relatedEntries.length">
<p v-if="relatedEntries.length > 5" class="small error">Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.</p>
<p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p>
<p class="small muted">
Related origins are verified by browsers against
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
@@ -474,7 +496,7 @@ function onRemoveOrigin(i) {
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@@ -491,37 +513,16 @@ function onRemoveOrigin(i) {
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form>
</Modal>
</template>
<style scoped>
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
/* Server config origins */
/* Domain origins */
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.key-badge { flex-shrink: 0; }
.row-menu { position: relative; flex-shrink: 0; }