Admin UI: rename Realms to Domains, list allowed origins per domain
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user