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:
LeoVasanko committed 2026-10-01 06:15:48 +00:00
1 parent 3a6e2bb370
commit ebe18912fc
4 files changed
+420 -315

No files matched your search

+3 -15
View File
@@ -480,7 +480,6 @@ function createDomain() {
origins: [],
originValidation: [],
wellKnownCheck: null,
remote: null,
})
}
@@ -496,8 +495,6 @@ function openDomain(domain) {
origins: rows.map(r => r.key),
originValidation: rows.map(() => null),
wellKnownCheck: null,
// The sync token is write-only: an empty field keeps the stored one
remote: domain.remote ? { ...domain.remote, token: '' } : null,
})
}
@@ -926,19 +923,9 @@ async function submitDialog() {
}
closeDialog()
// remote is replaced wholesale when present; null clears it, an
// absent key (create without remote) leaves it unset.
const remote = d.remote?.url?.trim()
? {
url: d.remote.url.trim().replace(/\/+$/, ''),
token: d.remote.token || '',
cache_ttl: Number(d.remote.cache_ttl) || 60,
refresh_interval: Number(d.remote.refresh_interval) || 300,
}
: null
const req = d.isNew
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, ...(remote ? { remote } : {}) } })
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins, remote } })
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } })
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } })
req
.then(() => {
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
@@ -1022,6 +1009,7 @@ async function submitDialog() {
@create-domain="createDomain"
@open-domain="openDomain"
@delete-domain="deleteDomain"
@domains-changed="loadDomains"
@navigate-out="handlePanelNavigateOut"
/>
+12 -4
View File
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate, originDisplayEntries } from '@/utils/helpers'
import AdminSatellites from './AdminSatellites.vue'
const props = defineProps({
info: Object,
@@ -14,7 +15,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'domainsChanged', 'navigateOut'])
// Template refs for navigation
const orgActionsRef = ref(null)
@@ -460,21 +461,28 @@ defineExpose({ focusFirstElement })
<tr v-for="domain in sortedDomains" :key="domain.rp_id">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
<a v-if="!domain.remote" :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
<span v-else>{{ domain.rp_name || domain.rp_id }}</span>
</div>
<div class="perm-id-info">
<span class="id-text">{{ domain.rp_id }}</span>
<span v-if="domain.remote" class="id-text" :title="`Served from remote ${domain.remote.url} (satellite mode)`">🛰 {{ domain.remote.url }}</span>
<span v-if="domain.remote" class="id-text" title="Satellite-served — its configuration is pushed by the uplink and managed in the Satellites panel">🛰 via uplink</span>
</div>
</td>
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td>
<td class="center">
<button v-if="domain.rp_id !== currentRpId" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
<button v-if="domain.rp_id !== currentRpId && !domain.remote" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
</td>
</tr>
</tbody>
</table>
</div>
<AdminSatellites
v-if="isMasterAdmin"
:domains="domains || []"
@domains-changed="$emit('domainsChanged')"
/>
</template>
<style scoped>
+375
View File
@@ -0,0 +1,375 @@
<script setup>
// Satellites panel: both link roles of this instance.
//
// Upstream role ("Satellites" table): other paskia instances registered to
// serve this instance's domains. Registration is online: mint a link URL
// (RAM-only, single-use, 10 minutes), the satellite pastes it in its own
// Satellites panel or runs `paskia satellite <url>`. A link's scope (the
// domains it serves) is adjustable at any time — the new set is pushed
// over the live link, no new registration. Revoking a link drops the
// satellite's connection and its copy of the domains.
//
// Satellite role ("Uplinks" table): this instance serving other
// instances' domains. The only local configuration is the link itself —
// domains, origins and auth host arrive from the upstream and are
// read-only here.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AdminDialog from './dialogs/AdminDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers'
import { apiJson } from 'paskia'
const props = defineProps({
domains: { type: Array, default: () => [] },
})
const emit = defineEmits(['domains-changed'])
const authStore = useAuthStore()
const links = ref([])
const uplinks = ref([])
async function load() {
try {
;[links.value, uplinks.value] = await Promise.all([
apiJson('/auth/api/admin/satellites/', { method: 'GET' }),
apiJson('/auth/api/admin/uplinks/', { method: 'GET' }),
])
} catch {
// Master admin only section; leave lists empty on failure
}
}
// While an uplink is not connected, poll gently so status goes live
// without a manual refresh (registration and reconnects included).
let pollTimer = null
function schedulePoll() {
clearTimeout(pollTimer)
const watching = uplinks.value.some(u => !u.connected)
if (watching) {
pollTimer = setTimeout(async () => {
await load()
schedulePoll()
}, 3000)
}
}
onMounted(async () => {
await load()
schedulePoll()
})
onBeforeUnmount(() => clearTimeout(pollTimer))
async function refresh() {
await load()
schedulePoll()
}
// Domains eligible for a link's scope: locally served, with an auth host
// (satellite users' account pages live there).
const eligibleDomains = computed(() =>
props.domains.filter(d => !d.remote && d.auth_host)
)
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
// --- Link dialog (create an offer / adjust scope) ---
const linkDialog = ref(null) // {id|null, selected:Set, minted:{url,expires_in}|null, busy, error}
function openLinkDialog(link) {
linkDialog.value = {
id: link?.id || null,
selected: new Set(link?.rp_ids || []),
minted: null,
busy: false,
error: '',
}
}
function toggleScope(rpId) {
const s = linkDialog.value.selected
if (s.has(rpId)) s.delete(rpId)
else s.add(rpId)
}
async function submitLinkDialog() {
const d = linkDialog.value
if (!d || d.busy) return
d.busy = true
d.error = ''
try {
const rp_ids = [...d.selected]
if (d.id) {
await apiJson(`/auth/api/admin/satellites/${d.id}`, { method: 'PATCH', body: { rp_ids } })
authStore.showMessage('Link scope updated — pushed to the satellite.', 'success', 2500)
linkDialog.value = null
} else {
d.minted = await apiJson('/auth/api/admin/satellites/', { method: 'POST', body: { rp_ids } })
}
await refresh()
} catch (e) {
d.error = e.message || 'Failed'
} finally {
d.busy = false
}
}
// --- Uplink dialog (register from a pasted link URL) ---
const uplinkDialog = ref(null) // {url, busy, error}
async function submitUplinkDialog() {
const d = uplinkDialog.value
if (!d || d.busy) return
d.busy = true
d.error = ''
try {
const result = await apiJson('/auth/api/admin/uplinks/', { method: 'POST', body: { url: d.url } })
uplinkDialog.value = null
const served = result.domains?.length ? result.domains.join(', ') : 'no domains yet'
authStore.showMessage(`Linked to ${result.url} — serving ${served}.`, 'success', 4000)
const rejected = Object.entries(result.rejected || {})
if (rejected.length) {
authStore.showMessage(
`Rejected by configuration: ${rejected.map(([r, why]) => `${r} (${why})`).join(', ')}`,
'error'
)
}
await refresh()
emit('domains-changed')
} catch (e) {
d.error = e.message || 'Registration failed'
} finally {
d.busy = false
}
}
// --- Confirm dialog (revoke / unlink) ---
const confirmDialog = ref(null) // {title, message, submitLabel, action, busy}
function askConfirm(title, message, submitLabel, action) {
confirmDialog.value = { title, message, submitLabel, action, busy: false }
}
async function submitConfirm() {
const d = confirmDialog.value
if (!d || d.busy) return
d.busy = true
try {
await d.action()
confirmDialog.value = null
await refresh()
emit('domains-changed')
} catch (e) {
authStore.showMessage(e.message || 'Failed', 'error')
confirmDialog.value = null
}
}
function revokeLink(link) {
askConfirm(
'Revoke satellite link',
`Revoke the link for ${link.label || link.id}? The satellite disconnects immediately and its copy of ${(link.rp_ids || []).join(', ') || 'the domains'} is dropped.`,
'Revoke',
async () => {
await apiJson(`/auth/api/admin/satellites/${link.id}`, { method: 'DELETE' })
authStore.showMessage('Satellite link revoked.', 'success', 2500)
}
)
}
function unlinkUplink(uplink) {
askConfirm(
'Unlink uplink',
`Unlink ${uplink.url}? This instance stops serving ${(uplink.domains || []).join(', ') || 'its domains'} — the domains and their local replica are removed.`,
'Unlink',
async () => {
await apiJson(`/auth/api/admin/uplinks/${uplink.id}`, { method: 'DELETE' })
authStore.showMessage('Uplink removed.', 'success', 2500)
}
)
}
function uplinkStatus(u) {
if (u.connected) return { icon: '●', text: 'connected', cls: 'status-ok' }
if (u.available) return { icon: '◐', text: 'offline — answering from cache', cls: 'status-warn' }
return { icon: '○', text: 'offline', cls: 'status-bad' }
}
</script>
<template>
<div class="satellites-section">
<div class="section-header">
<h2>Satellites</h2>
<p class="section-description">
A satellite is another paskia instance serving this instance's domains next to the apps that need
them — session checks answer locally in a millisecond from a RAM replica kept in sync over a live
link. Registration is online: mint a link URL, then paste it in the satellite's own Satellites
panel or run <code>paskia satellite &lt;url&gt;</code> on the satellite host.
</p>
</div>
<table v-if="links.length" class="org-table">
<thead>
<tr>
<th>Satellite</th>
<th>Serves domains</th>
<th class="center">Status</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-for="link in links" :key="link.id">
<td class="perm-name-cell">
<div class="perm-title">{{ link.label || 'satellite' }}</div>
<div class="perm-id-info"><span class="id-text">{{ link.id }}</span></div>
</td>
<td>
<span v-if="link.rp_ids.length">{{ link.rp_ids.join(', ') }}</span>
<span v-else class="muted">—</span>
<div v-if="Object.keys(link.rejected || {}).length" class="small error">
rejected there: {{ Object.entries(link.rejected).map(([r, why]) => `${r} (${why})`).join(', ') }}
</div>
</td>
<td class="center">
<span v-if="link.connected" class="status-ok" title="The satellite is connected now">●</span>
<span v-else class="muted" title="Not connected">○</span>
</td>
<td class="center">
<button type="button" class="icon-btn" @click="openLinkDialog(link)" aria-label="Adjust scope" title="Adjust which domains it serves">✏️</button>
<button type="button" class="icon-btn delete-icon" @click="revokeLink(link)" aria-label="Revoke link" title="Revoke — the satellite disconnects and unlinks">❌</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="small muted">No satellites registered.</p>
<div class="actions">
<button type="button" :disabled="!eligibleDomains.length" @click="openLinkDialog(null)">+ New satellite link</button>
<span v-if="!eligibleDomains.length" class="small muted">A domain with an auth host is required first.</span>
</div>
<h3 class="uplinks-title">Uplinks <span class="muted uplinks-hint">— this instance as the satellite</span></h3>
<table v-if="uplinks.length" class="org-table">
<thead>
<tr>
<th>Upstream</th>
<th>Serving domains</th>
<th class="center">Status</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-for="uplink in uplinks" :key="uplink.id">
<td class="perm-name-cell">
<div class="perm-title">{{ uplink.url }}</div>
<div class="perm-id-info"><span class="id-text">{{ uplink.id }}</span></div>
</td>
<td>
<span v-if="uplink.domains.length">{{ uplink.domains.join(', ') }}</span>
<span v-else class="muted">—</span>
</td>
<td class="center">
<span :class="uplinkStatus(uplink).cls" :title="uplinkStatus(uplink).text">{{ uplinkStatus(uplink).icon }}</span>
</td>
<td class="center">
<button type="button" class="icon-btn delete-icon" @click="unlinkUplink(uplink)" aria-label="Unlink" title="Unlink — stop serving its domains">❌</button>
</td>
</tr>
</tbody>
</table>
<div class="actions">
<button type="button" @click="uplinkDialog = { url: '', busy: false, error: '' }">+ Add uplink</button>
</div>
<AdminDialog
v-if="linkDialog"
:title="linkDialog.id ? 'Satellite link scope' : 'New satellite link'"
:busy="linkDialog.busy"
:error="linkDialog.error"
:submit-label="linkDialog.id ? 'Save' : 'Generate link URL'"
@submit="submitLinkDialog"
@close="linkDialog = null"
>
<template v-if="!linkDialog.minted">
<p class="small muted">
Domains this satellite serves. Adjustable later — the new set is pushed over the live link,
no new registration.
</p>
<div class="scope-list">
<label v-for="d in eligibleDomains" :key="d.rp_id" class="scope-row">
<input type="checkbox" :checked="linkDialog.selected.has(d.rp_id)" @change="toggleScope(d.rp_id)" />
<span>{{ d.rp_id }}</span>
</label>
</div>
</template>
<template v-else>
<p class="small muted">
The link URL — hand it to the satellite (its Satellites panel, or
<code>paskia satellite &lt;url&gt;</code>). Valid for {{ Math.round(linkDialog.minted.expires_in / 60) }} minutes,
single-use, shown once:
</p>
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(linkDialog.minted.url, 'Link URL')" @keydown.enter.prevent="copyText(linkDialog.minted.url, 'Link URL')">{{ linkDialog.minted.url }}</pre>
</template>
</AdminDialog>
<AdminDialog
v-if="uplinkDialog"
title="Add uplink"
:busy="uplinkDialog.busy"
:error="uplinkDialog.error"
submit-label="Register"
:submit-disabled="!uplinkDialog.url.trim()"
@submit="submitUplinkDialog"
@close="uplinkDialog = null"
>
<label>Link URL
<input
v-model="uplinkDialog.url"
placeholder="wss://alpha.bravo.charlie.delta.echo@auth.example.com"
autocomplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
spellcheck="false"
/>
</label>
<p class="small muted">
Minted on the upstream (its Satellites panel → New satellite link). Registration runs now:
the domains it covers appear here and in the domains list, managed by the upstream.
</p>
</AdminDialog>
<AdminDialog
v-if="confirmDialog"
:title="confirmDialog.title"
:busy="confirmDialog.busy"
:submit-label="confirmDialog.submitLabel"
@submit="submitConfirm"
@close="confirmDialog = null"
>
<p class="small">{{ confirmDialog.message }}</p>
</AdminDialog>
</div>
</template>
<style scoped>
.satellites-section { margin-top: var(--space-2xl); display: flex; flex-direction: column; gap: var(--space-sm); }
.satellites-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-xs); }
.satellites-section .actions { display: flex; align-items: center; gap: var(--space-sm); }
.uplinks-title { margin: var(--space-lg) 0 var(--space-xs); font-size: 1.05rem; }
.uplinks-hint { font-weight: 400; font-size: 0.85rem; }
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
.id-text { font-family: var(--font-mono, monospace); }
.org-table .center { width: 4rem; min-width: 4rem; text-align: center; }
.status-ok { color: var(--color-success, #16a34a); }
.status-warn { color: var(--color-warning, #d97706); }
.status-bad { color: var(--color-text-muted); }
.scope-list { display: flex; flex-direction: column; gap: 0.3rem; }
.scope-row { display: flex; flex-direction: row; align-items: center; gap: var(--space-xs); }
.scope-row input { width: auto; }
.wellknown-doc { 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; white-space: pre-wrap; word-break: break-all; cursor: pointer; }
.error { color: var(--color-error); }
</style>
+30 -296
View File
@@ -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>