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:
@@ -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.
|
||||
* 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 authHost = store.settings?.auth_host
|
||||
const authHost = store.settings?.own_auth_host
|
||||
if (!authHost) return false
|
||||
const currentHost = normalizeHost(window.location.host)
|
||||
const configuredHost = normalizeHost(authHost)
|
||||
@@ -99,7 +101,7 @@ onMounted(async () => {
|
||||
if (rpName) {
|
||||
// In host mode, show "account summary" style title
|
||||
// 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)
|
||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const oidcClients = ref([])
|
||||
const realms = 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
|
||||
@@ -174,6 +175,16 @@ 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() {
|
||||
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]
|
||||
function roleUsers(org, roleUuid) {
|
||||
return Object.entries(org.users)
|
||||
@@ -207,6 +218,7 @@ function clearSensitiveState() {
|
||||
orgs.value = []
|
||||
permissions.value = []
|
||||
oidcClients.value = []
|
||||
realms.value = []
|
||||
userDetail.value = null
|
||||
editingOidcClient.value = null
|
||||
authenticated.value = false
|
||||
@@ -236,6 +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 && isOrgAdmin.value && orgs.value.length === 1) {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
@@ -462,21 +475,41 @@ function createPermissionForClient(clientId) {
|
||||
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
||||
}
|
||||
|
||||
async function openServerConfig() {
|
||||
try {
|
||||
const config = await apiJson('/auth/api/admin/server-config')
|
||||
function createRealm() {
|
||||
openDialog('realm-edit', {
|
||||
isNew: true,
|
||||
rp_id: '',
|
||||
rp_name: '',
|
||||
auth_host: '',
|
||||
origins: [],
|
||||
originValidation: [],
|
||||
authHostValidation: null,
|
||||
})
|
||||
}
|
||||
|
||||
function openRealm(realm) {
|
||||
// Strip https:// scheme from stored origins and auth_host for editing
|
||||
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
||||
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
|
||||
openDialog('server-config', {
|
||||
rp_name: config.rp_name || '',
|
||||
auth_host,
|
||||
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,
|
||||
})
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -900,25 +933,32 @@ async function submitDialog() {
|
||||
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'server-config') {
|
||||
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
||||
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
||||
} else if (t === 'realm-edit') {
|
||||
const d = dialog.value.data
|
||||
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://
|
||||
const origins = dialog.value.data.origins
|
||||
const origins = (d.origins || [])
|
||||
.map(o => o.trim())
|
||||
.filter(o => o)
|
||||
|
||||
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(() => {
|
||||
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
|
||||
authStore.loadSettings().then(() => {
|
||||
authStore.loadSettings(true).then(() => {
|
||||
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||
})
|
||||
})
|
||||
.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
|
||||
} else if (t === 'confirm') {
|
||||
@@ -973,6 +1013,7 @@ async function submitDialog() {
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:oidc-clients="oidcClients"
|
||||
:realms="realms"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@@ -986,7 +1027,9 @@ async function submitDialog() {
|
||||
@create-oidc-client="createOidcClient"
|
||||
@open-oidc-client="openOidcClient"
|
||||
@delete-oidc-client="deleteOidcClient"
|
||||
@open-server-config="openServerConfig"
|
||||
@create-realm="createRealm"
|
||||
@open-realm="openRealm"
|
||||
@delete-realm="deleteRealm"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
@@ -1047,7 +1090,6 @@ async function submitDialog() {
|
||||
<AdminDialogs
|
||||
:dialog="dialog"
|
||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||
:settings="authStore.settings"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
@reset-oidc-secret="resetOidcSecret"
|
||||
|
||||
@@ -6,32 +6,59 @@ import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
PERMISSION_ID_PATTERN: String,
|
||||
settings: Object
|
||||
PERMISSION_ID_PATTERN: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||||
|
||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||
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`)
|
||||
|
||||
// The rp-id of the realm being edited in the 'realm-edit' dialog
|
||||
const realmRpId = computed(() => props.dialog?.data?.rp_id || '')
|
||||
|
||||
// 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)) {
|
||||
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(() => {
|
||||
if (props.dialog?.type !== 'server-config') return false
|
||||
if (props.dialog?.type !== 'realm-edit') return false
|
||||
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 (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||
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
|
||||
const authStore = useAuthStore()
|
||||
function copyText(value, label) {
|
||||
@@ -43,7 +70,7 @@ function copyText(value, label) {
|
||||
function addOrigin() {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
d.origins.push(rpId.value)
|
||||
d.origins.push(realmRpId.value)
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||
}
|
||||
@@ -67,17 +94,32 @@ function focusOriginStart(e) {
|
||||
e.target.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
function validateOriginDomain(origin, rpId) {
|
||||
if (!origin.trim()) return false
|
||||
function isWellFormedDomain(value) {
|
||||
if (!value.trim()) return false
|
||||
try {
|
||||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||
const hostname = url.hostname
|
||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
|
||||
return url.hostname.includes('.') || url.hostname === 'localhost'
|
||||
} catch {
|
||||
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) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
@@ -90,22 +132,17 @@ async function validateOriginConnectivity(origin, i) {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (d.origins[i] !== origin) return // origin changed while validating
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the origin hasn't changed
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = result
|
||||
}
|
||||
// Valid when the origin is served by this instance for the edited realm
|
||||
d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
||||
} else {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
}
|
||||
d.originValidation[i] = 'unreachable'
|
||||
}
|
||||
} catch (e) {
|
||||
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
|
||||
if (!d) return
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(origin, id)) {
|
||||
// Related origins on unrelated domains are allowed (WebAuthn ROR), so any
|
||||
// well-formed origin passes; connectivity is checked as a hint only.
|
||||
if (originHostname(origin)) {
|
||||
validateOriginConnectivity(origin, i)
|
||||
} else {
|
||||
d.originValidation[i] = 'invalid'
|
||||
@@ -134,22 +172,16 @@ async function validateAuthHostConnectivity(authHost) {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (d.auth_host !== authHost) return // auth_host changed while validating
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
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
|
||||
}
|
||||
d.authHostValidation = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
||||
} else {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
d.authHostValidation = 'unreachable'
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
d.authHostValidation = 'unreachable'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,12 +189,11 @@ async function validateAuthHostConnectivity(authHost) {
|
||||
function validateAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (!d || !d.auth_host?.trim()) {
|
||||
d.authHostValidation = null // Allow empty
|
||||
if (d) d.authHostValidation = null // Allow empty
|
||||
return
|
||||
}
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(d.auth_host, id)) {
|
||||
if (isWithinDomain(d.auth_host, realmRpId.value)) {
|
||||
validateAuthHostConnectivity(d.auth_host)
|
||||
} else {
|
||||
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==='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==='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>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -239,21 +270,28 @@ function validateAuthHost() {
|
||||
<label>Domain Scope
|
||||
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||
</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 v-else-if="dialog.type==='server-config'">
|
||||
<label>Site Branding (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
||||
<template v-else-if="dialog.type==='realm-edit'">
|
||||
<template v-if="dialog.data.isNew">
|
||||
<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>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>
|
||||
<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 === 'invalid-domain'" class="small muted">Invalid domain</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 === 'invalid'" class="small muted">Invalid configuration</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</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 class="small muted">Optional. Leave empty to serve authentication on {{ dialog.data.rp_id }} itself.</p>
|
||||
<div class="origin-label">
|
||||
Allowed Origins
|
||||
<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>
|
||||
</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>
|
||||
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
||||
<p v-else class="small muted">Only the above sites are allowed to authenticate.</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. 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 v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
|
||||
@@ -8,11 +8,12 @@ const props = defineProps({
|
||||
orgs: Array,
|
||||
permissions: Array,
|
||||
oidcClients: Array,
|
||||
realms: Array,
|
||||
permissionSummary: Object,
|
||||
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
|
||||
const orgActionsRef = ref(null)
|
||||
@@ -425,14 +426,50 @@ defineExpose({ focusFirstElement })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="isMasterAdmin" class="server-options-section">
|
||||
<div v-if="isMasterAdmin" class="realms-section">
|
||||
<div class="section-header">
|
||||
<h2>Server</h2>
|
||||
<h2>Realms</h2>
|
||||
<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>
|
||||
</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>
|
||||
</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); }
|
||||
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||
|
||||
/* Server Options Section */
|
||||
.server-options-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 */
|
||||
.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; }
|
||||
.realms-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -804,6 +804,28 @@ th {
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</div>
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<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-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||
@@ -61,8 +62,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
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({
|
||||
credentials: { type: Array, default: () => [] },
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<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
|
||||
ref="credentialList"
|
||||
:credentials="credentials"
|
||||
@@ -410,6 +414,11 @@ const hasMultipleSessions = computed(() => Object.keys(sessions.value).length >
|
||||
const credentials = computed(() =>
|
||||
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(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
const groups = {}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<!-- Device info display (shown when 3 words match a request) -->
|
||||
<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 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 v-if="error" class="error-message">{{ error }}</p>
|
||||
@@ -122,6 +123,13 @@ watch(deviceInfo, (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 serverError = ref(false)
|
||||
const cursorPos = ref(0)
|
||||
@@ -613,7 +621,9 @@ async function lookupDeviceInfo() {
|
||||
host: res.host,
|
||||
user_agent_pretty: res.user_agent_pretty,
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
.realm-notice {
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -83,8 +83,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else this.currentView = 'profile'
|
||||
},
|
||||
async loadSettings() {
|
||||
this.settings = await getSettings()
|
||||
async loadSettings(force = false) {
|
||||
this.settings = await getSettings(force)
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,8 @@ let _settings = null
|
||||
|
||||
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 (_settingsPromise) return _settingsPromise
|
||||
_settingsPromise = fetch('/auth/api/settings')
|
||||
|
||||
Reference in New Issue
Block a user