Frontend: realm admin UI, passkey realm badges, cross-realm notices

- Admin: replace Server Options dialog with per-realm management —
  realms table on the overview, add/edit/delete realm dialog backed by
  /auth/api/admin/realms/. Origins may be any well-formed origin;
  non-subdomain ones are related origins (ROR, max 5) and the dialog
  points at the .well-known/webauthn URL that must list them.
  Connectivity checks compare against the edited realm's rp-id and
  degrade to warnings instead of blocking saves.
- Host mode (limited profile) now keys off own_auth_host so realms
  sharing another realm's auth host serve the full profile locally.
- Credential list shows a realm badge on passkeys registered for a
  different rp-id than the current realm.
- Profile shows an enrollment prompt when the user has no passkey for
  the current realm (e.g. after a cross-realm remote login).
- Remote auth permit shows the requesting realm when it differs from
  the approver's own.
- settings cache can be force-refreshed after realm changes.
This commit is contained in:
2026-09-06 04:50:35 +00:00
parent cdabc5d9e6
commit 8e7acd6b9e
10 changed files with 274 additions and 89 deletions
+5 -3
View File
@@ -37,11 +37,13 @@ function normalizeHost(raw) {
} }
/** /**
* Host mode is active when an auth_host is configured AND the current host differs from it. * 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. * 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
* still serve the full profile on their own hosts.
*/ */
const isHostMode = computed(() => { const isHostMode = computed(() => {
const authHost = store.settings?.auth_host const authHost = store.settings?.own_auth_host
if (!authHost) return false if (!authHost) return false
const currentHost = normalizeHost(window.location.host) const currentHost = normalizeHost(window.location.host)
const configuredHost = normalizeHost(authHost) const configuredHost = normalizeHost(authHost)
@@ -99,7 +101,7 @@ onMounted(async () => {
if (rpName) { if (rpName) {
// In host mode, show "account summary" style title // In host mode, show "account summary" style title
// Settings are loaded but isHostMode depends on them, so check here // Settings are loaded but isHostMode depends on them, so check here
const authHost = store.settings?.auth_host const authHost = store.settings?.own_auth_host
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost) const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
document.title = inHostMode ? `${rpName} · Account summary` : rpName document.title = inHostMode ? `${rpName} · Account summary` : rpName
} }
+67 -25
View File
@@ -28,6 +28,7 @@ const error = ref(null)
const orgs = ref([]) const orgs = ref([])
const permissions = ref([]) const permissions = ref([])
const oidcClients = ref([]) const oidcClients = ref([])
const realms = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view const currentUserId = ref(null) // UUID for user detail view
const currentOidcId = ref(null) // UUID for OIDC client detail view const currentOidcId = ref(null) // UUID for OIDC client detail view
@@ -174,6 +175,16 @@ async function loadAdminData() {
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c })) 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() {
try {
realms.value = await apiJson('/auth/api/admin/realms/')
} catch (e) {
console.warn('Unable to load realms', e)
realms.value = []
}
}
// Helper to get users for a role as sorted array of [uuid, user] // Helper to get users for a role as sorted array of [uuid, user]
function roleUsers(org, roleUuid) { function roleUsers(org, roleUuid) {
return Object.entries(org.users) return Object.entries(org.users)
@@ -207,6 +218,7 @@ function clearSensitiveState() {
orgs.value = [] orgs.value = []
permissions.value = [] permissions.value = []
oidcClients.value = [] oidcClients.value = []
realms.value = []
userDetail.value = null userDetail.value = null
editingOidcClient.value = null editingOidcClient.value = null
authenticated.value = false authenticated.value = false
@@ -236,6 +248,7 @@ async function load() {
await loadAdminData() await loadAdminData()
// If we get here, user has admin access - now fetch user info for display // If we get here, user has admin access - now fetch user info for display
await loadUserInfo() await loadUserInfo()
if (isMasterAdmin.value) await loadRealms()
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') { if (!window.location.hash || window.location.hash === '#overview') {
@@ -462,21 +475,41 @@ function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
} }
async function openServerConfig() { function createRealm() {
try { openDialog('realm-edit', {
const config = await apiJson('/auth/api/admin/server-config') isNew: true,
// Strip https:// scheme from stored origins and auth_host for editing rp_id: '',
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, '')) rp_name: '',
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '') auth_host: '',
openDialog('server-config', { origins: [],
rp_name: config.rp_name || '', originValidation: [],
auth_host, authHostValidation: null,
origins, })
originValidation: origins.map(() => null), }
})
} catch (e) { function openRealm(realm) {
authStore.showMessage(e.message || 'Failed to load server configuration', 'error') // Strip https:// scheme from stored origins and auth_host for editing
} const origins = (realm.origins || []).map(o => o.replace(/^https:\/\//, ''))
openDialog('realm-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),
authHostValidation: null,
})
}
function deleteRealm(realm) {
openDialog('confirm', {
message: `Delete realm "${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)
await loadRealms()
}
})
} }
function deleteOidcClient(client) { function deleteOidcClient(client) {
@@ -900,25 +933,32 @@ async function submitDialog() {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'server-config') { } else if (t === 'realm-edit') {
const rp_name = dialog.value.data.rp_name?.trim() || '' const d = dialog.value.data
const auth_host = dialog.value.data.auth_host?.trim() || '' const rp_id = d.rp_id?.trim().toLowerCase()
if (!rp_id) throw new Error('RP ID (domain) required')
const rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https:// // Origins are stored as-is (hostnames); backend normalizes with https://
const origins = dialog.value.data.origins const origins = (d.origins || [])
.map(o => o.trim()) .map(o => o.trim())
.filter(o => o) .filter(o => o)
closeDialog() closeDialog()
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } }) const req = d.isNew
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins } })
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins } })
req
.then(() => { .then(() => {
authStore.showMessage('Server configuration updated.', 'success', 2500) authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadRealms()
// Reload settings to reflect rp_name changes // Reload settings to reflect rp_name changes
authStore.loadSettings().then(() => { authStore.loadSettings(true).then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin' if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
}) })
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update server configuration', 'error') authStore.showMessage(e.message || 'Failed to save realm', 'error')
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'confirm') { } else if (t === 'confirm') {
@@ -973,6 +1013,7 @@ async function submitDialog() {
:orgs="orgs" :orgs="orgs"
:permissions="permissions" :permissions="permissions"
:oidc-clients="oidcClients" :oidc-clients="oidcClients"
:realms="realms"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary" :permission-summary="permissionSummary"
@create-org="createOrg" @create-org="createOrg"
@@ -986,7 +1027,9 @@ async function submitDialog() {
@create-oidc-client="createOidcClient" @create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient" @open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient" @delete-oidc-client="deleteOidcClient"
@open-server-config="openServerConfig" @create-realm="createRealm"
@open-realm="openRealm"
@delete-realm="deleteRealm"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
/> />
@@ -1047,7 +1090,6 @@ async function submitDialog() {
<AdminDialogs <AdminDialogs
:dialog="dialog" :dialog="dialog"
:permission-id-pattern="PERMISSION_ID_PATTERN" :permission-id-pattern="PERMISSION_ID_PATTERN"
:settings="authStore.settings"
@submit-dialog="submitDialog" @submit-dialog="submitDialog"
@close-dialog="closeDialog" @close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret" @reset-oidc-secret="resetOidcSecret"
+96 -49
View File
@@ -6,32 +6,59 @@ import { useAuthStore } from '@/stores/auth'
const props = defineProps({ const props = defineProps({
dialog: Object, dialog: Object,
PERMISSION_ID_PATTERN: String, PERMISSION_ID_PATTERN: String
settings: Object
}) })
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient']) const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([]) const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`) 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 || '')
// Initialize validation properties // Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'server-config') { if (props.dialog?.data && props.dialog.type === 'realm-edit') {
if (!('authHostValidation' in props.dialog.data)) { if (!('authHostValidation' in props.dialog.data)) {
props.dialog.data.authHostValidation = null props.dialog.data.authHostValidation = null
} }
if (!('originValidation' in props.dialog.data)) {
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
}
} }
// Block submit on hard errors: malformed entries, auth-host outside the
// rp-id domain, or validation still in flight. Connectivity and rp-id
// mismatch results are warnings only (e.g. related origins hosted elsewhere,
// or a new realm whose DNS is not routed to this instance yet).
const isValidationInvalid = computed(() => { const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'server-config') return false if (props.dialog?.type !== 'realm-edit') return false
const d = props.dialog.data const d = props.dialog.data
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
if (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
return false return false
}) })
// Well-known URL that must list any related (non-subdomain) origins.
// Browsers always fetch it from the rp-id domain, never the auth host.
const wellKnownUrl = computed(() => {
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
return host ? `https://${host}/.well-known/webauthn` : ''
})
// Number of related (non-subdomain) origins in the realm dialog
const relatedOriginCount = computed(() => {
const d = props.dialog?.data
if (!d?.origins) return 0
const id = realmRpId.value
return d.origins.filter(o => {
const h = originHostname(o)
return h && id && h !== id && !h.endsWith('.' + id)
}).length
})
// Copy-to-clipboard helper // Copy-to-clipboard helper
const authStore = useAuthStore() const authStore = useAuthStore()
function copyText(value, label) { function copyText(value, label) {
@@ -43,7 +70,7 @@ function copyText(value, label) {
function addOrigin() { function addOrigin() {
const d = props.dialog?.data const d = props.dialog?.data
if (d) { if (d) {
d.origins.push(rpId.value) d.origins.push(realmRpId.value)
d.originValidation.push(null) d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1) validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
} }
@@ -67,17 +94,32 @@ function focusOriginStart(e) {
e.target.setSelectionRange(0, 0) e.target.setSelectionRange(0, 0)
} }
function validateOriginDomain(origin, rpId) { function isWellFormedDomain(value) {
if (!origin.trim()) return false if (!value.trim()) return false
try { try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin) const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
const hostname = url.hostname return url.hostname.includes('.') || url.hostname === 'localhost'
return hostname === rpId || hostname.endsWith('.' + rpId)
} catch { } catch {
return false return false
} }
} }
function originHostname(origin) {
if (!origin.trim()) return null
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
return url.hostname || null
} catch {
return null
}
}
function isWithinDomain(origin, rpId) {
const hostname = originHostname(origin)
if (!hostname) return false
return hostname === rpId || hostname.endsWith('.' + rpId)
}
async function validateOriginConnectivity(origin, i) { async function validateOriginConnectivity(origin, i) {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return if (!d) return
@@ -90,22 +132,17 @@ async function validateOriginConnectivity(origin, i) {
method: 'GET', method: 'GET',
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}) })
if (d.origins[i] !== origin) return // origin changed while validating
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id) // Valid when the origin is served by this instance for the edited realm
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid' d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
// Only update if the origin hasn't changed
if (d.origins[i] === origin) {
d.originValidation[i] = result
}
} else { } else {
if (d.origins[i] === origin) { d.originValidation[i] = 'unreachable'
d.originValidation[i] = 'invalid'
}
} }
} catch (e) { } catch (e) {
if (d.origins[i] === origin) { if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid' d.originValidation[i] = 'unreachable'
} }
} }
} }
@@ -114,8 +151,9 @@ function validateOrigin(origin, i) {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return if (!d) return
const id = rpId.value // Related origins on unrelated domains are allowed (WebAuthn ROR), so any
if (validateOriginDomain(origin, id)) { // well-formed origin passes; connectivity is checked as a hint only.
if (originHostname(origin)) {
validateOriginConnectivity(origin, i) validateOriginConnectivity(origin, i)
} else { } else {
d.originValidation[i] = 'invalid' d.originValidation[i] = 'invalid'
@@ -134,22 +172,16 @@ async function validateAuthHostConnectivity(authHost) {
method: 'GET', method: 'GET',
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}) })
if (d.auth_host !== authHost) return // auth_host changed while validating
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id) d.authHostValidation = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the auth_host hasn't changed
if (d.auth_host === authHost) {
d.authHostValidation = result
}
} else { } else {
if (d.auth_host === authHost) { d.authHostValidation = 'unreachable'
d.authHostValidation = 'invalid-connectivity'
}
} }
} catch (e) { } catch (e) {
if (d.auth_host === authHost) { if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity' d.authHostValidation = 'unreachable'
} }
} }
} }
@@ -157,12 +189,11 @@ async function validateAuthHostConnectivity(authHost) {
function validateAuthHost() { function validateAuthHost() {
const d = props.dialog?.data const d = props.dialog?.data
if (!d || !d.auth_host?.trim()) { if (!d || !d.auth_host?.trim()) {
d.authHostValidation = null // Allow empty if (d) d.authHostValidation = null // Allow empty
return return
} }
const id = rpId.value if (isWithinDomain(d.auth_host, realmRpId.value)) {
if (validateOriginDomain(d.auth_host, id)) {
validateAuthHostConnectivity(d.auth_host) validateAuthHostConnectivity(d.auth_host)
} else { } else {
d.authHostValidation = 'invalid-domain' d.authHostValidation = 'invalid-domain'
@@ -181,7 +212,7 @@ function validateAuthHost() {
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</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==='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==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template> <template v-else-if="dialog.type==='realm-edit'">{{ dialog.data?.isNew ? 'Add Realm' : 'Edit Realm' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template> <template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3> </h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form"> <form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -239,21 +270,28 @@ function validateAuthHost() {
<label>Domain Scope <label>Domain Scope
<input v-model="dialog.data.domain" data-form-type="other" /> <input v-model="dialog.data.domain" data-form-type="other" />
</label> </label>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p> <p class="small muted">A domain restricts this permission to that host (any configured realm's rp-id or a subdomain of it). An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template> </template>
<template v-else-if="dialog.type==='server-config'"> <template v-else-if="dialog.type==='realm-edit'">
<label>Site Branding (rp-name) <template v-if="dialog.data.isNew">
<input v-model="dialog.data.rp_name" :placeholder="rpId" /> <label>RP ID (domain)
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
</label>
<p class="small muted">The domain passkeys are registered for. Cannot be changed later.</p>
</template>
<p v-else class="small muted">Realm: <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> </label>
<label>Dedicated Authentication Site (auth-host) <label>Dedicated Authentication Site (auth-host)
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" /> <input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation === 'invalid-domain' }" />
</label> </label>
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p> <p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p> <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">Invalid domain</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 === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</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 === 'invalid'" class="small muted">Invalid configuration</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 === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p> <p v-else class="small muted">Optional. Leave empty to serve authentication on {{ dialog.data.rp_id }} itself.</p>
<div class="origin-label"> <div class="origin-label">
Allowed Origins Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button> <button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
@@ -269,9 +307,18 @@ function validateAuthHost() {
/> />
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button> <button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
</div> </div>
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some origins are unreachable — make sure they are routed to this instance, or host them externally.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some origins are reachable but do not serve this realm.</p>
</div> </div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p> <p v-if="!dialog.data.origins.length" class="small muted">{{ dialog.data.rp_id }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p> <p v-else class="small muted">Only the above sites are allowed to authenticate. Origins on unrelated domains count as related origins (max 5 per realm).</p>
<template v-if="relatedOriginCount > 0">
<p class="small muted">
Related origins require the rp-id domain to list them at
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
— this instance serves it automatically; copy it there if the main site is hosted elsewhere.
</p>
</template>
</template> </template>
<template v-else-if="dialog.type==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
+48 -8
View File
@@ -8,11 +8,12 @@ const props = defineProps({
orgs: Array, orgs: Array,
permissions: Array, permissions: Array,
oidcClients: Array, oidcClients: Array,
realms: Array,
permissionSummary: Object, permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createRealm', 'openRealm', 'deleteRealm', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgActionsRef = ref(null) const orgActionsRef = ref(null)
@@ -425,14 +426,50 @@ defineExpose({ focusFirstElement })
</table> </table>
</div> </div>
<div v-if="isMasterAdmin" class="server-options-section"> <div v-if="isMasterAdmin" class="realms-section">
<div class="section-header"> <div class="section-header">
<h2>Server</h2> <h2>Realms</h2>
<p class="section-description"> <p class="section-description">
Configure core server settings such as the display name, authentication host, and allowed origins. Each realm is one passkey rp-id with its own display name, optional dedicated auth host, and allowed origins (including Related Origin Requests origins on unrelated domains). Changes apply immediately.
</p> </p>
</div> </div>
<button @click="$emit('openServerConfig')"> Server Options</button> <div class="section-actions">
<button @click="$emit('createRealm')">+ Add Realm</button>
</div>
<table class="org-table">
<thead>
<tr>
<th>Realm</th>
<th>Auth Host</th>
<th class="center">Origins</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-if="!realms || realms.length === 0">
<td colspan="4" class="center muted">No realms configured</td>
</tr>
<tr v-for="realm in realms" :key="realm.rp_id">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#realm:' + realm.rp_id" @click.prevent="$emit('openRealm', realm)">{{ realm.rp_name || realm.rp_id }}</a>
<span v-if="realm.is_default" class="badge badge-current">Default</span>
</div>
<div class="perm-id-info">
<span class="id-text">{{ realm.rp_id }}</span>
</div>
</td>
<td class="realm-auth-host">
<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 || 0 }}</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>
</td>
</tr>
</tbody>
</table>
</div> </div>
</template> </template>
@@ -459,7 +496,10 @@ defineExpose({ focusFirstElement })
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); } .oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); } .client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
/* Server Options Section */ /* Realms Section */
.server-options-section { margin-top: var(--space-2xl); } .realms-section { margin-top: var(--space-2xl); }
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); } .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; }
.realms-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
</style> </style>
+22
View File
@@ -804,6 +804,28 @@ th {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.badge-realm {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.75rem;
}
.realm-enroll-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-surface-subtle);
}
.realm-enroll-notice p {
margin: 0;
}
.session-meta-info { .session-meta-info {
font-size: 0.75rem; font-size: 0.75rem;
@@ -32,6 +32,7 @@
</div> </div>
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4> <h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
<div class="item-actions"> <div class="item-actions">
<span v-if="credential.rp_id && settings?.rp_id && credential.rp_id !== settings.rp_id" class="badge badge-realm" :title="`Passkey registered for ${credential.rp_id}`">{{ credential.rp_id }}</span>
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span> <span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span> <span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span> <span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
@@ -61,8 +62,13 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav' import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
import { getSettings } from '@/utils/settings'
const settings = ref(null)
onMounted(async () => { settings.value = await getSettings() })
const props = defineProps({ const props = defineProps({
credentials: { type: Array, default: () => [] }, credentials: { type: Array, default: () => [] },
+9
View File
@@ -54,6 +54,10 @@
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p> <p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
</div> </div>
<div class="section-body"> <div class="section-body">
<div v-if="missingRealmPasskey" class="realm-enroll-notice">
<p>You don't have a passkey for <strong>{{ rpName }}</strong> ({{ authStore.settings.rp_id }}) yet. Add one to log in here directly.</p>
<button @click="addNewCredential" class="btn-primary">Add Passkey for {{ authStore.settings.rp_id }}</button>
</div>
<CredentialList <CredentialList
ref="credentialList" ref="credentialList"
:credentials="credentials" :credentials="credentials"
@@ -410,6 +414,11 @@ const hasMultipleSessions = computed(() => Object.keys(sessions.value).length >
const credentials = computed(() => const credentials = computed(() =>
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid })) Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
) )
const missingRealmPasskey = computed(() => {
const rpId = authStore.settings?.rp_id
if (!rpId) return false
return !credentials.value.some(c => c.rp_id === rpId)
})
const useWideLayout = computed(() => { const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions // Check if any single site has more than 8 sessions
const groups = {} const groups = {}
+17 -1
View File
@@ -53,6 +53,7 @@
<!-- Device info display (shown when 3 words match a request) --> <!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info"> <div v-else-if="deviceInfo" class="device-info">
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p> <p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p v-if="crossRealmNotice" class="device-meta realm-notice">on <strong>{{ deviceInfo.rp_name || deviceInfo.rp_id }}</strong><template v-if="deviceInfo.rp_name"> ({{ deviceInfo.rp_id }})</template></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p> <p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
<p v-if="error" class="error-message">{{ error }}</p> <p v-if="error" class="error-message">{{ error }}</p>
@@ -122,6 +123,13 @@ watch(deviceInfo, (newVal) => {
emit('deviceInfoVisible', !!newVal) emit('deviceInfoVisible', !!newVal)
}) })
const crossRealmNotice = computed(() => {
const info = deviceInfo.value
if (!info?.rp_id) return false
const ownRpId = settings.value?.rp_id
return ownRpId ? info.rp_id !== ownRpId : true
})
const hasInvalidWord = ref(false) const hasInvalidWord = ref(false)
const serverError = ref(false) const serverError = ref(false)
const cursorPos = ref(0) const cursorPos = ref(0)
@@ -613,7 +621,9 @@ async function lookupDeviceInfo() {
host: res.host, host: res.host,
user_agent_pretty: res.user_agent_pretty, user_agent_pretty: res.user_agent_pretty,
client_ip: res.client_ip, client_ip: res.client_ip,
action: res.action || 'login' action: res.action || 'login',
rp_id: res.rp_id || null,
rp_name: res.rp_name || null
} }
lastLookedUpCode = currentCode lastLookedUpCode = currentCode
nextTick(() => { submitBtnRef.value?.focus() }) nextTick(() => { submitBtnRef.value?.focus() })
@@ -937,6 +947,12 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
} }
.realm-notice {
color: var(--color-text);
font-family: inherit;
font-size: 0.9rem;
}
.error-message { .error-message {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
font-size: 0.875rem; font-size: 0.875rem;
+2 -2
View File
@@ -83,8 +83,8 @@ export const useAuthStore = defineStore('auth', {
if (!this.userInfo) this.currentView = 'login' if (!this.userInfo) this.currentView = 'login'
else this.currentView = 'profile' else this.currentView = 'profile'
}, },
async loadSettings() { async loadSettings(force = false) {
this.settings = await getSettings() this.settings = await getSettings(force)
}, },
async loadUserInfo() { async loadUserInfo() {
try { try {
+2 -1
View File
@@ -3,7 +3,8 @@ let _settings = null
export function getSettingsCached() { return _settings } export function getSettingsCached() { return _settings }
export async function getSettings() { export async function getSettings(force = false) {
if (force) { _settings = null; _settingsPromise = null }
if (_settings) return _settings if (_settings) return _settings
if (_settingsPromise) return _settingsPromise if (_settingsPromise) return _settingsPromise
_settingsPromise = fetch('/auth/api/settings') _settingsPromise = fetch('/auth/api/settings')