Files
paskia/frontend/src/admin/AdminDialogs.vue
T
LeoVasanko 7726203382 Frontend: single origins table; lockout guard in the domain editor
- 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
2026-09-07 14:54:46 +00:00

567 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>