Frontend: domain terminology, object-keyed origins, 🔑 auth host in table, display-time ordering
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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.<base> entry
|
||||
entry = 'auth.' + entry.slice(2)
|
||||
if (!d.origins.some(o => hostWithPort(o) === entry)) {
|
||||
d.origins.push(entry)
|
||||
key = 'auth.' + key.slice(2)
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins.length - 1)
|
||||
}
|
||||
}
|
||||
d.auth_host = hostWithPort(entry) || entry
|
||||
d.auth_host = key
|
||||
openMenu.value = null
|
||||
}
|
||||
|
||||
@@ -288,7 +287,7 @@ function onRemoveOrigin(i) {
|
||||
<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 Domain' : 'Edit Domain' }}</template>
|
||||
<template v-else-if="dialog.type==='domain-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">
|
||||
@@ -346,9 +345,9 @@ function onRemoveOrigin(i) {
|
||||
<label>Domain Scope
|
||||
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||
</label>
|
||||
<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>
|
||||
<p class="small muted">A domain restricts this permission to that host (any configured domain'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==='realm-edit'">
|
||||
<template v-else-if="dialog.type==='domain-edit'">
|
||||
<template v-if="dialog.data.isNew">
|
||||
<label>Domain (rp-id)
|
||||
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { formatDate, originDisplayEntries } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
info: Object,
|
||||
orgs: Array,
|
||||
permissions: Array,
|
||||
oidcClients: Array,
|
||||
realms: Array,
|
||||
domains: Array,
|
||||
currentRpId: { type: String, default: '' },
|
||||
permissionSummary: Object,
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createRealm', 'openRealm', 'deleteRealm', 'navigateOut'])
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgActionsRef = ref(null)
|
||||
@@ -38,17 +39,11 @@ 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) {
|
||||
if (origin.startsWith('*.')) return 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)]
|
||||
}
|
||||
// Domains display in alphabetical rp-id order — the stored configuration
|
||||
// is an unordered object.
|
||||
const sortedDomains = computed(() =>
|
||||
[...(props.domains || [])].sort((a, b) => a.rp_id.localeCompare(b.rp_id))
|
||||
)
|
||||
|
||||
// Map OIDC client UUIDs to their group permissions (sorted by scope)
|
||||
const clientGroups = computed(() => {
|
||||
@@ -438,7 +433,7 @@ defineExpose({ focusFirstElement })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="isMasterAdmin" class="realms-section">
|
||||
<div v-if="isMasterAdmin" class="domains-section">
|
||||
<div class="section-header">
|
||||
<h2>Domains</h2>
|
||||
<p class="section-description">
|
||||
@@ -446,38 +441,32 @@ defineExpose({ focusFirstElement })
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button @click="$emit('createRealm')">+ Add Domain</button>
|
||||
<button @click="$emit('createDomain')">+ Add Domain</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Auth Host</th>
|
||||
<th>Allowed Origins</th>
|
||||
<th class="center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!realms || realms.length === 0">
|
||||
<td colspan="4" class="center muted">No domains configured</td>
|
||||
<tr v-if="!domains || domains.length === 0">
|
||||
<td colspan="3" class="center muted">No domains configured</td>
|
||||
</tr>
|
||||
<tr v-for="realm in realms" :key="realm.rp_id">
|
||||
<tr v-for="domain in sortedDomains" :key="domain.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>
|
||||
<a :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<span class="id-text">{{ realm.rp_id }}</span>
|
||||
<span class="id-text">{{ domain.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="realm-origins">{{ allowedOrigins(realm).join(', ') }}</td>
|
||||
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? ' 🔑' : '' }}</span></td>
|
||||
<td class="center">
|
||||
<button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
|
||||
<button v-if="domain.rp_id !== currentRpId" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -508,10 +497,9 @@ 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); }
|
||||
|
||||
/* 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); }
|
||||
.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; }
|
||||
/* Domains Section */
|
||||
.domains-section { margin-top: var(--space-2xl); }
|
||||
.domains-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
.domain-origins { font-family: var(--font-mono, monospace); font-size: 0.85rem; }
|
||||
.domains-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -32,7 +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.rp_id && settings?.rp_id && credential.rp_id !== settings.rp_id" class="badge badge-domain" :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>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<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">
|
||||
<div v-if="missingDomainPasskey" class="domain-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>
|
||||
@@ -414,7 +414,7 @@ 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 missingDomainPasskey = computed(() => {
|
||||
const rpId = authStore.settings?.rp_id
|
||||
if (!rpId) return false
|
||||
return !credentials.value.some(c => c.rp_id === rpId)
|
||||
|
||||
@@ -53,7 +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 v-if="crossDomainNotice" class="device-meta domain-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>
|
||||
@@ -123,7 +123,7 @@ watch(deviceInfo, (newVal) => {
|
||||
emit('deviceInfoVisible', !!newVal)
|
||||
})
|
||||
|
||||
const crossRealmNotice = computed(() => {
|
||||
const crossDomainNotice = computed(() => {
|
||||
const info = deviceInfo.value
|
||||
if (!info?.rp_id) return false
|
||||
const ownRpId = settings.value?.rp_id
|
||||
@@ -947,7 +947,7 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.realm-notice {
|
||||
.domain-notice {
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -41,3 +41,26 @@ export const hostIP = ip => {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// Display-time ordering of a domain's configured origins (the stored
|
||||
// objects are unordered): the auth host first (flagged), then in-domain
|
||||
// entries (exact rp-id, then alphabetical), then related domains
|
||||
// alphabetically. An empty origins object shows as the '*.rp_id' default.
|
||||
export function originDisplayEntries(domain) {
|
||||
const origins = domain.origins || {}
|
||||
const keys = Object.keys(origins)
|
||||
const authKey = keys.find(k => origins[k] !== true && origins[k]?.auth_host)
|
||||
const inDomain = keys.filter(k => k !== authKey).sort((a, b) => {
|
||||
if (a === domain.rp_id) return -1
|
||||
if (b === domain.rp_id) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const rows = []
|
||||
if (authKey) rows.push({ key: authKey, auth: true })
|
||||
for (const k of inDomain) rows.push({ key: k, auth: false })
|
||||
if (!keys.length) rows.push({ key: '*.' + domain.rp_id, auth: false })
|
||||
for (const k of Object.keys(domain.related || {}).sort()) {
|
||||
rows.push({ key: k, auth: false, related: true })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user