From ebe18912fc3339095084f3790bd7b104acb9d37e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 1 Oct 2026 06:15:48 +0000 Subject: [PATCH] Admin UI: Satellites panel replaces domain-dialog satellite config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/auth/admin/AdminApp.vue | 18 +- frontend/src/admin/AdminOverview.vue | 16 +- frontend/src/admin/AdminSatellites.vue | 375 ++++++++++++++++++ .../src/admin/dialogs/DomainEditDialog.vue | 326 ++------------- 4 files changed, 420 insertions(+), 315 deletions(-) create mode 100644 frontend/src/admin/AdminSatellites.vue diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index d2067e0..6042315 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -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" /> diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue index eb6e861..c844c99 100644 --- a/frontend/src/admin/AdminOverview.vue +++ b/frontend/src/admin/AdminOverview.vue @@ -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 })
- {{ domain.rp_name || domain.rp_id }} + {{ domain.rp_name || domain.rp_id }} + {{ domain.rp_name || domain.rp_id }}
{{ domain.rp_id }} - ๐Ÿ›ฐ {{ domain.remote.url }} + ๐Ÿ›ฐ via uplink
{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '๐Ÿ”‘' : '' }}{{ e.related ? '๐Ÿ”—' : '' }} - + + + diff --git a/frontend/src/admin/dialogs/DomainEditDialog.vue b/frontend/src/admin/dialogs/DomainEditDialog.vue index 9ac5bd7..e0a6170 100644 --- a/frontend/src/admin/dialogs/DomainEditDialog.vue +++ b/frontend/src/admin/dialogs/DomainEditDialog.vue @@ -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) { - -
- + Allowed Origins +
- - - + + +

+ Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: **.{{ dialog.data.rp_id }} allows the whole domain, *.{{ dialog.data.rp_id }} only a single subdomain level. +

@@ -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); }