- Domain dialog submits one origins map (in-domain + related together);
classification is derived, the submit-time split is gone
- Placeholder-row machinery deleted: an empty list now means 'nothing
allowed'; new domains get a real pre-filled '*.{rp-id}' row that
follows rp-id edits until touched
- Plain '*' is invalid; wildcards only within the domain
- Editing the domain in use: when no auth host is marked and the admin's
current page origin would no longer be allowed to run ceremonies, Save
is disabled with an explanatory error (mirrors the backend guard)
- Origin list display: single table with derived related badges; '*'
sort special case removed
567 lines
25 KiB
Vue
567 lines
25 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import Modal from '@/components/Modal.vue'
|
||
import NameEditForm from '@/components/NameEditForm.vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { compareOrigins } from '@/utils/helpers'
|
||
|
||
const props = defineProps({
|
||
dialog: Object,
|
||
PERMISSION_ID_PATTERN: String
|
||
})
|
||
|
||
defineEmits(['submitDialog', 'closeDialog'])
|
||
|
||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||
|
||
// The rp-id of the domain being edited in the 'domain-edit' dialog
|
||
// (lowercased: classification compares against it, and hosts are
|
||
// case-insensitive)
|
||
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
|
||
|
||
// Block submit on hard errors: malformed entries, an over-cap related
|
||
// list (the server rejects the save), a save that would lock the admin
|
||
// out of the domain they are using, or validation still in flight.
|
||
// Connectivity and rp-id mismatch results are warnings only (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 !== 'domain-edit') return false
|
||
const d = props.dialog.data
|
||
const bad = v => v === 'invalid' || v === 'validating'
|
||
if (d.originValidation?.some(bad)) return true
|
||
if (relatedEntries.value.length > 5) return true
|
||
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||
if (lockoutWarning.value) return true
|
||
return false
|
||
})
|
||
|
||
// A single origins list holds two kinds of entries: sites on the rp-id
|
||
// domain form the in-domain sign-in allow-list; entries on other domain
|
||
// names are related origins (WebAuthn ROR). Classification is automatic
|
||
// from the hostname. A plain '*' is invalid (wildcards must sit under
|
||
// the rp-id) and never a related origin.
|
||
function isRelatedEntry(origin) {
|
||
if (origin.trim().startsWith('*.')) return false // wildcards are never related
|
||
const h = originHostname(origin)
|
||
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
|
||
}
|
||
const relatedEntries = computed(() => {
|
||
const d = props.dialog?.data
|
||
if (!d?.origins) return []
|
||
return d.origins.filter(isRelatedEntry)
|
||
})
|
||
|
||
// Well-known document browsers fetch from the rp-id domain to verify the
|
||
// related-origin list (never from the auth host).
|
||
const wellKnownUrl = computed(() => {
|
||
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||
return host ? `https://${host}/.well-known/webauthn` : ''
|
||
})
|
||
|
||
// ROR origins must be absolute https URLs in the well-known document.
|
||
function asHttpsOrigin(origin) {
|
||
const o = origin.trim().replace(/\/+$/, '')
|
||
return o.startsWith('http') ? o : `https://${o}`
|
||
}
|
||
const wellKnownJson = computed(() =>
|
||
JSON.stringify({ origins: relatedEntries.value.map(asHttpsOrigin) })
|
||
)
|
||
|
||
// Copy-to-clipboard helper
|
||
const authStore = useAuthStore()
|
||
function copyText(value, label) {
|
||
navigator.clipboard.writeText(value).then(() => {
|
||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||
})
|
||
}
|
||
|
||
// --- Lockout prevention (editing the domain in use) ---
|
||
|
||
// When the admin edits the domain they are currently signed in on and no
|
||
// auth host is marked (with one, ceremonies move there and saving is
|
||
// always allowed), their current page origin must stay allowed to run
|
||
// passkey ceremonies — otherwise saving locks them out. Mirrors the
|
||
// backend check (Passkey.validate_origin): an in-domain origin matches a
|
||
// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the
|
||
// base domain and its subdomains over https — any scheme and port under
|
||
// localhost); a related row matches only on exact equality (https://host).
|
||
const lockoutWarning = computed(() => {
|
||
const d = props.dialog?.data
|
||
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
|
||
const rpId = dialogRpId.value
|
||
if (!rpId || rpId !== authStore.settings?.rp_id) return null
|
||
return pageOriginAllowed(d.origins || [], rpId) ? null : window.location.host
|
||
})
|
||
|
||
function pageOriginAllowed(rows, rpId) {
|
||
const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key
|
||
const inDomain = []
|
||
const related = []
|
||
for (const row of rows) {
|
||
if (!originHostname(row)) continue
|
||
const key = entryKey(row).toLowerCase()
|
||
if (!key) continue
|
||
const bucket = isRelatedEntry(row) ? related : inDomain
|
||
bucket.push(toUrl(key))
|
||
}
|
||
const probe = origin => {
|
||
let hostname
|
||
try { hostname = new URL(origin).hostname } catch { return false }
|
||
if (hostname === rpId || hostname.endsWith('.' + rpId)) {
|
||
if (inDomain.includes(origin)) return true
|
||
return inDomain.some(e => {
|
||
if (!e.startsWith('*.')) return false
|
||
const base = e.slice(2).replace(/\.+$/, '')
|
||
if (hostname !== base && !hostname.endsWith('.' + base)) return false
|
||
// Under localhost a wildcard matches any scheme and port
|
||
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://')
|
||
})
|
||
}
|
||
return related.includes(origin)
|
||
}
|
||
// The page scheme may be http (e.g. on localhost) — probe both
|
||
return probe(`https://${window.location.host}`) || probe(`http://${window.location.host}`)
|
||
}
|
||
|
||
function addOrigin() {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
// Prefill with the origin the admin is currently on when editing that
|
||
// very domain (so saving never locks them out), else with the rp-id
|
||
// (https default).
|
||
const onThisDomain = authStore.settings?.rp_id && dialogRpId.value === authStore.settings.rp_id
|
||
d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value)
|
||
d.originValidation.push(null)
|
||
validateOrigin(d.origins.length - 1)
|
||
}
|
||
function removeOrigin(i) {
|
||
const d = props.dialog?.data
|
||
if (d) {
|
||
d.origins.splice(i, 1)
|
||
d.originValidation.splice(i, 1)
|
||
}
|
||
}
|
||
function focusOriginStart(e) {
|
||
e.target.setSelectionRange(0, 0)
|
||
}
|
||
|
||
function isWellFormedDomain(value) {
|
||
if (!value.trim()) return false
|
||
try {
|
||
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
|
||
// Any DNS label sequence (matching backend validate_rp_id): labels of
|
||
// 1-63 alnum/hyphen chars, no leading/trailing hyphen, dot-separated
|
||
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(url.hostname)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function originHostname(origin) {
|
||
const v = origin.trim()
|
||
if (!v || v === '*') return null // a plain '*' is not a valid entry
|
||
if (v.startsWith('*.')) {
|
||
const base = v.slice(2).replace(/\.+$/, '').toLowerCase()
|
||
return isWellFormedDomain(base) ? base : null
|
||
}
|
||
try {
|
||
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
|
||
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(i) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const value = d.origins[i]
|
||
|
||
d.originValidation[i] = 'validating'
|
||
try {
|
||
const cleanValue = value.replace(/\/+$/, '')
|
||
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
|
||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||
method: 'GET',
|
||
headers: { 'Accept': 'application/json' }
|
||
})
|
||
if (d.origins[i] !== value) return // entry changed while validating
|
||
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 === dialogRpId.value) ? 'valid' : 'mismatch'
|
||
} else {
|
||
d.originValidation[i] = 'unreachable'
|
||
}
|
||
} catch (e) {
|
||
if (d.origins[i] === value) {
|
||
d.originValidation[i] = 'unreachable'
|
||
}
|
||
}
|
||
}
|
||
|
||
// A sole '*' expands to '*.<rp-id>' immediately, keeping the cursor where
|
||
// it was (before the inserted rp-id).
|
||
function onOriginInput(i, e) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const el = e.target
|
||
const oldKey = entryKey(d.origins[i])
|
||
let value = el.value
|
||
if (value === '*' && dialogRpId.value) {
|
||
const pos = el.selectionStart
|
||
value = '*.' + dialogRpId.value
|
||
el.value = value
|
||
el.setSelectionRange(pos, pos)
|
||
}
|
||
d.origins[i] = value
|
||
// Keep the auth-host mark on a renamed entry, unless it no longer
|
||
// qualifies (wildcards and related origins cannot be the auth host)
|
||
if (d.auth_host && oldKey === d.auth_host) {
|
||
const key = entryKey(value)
|
||
d.auth_host = key && !key.startsWith('*') && !isRelatedEntry(value) ? key : ''
|
||
}
|
||
validateOrigin(i)
|
||
}
|
||
|
||
function validateOrigin(i) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const value = d.origins[i]
|
||
if (!originHostname(value)) {
|
||
d.originValidation[i] = 'invalid'
|
||
return
|
||
}
|
||
if (value.trim().startsWith('*.')) {
|
||
// Wildcards have no concrete site to probe, and are only allowed
|
||
// within the domain (related origins are individual hosts)
|
||
d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid'
|
||
return
|
||
}
|
||
validateOriginConnectivity(i)
|
||
}
|
||
|
||
// Fetch the well-known document and check it lists every related origin.
|
||
// Runs automatically whenever the related set changes; result is a
|
||
// warning only, never a submit blocker (the rp-id site may be hosted
|
||
// elsewhere, and cross-origin fetches can fail for unrelated reasons).
|
||
async function testWellKnown() {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const related = relatedEntries.value.map(asHttpsOrigin)
|
||
if (!related.length) {
|
||
d.wellKnownCheck = null
|
||
return
|
||
}
|
||
const key = related.join('|')
|
||
d.wellKnownCheck = 'validating'
|
||
try {
|
||
const response = await fetch(wellKnownUrl.value, { headers: { 'Accept': 'application/json' } })
|
||
if (!response.ok) throw new Error('not ok')
|
||
const doc = await response.json()
|
||
if (related.join('|') !== key) return // list changed while fetching
|
||
const listed = new Set((doc.origins || []).map(o => String(o).replace(/\/+$/, '')))
|
||
const missing = related.filter(o => !listed.has(o))
|
||
d.wellKnownCheck = missing.length ? 'missing' : 'valid'
|
||
d.wellKnownMissing = missing
|
||
} catch {
|
||
if (related.join('|') === key) d.wellKnownCheck = 'unreachable'
|
||
}
|
||
}
|
||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||
|
||
// Prefill a new domain's list with the real '*.<rp-id>' row once its
|
||
// rp-id is known ('*.x' = the domain and all its subdomains over https,
|
||
// any scheme and port under localhost). The row follows rp-id edits while
|
||
// it is still the untouched prefilled row; once the admin edits it, it is
|
||
// left alone. Seeding waits for a complete-looking rp-id (letters after
|
||
// the final dot) so mid-typing states like 'something.' don't prefill a
|
||
// broken '*.something'.
|
||
function looksCompleteDomain(value) {
|
||
const host = (value || '').trim().replace(/\.$/, '')
|
||
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
||
}
|
||
// Tracks the prefilled row so rp-id edits can keep updating it.
|
||
let seededOrigin = null
|
||
watch(dialogRpId, rp => {
|
||
const d = props.dialog?.data
|
||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||
const seed = '*.' + rp.trim().replace(/\.$/, '')
|
||
if (!d.origins.length) {
|
||
d.origins.push(seed)
|
||
d.originValidation.push(null)
|
||
seededOrigin = seed
|
||
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
|
||
d.origins[0] = seed
|
||
seededOrigin = seed
|
||
}
|
||
})
|
||
|
||
// --- Row menu: auth host assignment and entry removal ---
|
||
|
||
const openMenu = ref(null)
|
||
|
||
// The component stays mounted across dialogs; a closed dialog (including
|
||
// Escape in Modal) must not leave a row menu open
|
||
watch(() => props.dialog?.type, () => { openMenu.value = null })
|
||
|
||
// Close the popup on any click outside it (the toggle button stops
|
||
// propagation, so it never reaches this listener).
|
||
function onDocumentClick(e) {
|
||
if (openMenu.value !== null && !e.target.closest('.row-menu')) openMenu.value = null
|
||
}
|
||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
||
|
||
// 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 key = entryKey(origin)
|
||
return !!(key && d?.auth_host && key === d.auth_host)
|
||
}
|
||
|
||
function setAuthHost(i) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
let key = entryKey(d.origins[i])
|
||
let added = false
|
||
if (key.startsWith('*.')) {
|
||
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||
key = 'auth.' + key.slice(2)
|
||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||
d.origins.push(key)
|
||
d.originValidation.push(null)
|
||
added = true
|
||
}
|
||
}
|
||
d.auth_host = key
|
||
openMenu.value = null
|
||
resortOrigins()
|
||
if (added) validateOrigin(d.origins.findIndex(o => entryKey(o) === key))
|
||
}
|
||
|
||
function clearAuthHost() {
|
||
const d = props.dialog?.data
|
||
if (d) d.auth_host = ''
|
||
openMenu.value = null
|
||
resortOrigins()
|
||
}
|
||
|
||
// Display order, applied after row-menu actions (never while typing in an
|
||
// input, to avoid focus loss): auth host first, then the rp-id, then
|
||
// in-domain entries hierarchically, then related origins.
|
||
function resortOrigins() {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2
|
||
const pairs = d.origins.map((o, i) => [o, d.originValidation[i]])
|
||
pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0]))
|
||
d.origins = pairs.map(p => p[0])
|
||
d.originValidation = pairs.map(p => p[1])
|
||
}
|
||
|
||
function onRemoveOrigin(i) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
||
removeOrigin(i)
|
||
openMenu.value = null
|
||
resortOrigins()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||
<h3 class="modal-title">
|
||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||
<template v-else-if="dialog.type==='role-create'">Create Role</template>
|
||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||
<template v-else-if="dialog.type==='user-create'">Add User To Role</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==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : `Edit Domain: ${dialog.data?.rp_id}` }}</template>
|
||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||
</h3>
|
||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||
<template v-if="dialog.type==='org-create'">
|
||
<label>Name
|
||
<input ref="nameInput" v-model="dialog.data.name" required />
|
||
</label>
|
||
</template>
|
||
<template v-else-if="dialog.type==='org-update'">
|
||
<NameEditForm
|
||
label="Organization Name"
|
||
v-model="dialog.data.name"
|
||
:busy="dialog.busy"
|
||
:error="dialog.error"
|
||
@cancel="$emit('closeDialog')"
|
||
/>
|
||
</template>
|
||
<template v-else-if="dialog.type==='role-create'">
|
||
<label>Role Name
|
||
<input v-model="dialog.data.name" placeholder="Role name" required />
|
||
</label>
|
||
</template>
|
||
<template v-else-if="dialog.type==='role-update'">
|
||
<NameEditForm
|
||
label="Role Name"
|
||
v-model="dialog.data.name"
|
||
:busy="dialog.busy"
|
||
:error="dialog.error"
|
||
@cancel="$emit('closeDialog')"
|
||
/>
|
||
</template>
|
||
<template v-else-if="dialog.type==='user-create'">
|
||
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
|
||
<label>Display Name
|
||
<input v-model="dialog.data.name" placeholder="User display name" required />
|
||
</label>
|
||
</template>
|
||
<template v-else-if="dialog.type==='user-update-name'">
|
||
<NameEditForm
|
||
label="Display Name"
|
||
v-model="dialog.data.name"
|
||
:busy="dialog.busy"
|
||
:error="dialog.error"
|
||
@cancel="$emit('closeDialog')"
|
||
/>
|
||
</template>
|
||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">
|
||
<label>Display Name
|
||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||
</label>
|
||
<label>Permission Scope
|
||
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||
</label>
|
||
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
||
<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 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==='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 />
|
||
</label>
|
||
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and related domains. Cannot be changed later.</p>
|
||
</template>
|
||
<label>Display Name (rp-name)
|
||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||
</label>
|
||
|
||
<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>
|
||
</div>
|
||
<div v-if="dialog.data.origins.length" class="origin-list">
|
||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||
<input
|
||
:value="dialog.data.origins[i]"
|
||
@input="e => onOriginInput(i, e)"
|
||
@focus="focusOriginStart"
|
||
class="origin-input"
|
||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||
/>
|
||
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
|
||
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="key-badge" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">🔗</span>
|
||
<div class="row-menu">
|
||
<button type="button" class="icon-btn" @click.stop="openMenu = openMenu === i ? null : i" aria-label="Origin actions" title="Actions">⋮</button>
|
||
<div v-if="openMenu === i" class="row-menu-popup">
|
||
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()"><span class="menu-icon">🔑</span>Remove auth host</button>
|
||
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)"><span class="menu-icon">🔑</span>Set as auth host</button>
|
||
<button type="button" @click="onRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.</p>
|
||
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
|
||
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
|
||
</div>
|
||
<p class="small muted">
|
||
Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> allows the whole domain over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> for other exceptions.
|
||
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). An empty list allows nothing of this domain (related origins still work).
|
||
</p>
|
||
<p v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</p>
|
||
|
||
<template v-if="relatedEntries.length">
|
||
<p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p>
|
||
<p class="small muted">
|
||
Related origins are verified by browsers against
|
||
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
|
||
— served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise publish this document there:
|
||
</p>
|
||
<div class="wellknown-doc">
|
||
<pre>{{ wellKnownJson }}</pre>
|
||
<button type="button" class="icon-btn" @click="copyText(wellKnownJson, 'Well-known document')" aria-label="Copy well-known document" title="Copy">📋</button>
|
||
</div>
|
||
<p v-if="dialog.data.wellKnownCheck === 'validating'" class="small muted">Checking the published document…</p>
|
||
<p v-else-if="dialog.data.wellKnownCheck === 'valid'" class="small muted">✓ The published document lists all related origins.</p>
|
||
<p v-else-if="dialog.data.wellKnownCheck === 'missing'" class="small error">The published document does not list: {{ (dialog.data.wellKnownMissing || []).join(', ') }}</p>
|
||
<p v-else-if="dialog.data.wellKnownCheck === 'unreachable'" class="small muted">Could not fetch the published document to verify it.</p>
|
||
</template>
|
||
</template>
|
||
<template v-else-if="dialog.type==='confirm'">
|
||
<p>{{ dialog.data.message }}</p>
|
||
</template>
|
||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
||
<button
|
||
type="button"
|
||
class="btn-secondary"
|
||
@click="$emit('closeDialog')"
|
||
:disabled="dialog.busy"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="btn-primary"
|
||
:disabled="dialog.busy || isValidationInvalid"
|
||
>
|
||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Domain origins */
|
||
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||
.origin-add-btn { font-size: 1.2rem; }
|
||
.key-badge { flex-shrink: 0; }
|
||
.row-menu { position: relative; flex-shrink: 0; }
|
||
.row-menu-popup { position: absolute; right: 0; top: 100%; z-index: 10; display: flex; flex-direction: column; min-width: 9rem; background: var(--color-bg, #fff); border: 1px solid var(--color-border, #ccc); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||
.row-menu-popup button { display: flex; align-items: center; justify-content: flex-start; gap: 0.45em; text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; }
|
||
.row-menu-popup button:hover:not(:disabled) { background: var(--color-bg-soft, rgba(127,127,127,0.12)); }
|
||
.row-menu-popup button:disabled { opacity: 0.5; cursor: default; }
|
||
.row-menu-popup .menu-icon { flex-shrink: 0; width: 1.1em; text-align: center; }
|
||
.row-menu-popup .delete-menu-icon { filter: saturate(1.4); }
|
||
|
||
.wellknown-doc { display: flex; align-items: flex-start; gap: var(--space-xs); }
|
||
.wellknown-doc pre { flex: 1; margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; overflow-x: auto; }
|
||
|
||
.input-error {
|
||
border-color: var(--color-error);
|
||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||
}
|
||
</style>
|