Admin UI: Satellites panel replaces domain-dialog satellite config
The domain edit dialog returns to pure local domain editing — the remote toggle, sync URL field, uplink probing and per-domain token management are gone (the bad checkbox-on-its-own-line UX with them). Satellite setup now lives in a self-contained Satellites section of the admin overview, covering both roles: - Upstream: links table (satellite label, served domains, live status, the satellite's rejection reports), New satellite link dialog with domain scope checkboxes minting the wss link URL (shown once, 10 min, single-use), scope editing pushed live, revocation with confirm. - Satellite: uplinks table (upstream URL, served domains, live connected/cached/offline status with gentle polling while disconnected), Add uplink dialog taking the pasted link URL with the registration handshake running synchronously (immediate errors), unlink with confirm. Remote domains in the domains list are no longer clickable/editable or deletable — they carry a 'via uplink' badge pointing at the Satellites panel.
This commit is contained in:
1 parent
3a6e2bb370
commit
ebe18912fc
4 files changed
+420
-315
No files matched your search
@@ -2,8 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import AdminDialog from './AdminDialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { compareOrigins, formatDate } from '@/utils/helpers'
|
||||
import { apiJson } from 'paskia'
|
||||
import { compareOrigins } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: { type: Object, required: true }
|
||||
@@ -19,141 +18,14 @@ const title = computed(() =>
|
||||
// compares against it, and hosts are case-insensitive)
|
||||
const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowerCase())
|
||||
|
||||
// --- Remote (satellite) backing ---
|
||||
//
|
||||
// A remote domain is served from another paskia instance: this one keeps a
|
||||
// RAM-only read replica for fast local session checks and forwards
|
||||
// mutations. Setup is a single sync URL minted on the upstream (its admin
|
||||
// UI, the domain's Satellite access section): it carries the upstream auth
|
||||
// host and a token scoped to this domain. The remote configuration takes
|
||||
// over completely — allowed origins and the auth host are derived from the
|
||||
// uplink once it verifies.
|
||||
const remoteEnabled = computed({
|
||||
get: () => !!props.dialog.data?.remote,
|
||||
set: on => {
|
||||
const d = props.dialog.data
|
||||
if (!d) return
|
||||
d.remote = on ? { url: '', token: '', cache_ttl: 60, refresh_interval: 300 } : null
|
||||
},
|
||||
})
|
||||
|
||||
// The raw sync URL as pasted; the parsed parts land in remote.url (clean,
|
||||
// userinfo stripped) and remote.token. An existing configuration shows the
|
||||
// clean URL — the stored token is write-only, and a URL without userinfo
|
||||
// keeps it.
|
||||
const syncUrl = ref(props.dialog.data?.remote?.url || '')
|
||||
const hadRemoteOnOpen = !!props.dialog.data?.remote
|
||||
|
||||
function parseSyncUrl(value) {
|
||||
const v = value.trim()
|
||||
if (!v) return null
|
||||
try {
|
||||
const u = new URL(v.includes('://') ? v : 'https://' + v)
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: true }
|
||||
if (!u.hostname) return { error: true }
|
||||
const token = decodeURIComponent(u.password || u.username || '')
|
||||
const url = u.origin + (u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, ''))
|
||||
return { url, token, host: u.host }
|
||||
} catch {
|
||||
return { error: true }
|
||||
}
|
||||
}
|
||||
|
||||
const parsedSyncUrl = computed(() => parseSyncUrl(syncUrl.value))
|
||||
const syncUrlInvalid = computed(() => !!(syncUrl.value.trim() && parsedSyncUrl.value?.error))
|
||||
// A token is required unless the domain already has one stored (write-only)
|
||||
const syncUrlMissingToken = computed(() => {
|
||||
const parsed = parsedSyncUrl.value
|
||||
return !!(parsed && !parsed.error && !parsed.token && !hadRemoteOnOpen)
|
||||
})
|
||||
|
||||
// Keep the submit model in sync with the pasted URL
|
||||
watch(
|
||||
parsedSyncUrl,
|
||||
parsed => {
|
||||
const d = props.dialog.data
|
||||
if (!d?.remote) return
|
||||
if (!parsed || parsed.error) {
|
||||
d.remote.url = ''
|
||||
return
|
||||
}
|
||||
d.remote.url = parsed.url
|
||||
if (parsed.token) d.remote.token = parsed.token
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// --- Uplink verification ---
|
||||
// Fetch the upstream's /auth/api/settings (CORS-open), like the origin
|
||||
// connectivity probes: proves reachability, that the peer serves this very
|
||||
// domain authoritatively — not as a satellite itself (nested satellites are
|
||||
// not supported) — and that it has an auth host (where profile, admin and
|
||||
// sign-in pages live for satellite users). A verified uplink is required to
|
||||
// save a remote domain.
|
||||
const uplinkCheck = ref(null) // null|validating|valid|unreachable|mismatch|nested|no-auth-host
|
||||
let uplinkTimer = null
|
||||
let uplinkSeq = 0
|
||||
watch(
|
||||
() => [props.dialog.data?.remote ? parsedSyncUrl.value : null, dialogRpId.value],
|
||||
([parsed]) => {
|
||||
clearTimeout(uplinkTimer)
|
||||
uplinkCheck.value = null
|
||||
if (!parsed || parsed.error) return
|
||||
uplinkTimer = setTimeout(() => checkUplink(parsed.url, parsed.host), 600)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function checkUplink(url, host) {
|
||||
const seq = ++uplinkSeq
|
||||
uplinkCheck.value = 'validating'
|
||||
try {
|
||||
const response = await fetch(`${url}/auth/api/settings`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (seq !== uplinkSeq) return // url/rp-id changed while fetching
|
||||
if (!response.ok) throw new Error('not ok')
|
||||
const data = await response.json()
|
||||
const d = props.dialog.data
|
||||
// An existing (or already typed) rp-id must match; a fresh domain
|
||||
// adopts the upstream's, along with its display name.
|
||||
if (dialogRpId.value && data.rp_id !== dialogRpId.value) {
|
||||
uplinkCheck.value = 'mismatch'
|
||||
return
|
||||
}
|
||||
uplinkCheck.value = data.remote
|
||||
? 'nested'
|
||||
: !data.auth_host
|
||||
? 'no-auth-host'
|
||||
: 'valid'
|
||||
if (uplinkCheck.value !== 'valid' || !d?.remote) return
|
||||
if (d.isNew && !dialogRpId.value) {
|
||||
d.rp_id = data.rp_id
|
||||
if (!d.rp_name) d.rp_name = data.rp_name || ''
|
||||
}
|
||||
// The remote config takes over: origins are the whole rp-id domain
|
||||
// plus the uplink's auth host, which hosts all account pages.
|
||||
d.auth_host = host
|
||||
d.origins = ['**.' + data.rp_id, host]
|
||||
d.originValidation = [null, null]
|
||||
} catch {
|
||||
if (seq === uplinkSeq) uplinkCheck.value = 'unreachable'
|
||||
}
|
||||
}
|
||||
|
||||
// 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. For
|
||||
// remote domains the uplink must verify — it doubles as the source of
|
||||
// the derived origins and auth host.
|
||||
// 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(() => {
|
||||
const d = props.dialog.data
|
||||
if (d?.remote) {
|
||||
if (!parsedSyncUrl.value || parsedSyncUrl.value.error) return true
|
||||
if (syncUrlMissingToken.value) return true
|
||||
if (uplinkCheck.value !== 'valid') return true
|
||||
return d.isNew && !isWellFormedDomain(d.rp_id || '')
|
||||
}
|
||||
const bad = v => v === 'invalid' || v === 'validating'
|
||||
if (d.originValidation?.some(bad)) return true
|
||||
if (relatedEntries.value.length > 5) return true
|
||||
@@ -202,68 +74,6 @@ function copyText(value, label) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Satellite access (this instance as the upstream) ---
|
||||
//
|
||||
// Tokens are scoped to this domain: a satellite receives only this
|
||||
// domain's sessions (each with its user's identity context and effective
|
||||
// permissions) and credentials — never the user directory — and can only
|
||||
// write back session refreshes within scope. The raw token is shown
|
||||
// once, embedded in the sync URL; revoking drops the satellite's live
|
||||
// connections. Only an authoritative domain with an auth host can serve
|
||||
// satellites (enforced by the server).
|
||||
const syncTokens = ref(null) // null = not loaded yet
|
||||
const mintedSyncUrl = ref('')
|
||||
const satelliteBusy = ref(false)
|
||||
const showSatelliteAccess = computed(() => {
|
||||
const d = props.dialog.data
|
||||
return !!(d && !d.isNew && !d.remote)
|
||||
})
|
||||
|
||||
async function loadSyncTokens() {
|
||||
const d = props.dialog.data
|
||||
if (!showSatelliteAccess.value) return
|
||||
try {
|
||||
syncTokens.value = await apiJson(
|
||||
`/auth/api/admin/domains/${d.rp_id}/sync-tokens`, { method: 'GET' }
|
||||
)
|
||||
} catch {
|
||||
syncTokens.value = []
|
||||
}
|
||||
}
|
||||
onMounted(loadSyncTokens)
|
||||
|
||||
async function generateSyncToken() {
|
||||
const d = props.dialog.data
|
||||
if (!d || satelliteBusy.value) return
|
||||
satelliteBusy.value = true
|
||||
try {
|
||||
const data = await apiJson(
|
||||
`/auth/api/admin/domains/${d.rp_id}/sync-token`, { method: 'POST' }
|
||||
)
|
||||
mintedSyncUrl.value = data.url
|
||||
await loadSyncTokens()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to generate the sync URL', 'error')
|
||||
} finally {
|
||||
satelliteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSyncToken(id) {
|
||||
const d = props.dialog.data
|
||||
if (!d || satelliteBusy.value) return
|
||||
satelliteBusy.value = true
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/domains/${d.rp_id}/sync-token/${id}`, { method: 'DELETE' })
|
||||
authStore.showMessage('Sync token revoked.', 'success', 2500)
|
||||
await loadSyncTokens()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to revoke the sync token', 'error')
|
||||
} finally {
|
||||
satelliteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lockout prevention (editing the domain in use) ---
|
||||
|
||||
// When the admin edits the domain they are currently signed in on and no
|
||||
@@ -533,8 +343,7 @@ watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, {
|
||||
// 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'. Remote domains derive their
|
||||
// origins from the uplink instead.
|
||||
// don't prefill a broken '**.something'.
|
||||
function looksCompleteDomain(value) {
|
||||
const host = (value || '').trim().replace(/\.$/, '')
|
||||
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
||||
@@ -543,7 +352,7 @@ function looksCompleteDomain(value) {
|
||||
let seededOrigin = null
|
||||
watch(dialogRpId, rp => {
|
||||
const d = props.dialog.data
|
||||
if (!d?.isNew || d.remote) return
|
||||
if (!d?.isNew) return
|
||||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||||
const seed = '**.' + rp.trim().replace(/\.$/, '')
|
||||
if (!d.origins.length) {
|
||||
@@ -568,7 +377,6 @@ function onDocumentClick(e) {
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
clearTimeout(uplinkTimer)
|
||||
for (const t of originValidateTimers.values()) clearTimeout(t)
|
||||
originValidateTimers.clear()
|
||||
})
|
||||
@@ -678,100 +486,35 @@ function onRemoveOrigin(i) {
|
||||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||||
</label>
|
||||
|
||||
<template v-if="!dialog.data.remote">
|
||||
<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
|
||||
ref="originInputs"
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => onOriginInput(i, e)"
|
||||
@blur="onOriginBlur(i)"
|
||||
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>
|
||||
</div>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="origin-label">
|
||||
<label class="remote-toggle">
|
||||
<input type="checkbox" v-model="remoteEnabled" />
|
||||
Remote instance (satellite mode)
|
||||
</label>
|
||||
Allowed Origins
|
||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin()" aria-label="Add origin" title="Add origin">➕</button>
|
||||
</div>
|
||||
<template v-if="dialog.data.remote">
|
||||
<label>Sync URL
|
||||
<div v-if="dialog.data.origins.length" class="origin-list">
|
||||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||
<input
|
||||
v-model="syncUrl"
|
||||
placeholder="https://alpha.bravo.charlie.delta.echo@auth.example.com"
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
spellcheck="false"
|
||||
:class="{ 'input-error': syncUrlInvalid || syncUrlMissingToken }"
|
||||
ref="originInputs"
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => onOriginInput(i, e)"
|
||||
@blur="onOriginBlur(i)"
|
||||
class="origin-input"
|
||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||
/>
|
||||
</label>
|
||||
<p class="small muted">Minted on the authoritative instance — open the domain there and use <em>Satellite access → Generate sync URL</em>.<template v-if="hadRemoteOnOpen"> Paste a new one to replace the stored token.</template></p>
|
||||
<p v-if="syncUrlInvalid" class="small error">Must be an http(s) URL.</p>
|
||||
<p v-else-if="syncUrlMissingToken" class="small error">The URL carries no token — paste the complete sync URL from the upstream.</p>
|
||||
<p v-else-if="uplinkCheck === 'validating'" class="small muted">Checking the uplink…</p>
|
||||
<p v-else-if="uplinkCheck === 'valid'" class="small">✓ Uplink verified — {{ parsedSyncUrl?.host }} serves this domain authoritatively. Origins and the auth host are taken from it.</p>
|
||||
<p v-else-if="uplinkCheck === 'unreachable'" class="small error">The upstream is unreachable — check the URL.</p>
|
||||
<p v-else-if="uplinkCheck === 'mismatch'" class="small error">The upstream does not serve {{ dialogRpId }} — its sync URL must be minted for this domain.</p>
|
||||
<p v-else-if="uplinkCheck === 'nested'" class="small error">The upstream is itself a satellite for this domain — nested satellites are not supported. Point directly at the authoritative instance.</p>
|
||||
<p v-else-if="uplinkCheck === 'no-auth-host'" class="small error">The upstream has no auth host for this domain — only an instance with an auth host can serve satellites.</p>
|
||||
<label>Staleness limit (cache TTL, seconds)
|
||||
<input v-model.number="dialog.data.remote.cache_ttl" type="number" min="1" />
|
||||
</label>
|
||||
<label>Full re-sync interval (seconds)
|
||||
<input v-model.number="dialog.data.remote.refresh_interval" type="number" min="30" />
|
||||
</label>
|
||||
<p class="small muted">
|
||||
Session checks run locally against a RAM replica of the upstream (sub-millisecond). If the connection is down longer than the staleness limit, checks fail closed (503).
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-if="showSatelliteAccess">
|
||||
<div class="origin-label">Satellite access</div>
|
||||
<template v-if="dialog.data.auth_host">
|
||||
<p class="small muted">
|
||||
Other paskia instances can serve this domain as satellites — local RAM replicas kept in sync over a feed scoped to {{ dialog.data.rp_id }}. Generate one sync URL per satellite and paste it into that instance's configuration for the domain.
|
||||
</p>
|
||||
<div v-if="syncTokens?.length" class="sync-token-list">
|
||||
<div v-for="t in syncTokens" :key="t.id" class="sync-token-row">
|
||||
<span class="small sync-token-id" title="Sync token id">…{{ t.id.slice(-8) }}</span>
|
||||
<span class="small muted">{{ formatDate(t.created) }}</span>
|
||||
<button type="button" class="icon-btn" :disabled="satelliteBusy" @click="revokeSyncToken(t.id)" aria-label="Revoke sync token" title="Revoke — drops the satellite's connection">❌</button>
|
||||
<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>
|
||||
<button type="button" class="btn-secondary" :disabled="satelliteBusy" @click="generateSyncToken">Generate sync URL</button>
|
||||
</div>
|
||||
<template v-if="mintedSyncUrl">
|
||||
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(mintedSyncUrl, 'Sync URL')" @keydown.enter.prevent="copyText(mintedSyncUrl, 'Sync URL')">{{ mintedSyncUrl }}</pre>
|
||||
<p class="small muted">Shown once — the token is stored hashed and cannot be recovered later.</p>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else class="small muted">Mark an auth host above and save to let satellites connect — sign-in, profile and admin pages for satellite users live there.</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template>
|
||||
</p>
|
||||
</AdminDialog>
|
||||
</template>
|
||||
|
||||
@@ -797,13 +540,4 @@ function onRemoveOrigin(i) {
|
||||
border-color: var(--color-error);
|
||||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
/* The global label rule stacks children vertically — the toggle wants its
|
||||
checkbox beside the text. */
|
||||
.remote-toggle { display: flex; flex-direction: row; align-items: center; gap: var(--space-xs); font-weight: 600; font-size: 0.95rem; }
|
||||
.remote-toggle input { width: auto; }
|
||||
|
||||
.sync-token-list { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: var(--space-xs); }
|
||||
.sync-token-row { display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.sync-token-id { font-family: var(--font-mono, monospace); }
|
||||
</style>
|
||||
Reference in new issue
Block a user