diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue
index 73f50f0..f9725fc 100644
--- a/frontend/auth/App.vue
+++ b/frontend/auth/App.vue
@@ -39,7 +39,7 @@ function normalizeHost(raw) {
/**
* Host mode is active when an own_auth_host is configured AND the current host differs from it.
* In host mode, we show a limited profile view with logout and link to full profile.
- * own_auth_host (not auth_host) is used so that realms sharing another realm's auth host
+ * own_auth_host (not auth_host) is used so that domains sharing another domain's auth host
* still serve the full profile on their own hosts.
*/
const isHostMode = computed(() => {
diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue
index f1aca61..dd41b58 100644
--- a/frontend/auth/admin/AdminApp.vue
+++ b/frontend/auth/admin/AdminApp.vue
@@ -17,7 +17,7 @@ import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav'
-import { goBack } from '@/utils/helpers'
+import { goBack, originDisplayEntries } from '@/utils/helpers'
const info = ref(null)
const loading = ref(true)
@@ -28,7 +28,7 @@ const error = ref(null)
const orgs = ref([])
const permissions = ref([])
const oidcClients = ref([])
-const realms = ref([])
+const domains = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view
const currentOidcId = ref(null) // UUID for OIDC client detail view
@@ -175,13 +175,13 @@ async function loadAdminData() {
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
}
-// Realm list is master-admin only; callers guard on isMasterAdmin
-async function loadRealms() {
+// Domain list is master-admin only; callers guard on isMasterAdmin
+async function loadDomains() {
try {
- realms.value = await apiJson('/auth/api/admin/realms/')
+ domains.value = await apiJson('/auth/api/admin/domains/')
} catch (e) {
- console.warn('Unable to load realms', e)
- realms.value = []
+ console.warn('Unable to load domains', e)
+ domains.value = []
}
}
@@ -218,7 +218,7 @@ function clearSensitiveState() {
orgs.value = []
permissions.value = []
oidcClients.value = []
- realms.value = []
+ domains.value = []
userDetail.value = null
editingOidcClient.value = null
authenticated.value = false
@@ -248,7 +248,7 @@ async function load() {
await loadAdminData()
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
- if (isMasterAdmin.value) await loadRealms()
+ if (isMasterAdmin.value) await loadDomains()
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') {
@@ -475,8 +475,8 @@ function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
}
-function createRealm() {
- openDialog('realm-edit', {
+function createDomain() {
+ openDialog('domain-edit', {
isNew: true,
rp_id: '',
rp_name: '',
@@ -487,31 +487,29 @@ function createRealm() {
})
}
-function openRealm(realm) {
- // One combined list for editing: in-domain sites and related origins,
- // classified by hostname. The default is always shown explicitly as the
- // '*.rp_id' wildcard entry. Strip https:// scheme for editing.
- const stored = realm.origins || []
- const origins = [...(stored.length ? stored : ['*.' + realm.rp_id]), ...(realm.related_origins || [])]
- .map(o => o.replace(/^https:\/\//, ''))
- openDialog('realm-edit', {
+function openDomain(domain) {
+ // One combined list for editing, in display order: in-domain sites and
+ // related origins, classified by hostname. The default is always shown
+ // explicitly as the '*.rp_id' wildcard entry.
+ const rows = originDisplayEntries(domain)
+ openDialog('domain-edit', {
isNew: false,
- rp_id: realm.rp_id,
- rp_name: realm.rp_name || '',
- auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''),
- origins,
- originValidation: origins.map(() => null),
+ rp_id: domain.rp_id,
+ rp_name: domain.rp_name || '',
+ auth_host: rows.find(r => r.auth)?.key || '',
+ origins: rows.map(r => r.key),
+ originValidation: rows.map(() => null),
wellKnownCheck: null,
})
}
-function deleteRealm(realm) {
+function deleteDomain(domain) {
openDialog('confirm', {
- message: `Delete domain "${realm.rp_id}"? This is refused while any passkeys remain registered for it.`,
+ message: `Delete domain "${domain.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(`Domain "${realm.rp_id}" deleted.`, 'success', 2500)
- await loadRealms()
+ await apiJson(`/auth/api/admin/domains/${domain.rp_id}`, { method: 'DELETE' })
+ authStore.showMessage(`Domain "${domain.rp_id}" deleted.`, 'success', 2500)
+ await loadDomains()
}
})
}
@@ -937,39 +935,44 @@ async function submitDialog() {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
})
return // Don't call closeDialog() again
- } else if (t === 'realm-edit') {
+ } else if (t === 'domain-edit') {
const d = dialog.value.data
const rp_id = d.rp_id?.trim().toLowerCase()
if (!rp_id) throw new Error('Domain (rp-id) required')
const rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim() || ''
// The combined origins list is split by hostname: entries on the
- // rp-id domain form the in-domain allow-list, entries elsewhere are
- // related origins (ROR). Wildcards ('*.app.example.com') classify by
- // their base domain. Bare hostnames are sent as-is; the backend
- // normalizes them with https://.
- const origins = []
- const related_origins = []
+ // rp-id domain form the in-domain origins object (the auth host
+ // entry is marked), entries elsewhere are related origins (ROR).
+ // Wildcards ('*.app.example.com') classify by their base domain.
+ // Keys are stored without the https:// scheme.
+ const keyOf = o => o.replace(/^https:\/\//, '').replace(/\/+$/, '')
+ const origins = {}
+ const related = {}
for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) {
+ const key = keyOf(o)
let hn = null
- if (o.startsWith('*.')) {
- hn = o.slice(2).replace(/\.+$/, '')
+ if (key.startsWith('*.')) {
+ hn = key.slice(2).replace(/\.+$/, '')
} else {
- try { hn = new URL(o.startsWith('http') ? o : 'https://' + o).hostname } catch { continue }
+ try { hn = new URL(key.startsWith('http') ? key : 'https://' + key).hostname } catch { continue }
}
if (!hn) continue
- if (hn === rp_id || hn.endsWith('.' + rp_id)) origins.push(o)
- else related_origins.push(o)
+ if (hn === rp_id || hn.endsWith('.' + rp_id)) {
+ origins[key] = key === auth_host ? { auth_host: true } : true
+ } else {
+ related[key] = true
+ }
}
closeDialog()
const req = d.isNew
- ? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins, related_origins } })
- : apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins, related_origins } })
+ ? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, related } })
+ : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, related } })
req
.then(() => {
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
- loadRealms()
+ loadDomains()
// Reload settings to reflect rp_name changes
authStore.loadSettings(true).then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
@@ -1031,7 +1034,8 @@ async function submitDialog() {
:orgs="orgs"
:permissions="permissions"
:oidc-clients="oidcClients"
- :realms="realms"
+ :domains="domains"
+ :current-rp-id="authStore.settings?.rp_id || ''"
:navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary"
@create-org="createOrg"
@@ -1045,9 +1049,9 @@ async function submitDialog() {
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
- @create-realm="createRealm"
- @open-realm="openRealm"
- @delete-realm="deleteRealm"
+ @create-domain="createDomain"
+ @open-domain="openDomain"
+ @delete-domain="deleteDomain"
@navigate-out="handlePanelNavigateOut"
/>
diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue
index 833a4dc..2ec625e 100644
--- a/frontend/src/admin/AdminDialogs.vue
+++ b/frontend/src/admin/AdminDialogs.vue
@@ -15,11 +15,11 @@ 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 realm being edited in the 'realm-edit' dialog
-const realmRpId = computed(() => props.dialog?.data?.rp_id || '')
+// The rp-id of the domain being edited in the 'domain-edit' dialog
+const dialogRpId = computed(() => props.dialog?.data?.rp_id || '')
// Initialize validation properties
-if (props.dialog?.data && props.dialog.type === 'realm-edit') {
+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)
}
@@ -33,7 +33,7 @@ if (props.dialog?.data && props.dialog.type === 'realm-edit') {
// (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 !== 'realm-edit') return false
+ 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
@@ -47,7 +47,7 @@ const isValidationInvalid = computed(() => {
// from the hostname — the submit handler splits the two lists apart.
function isRelatedEntry(origin) {
const h = originHostname(origin)
- return !!(h && realmRpId.value && !isWithinDomain(origin, realmRpId.value))
+ return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
}
const relatedEntries = computed(() => {
const d = props.dialog?.data
@@ -86,7 +86,7 @@ function addOrigin() {
// 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
- d.origins.push(onThisDomain ? window.location.origin : realmRpId.value)
+ d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value)
d.originValidation.push(null)
validateOrigin(d.origins.length - 1)
}
@@ -148,7 +148,7 @@ async function validateOriginConnectivity(i) {
if (response.ok) {
const data = await response.json()
// Valid when the entry is served by this instance for the edited domain
- d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
+ d.originValidation[i] = (data.rp_id && data.rp_id === dialogRpId.value) ? 'valid' : 'mismatch'
} else {
d.originValidation[i] = 'unreachable'
}
@@ -207,9 +207,9 @@ watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, {
// so the list always shows what is allowed ('*.example.com' = the domain and
// all its subdomains). Removing the last in-domain entry is blocked in the
// row menu, so the list never becomes empty afterwards.
-watch(realmRpId, rp => {
+watch(dialogRpId, rp => {
const d = props.dialog?.data
- if (props.dialog?.type !== 'realm-edit' || !d?.isNew) return
+ if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
if (!d.origins.length && isWellFormedDomain(rp)) {
d.origins.push('*.' + rp)
d.originValidation.push(null)
@@ -220,33 +220,32 @@ watch(realmRpId, rp => {
const openMenu = ref(null)
-// Host[:port] of an entry or the auth_host value, for comparison.
-function hostWithPort(value) {
- if (!value?.trim()) return null
- if (value.trim().startsWith('*.')) return null
- try { return new URL(value.startsWith('http') ? value : 'https://' + value).host } catch { return null }
+// Origins-dict key form of an entry (https:// omitted), also used for the
+// auth_host value.
+function entryKey(value) {
+ return value?.trim().replace(/^https:\/\//, '').replace(/\/+$/, '') || ''
}
function isAuthHostEntry(origin) {
const d = props.dialog?.data
- const host = hostWithPort(origin)
- return !!(host && d?.auth_host && host === hostWithPort(d.auth_host))
+ const key = entryKey(origin)
+ return !!(key && d?.auth_host && key === d.auth_host)
}
function setAuthHost(i) {
const d = props.dialog?.data
if (!d) return
- let entry = d.origins[i].trim()
- if (entry.startsWith('*.')) {
+ let key = entryKey(d.origins[i])
+ if (key.startsWith('*.')) {
// A wildcard cannot be the auth host — create a concrete auth.