Admin UI: rename Realms to Domains, list allowed origins per domain

This commit is contained in:
2026-09-07 00:08:07 +00:00
parent b6a1b2e4a8
commit 38d994f312
3 changed files with 34 additions and 25 deletions
+4 -4
View File
@@ -508,10 +508,10 @@ function openRealm(realm) {
function deleteRealm(realm) {
openDialog('confirm', {
message: `Delete realm "${realm.rp_id}"? This is refused while any passkeys remain registered for it.`,
message: `Delete domain "${realm.rp_id}"? This is refused while any passkeys remain registered for it.`,
action: async () => {
await apiJson(`/auth/api/admin/realms/${realm.rp_id}`, { method: 'DELETE' })
authStore.showMessage(`Realm "${realm.rp_id}" deleted.`, 'success', 2500)
authStore.showMessage(`Domain "${realm.rp_id}" deleted.`, 'success', 2500)
await loadRealms()
}
})
@@ -958,7 +958,7 @@ async function submitDialog() {
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins, related_origins } })
req
.then(() => {
authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadRealms()
// Reload settings to reflect rp_name changes
authStore.loadSettings(true).then(() => {
@@ -966,7 +966,7 @@ async function submitDialog() {
})
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to save realm', 'error')
authStore.showMessage(e.message || 'Failed to save domain', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') {
+9 -9
View File
@@ -216,7 +216,7 @@ function validateAuthHost() {
<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==='realm-edit'">{{ dialog.data?.isNew ? 'Add Realm' : 'Edit Realm' }}</template>
<template v-else-if="dialog.type==='realm-edit'">{{ dialog.data?.isNew ? 'Add Domain' : 'Edit Domain' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -281,9 +281,9 @@ function validateAuthHost() {
<label>Domain (rp-id)
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
</label>
<p class="small muted">The domain name this realm's passkeys belong to they work on this domain and its subdomains, and never on other realms. Cannot be changed later.</p>
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and never on other domains. Cannot be changed later.</p>
</template>
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
<p v-else class="small muted">Domain: <strong>{{ dialog.data.rp_id }}</strong></p>
<label>Display Name (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
</label>
@@ -294,7 +294,7 @@ function validateAuthHost() {
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Must be {{ dialog.data.rp_id }} or a subdomain of it.</p>
<p v-else-if="dialog.data.authHostValidation === 'unreachable'" class="small muted">Well-formed but unreachable — make sure it is routed to this instance.</p>
<p v-else-if="dialog.data.authHostValidation === 'mismatch'" class="small muted">Reachable, but does not serve this realm.</p>
<p v-else-if="dialog.data.authHostValidation === 'mismatch'" class="small muted">Reachable, but does not serve this domain.</p>
<p v-else class="small muted">Optional. Moves the account and admin interface to this one hostname. Sign-in works on every site regardless.</p>
<div class="origin-label">
@@ -314,10 +314,10 @@ function validateAuthHost() {
</div>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid-domain')" class="small muted">Sites must be on {{ dialog.data.rp_id }} or a subdomain of it — use Related Domains below for other domain names.</p>
<p v-else-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 realm.</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 v-if="!dialog.data.origins.length" class="small muted">All of <strong>{{ dialog.data.rp_id }}</strong> and its subdomains may sign in (default). Add entries to restrict sign-in to specific sites on this domain.</p>
<p v-else class="small muted">Only the listed sites may sign in with this realm's passkeys.</p>
<p v-else class="small muted">Only the listed sites may sign in with this domain's passkeys.</p>
<div class="origin-label">
Related Domains
@@ -334,12 +334,12 @@ function validateAuthHost() {
/>
<button type="button" class="icon-btn delete-icon" @click="removeEntry('related_origins', i)" aria-label="Remove related domain" title="Remove related domain"></button>
</div>
<p v-if="dialog.data.relatedValidation.some(v => v === 'invalid-domain')" class="small muted">That entry is inside {{ dialog.data.rp_id }} — subdomains are already covered by the realm itself.</p>
<p v-if="dialog.data.relatedValidation.some(v => v === 'invalid-domain')" class="small muted">That entry is inside {{ dialog.data.rp_id }} subdomains are already covered by the domain itself.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'unreachable')" class="small muted">Some domains are unreachable make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'mismatch')" class="small muted">Some domains are reachable but do not serve this realm.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'mismatch')" class="small muted">Some domains are reachable but do not serve this domain.</p>
</div>
<p class="small muted">
Other domain names that may use this realm's passkeys (WebAuthn Related Origins, max 5). List only domains you trust as much as {{ dialog.data.rp_id }} itself.
Other domain names that may use this domain's passkeys (WebAuthn Related Origins, max 5). List only domains you trust as much as {{ dialog.data.rp_id }} itself.
<template v-if="dialog.data.related_origins.length">
Browsers verify the list at
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
+21 -12
View File
@@ -38,6 +38,17 @@ function domainDisplay(domain) {
return oidcClientNames.value[domain] || domain
}
// Origins a domain accepts sign-in from: an explicit in-domain allow-list
// replaces the default "*.rp_id" wildcard; related domains (ROR) are always
// listed individually — they cannot use wildcards.
function originHost(origin) {
try { return new URL(origin).host } catch { return origin }
}
function allowedOrigins(realm) {
const inDomain = realm.origins?.length ? realm.origins.map(originHost) : ['*.' + realm.rp_id]
return [...inDomain, ...(realm.related_origins || []).map(originHost)]
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
@@ -428,27 +439,26 @@ defineExpose({ focusFirstElement })
<div v-if="isMasterAdmin" class="realms-section">
<div class="section-header">
<h2>Realms</h2>
<h2>Domains</h2>
<p class="section-description">
Realms are the domain names this instance serves. Each realm has its own passkeys: users sign in per domain, and a passkey registered on one realm never works on another. Add a realm for every domain you operate. To let several <em>different</em> domain names share the same passkeys, open the realm and configure related domains (WebAuthn Related Origins). Changes apply immediately.
The domain names this instance serves. Each domain has its own passkeys: users sign in per domain, and a passkey registered on one domain never works on another. To let several <em>different</em> domain names share the same passkeys, open the domain and configure related domains (WebAuthn Related Origins). Changes apply immediately.
</p>
</div>
<div class="section-actions">
<button @click="$emit('createRealm')">+ Add Realm</button>
<div>
<button @click="$emit('createRealm')">+ Add Domain</button>
</div>
<table class="org-table">
<thead>
<tr>
<th>Realm</th>
<th>Domain</th>
<th>Auth Host</th>
<th class="center">Sign-in Sites</th>
<th class="center">Related Domains</th>
<th>Allowed Origins</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-if="!realms || realms.length === 0">
<td colspan="5" class="center muted">No realms configured</td>
<td colspan="4" class="center muted">No domains configured</td>
</tr>
<tr v-for="realm in realms" :key="realm.rp_id">
<td class="perm-name-cell">
@@ -464,10 +474,9 @@ defineExpose({ focusFirstElement })
<span v-if="realm.effective_auth_host">{{ realm.effective_auth_host }}<span v-if="!realm.auth_host" class="muted"> (shared)</span></span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ realm.origins?.length || 'All' }}</td>
<td class="center">{{ realm.related_origins?.length || '—' }}</td>
<td class="realm-origins">{{ allowedOrigins(realm).join(', ') }}</td>
<td class="center">
<button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete realm" title="Delete realm"></button>
<button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain"></button>
</td>
</tr>
</tbody>
@@ -501,7 +510,7 @@ defineExpose({ focusFirstElement })
/* Realms Section */
.realms-section { margin-top: var(--space-2xl); }
.realms-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.realms-section .section-actions { margin-bottom: var(--space-md); }
.realm-auth-host { font-family: var(--font-mono, monospace); font-size: 0.85rem; }
.realm-origins { font-family: var(--font-mono, monospace); font-size: 0.85rem; }
.realms-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
</style>