Satellite v2: upstream-minted scoped sync tokens, per-session contexts
Registration simplified to a single sync URL: minted in the upstream's admin UI (Satellite access section of the domain dialog), carrying the token as userinfo — https://<token>@<auth-host>. Tokens live hashed in the upstream database, scoped to one rp-id, revocable (drops live connections). PASKIA_SYNC_TOKENS env is gone. Data minimization: the sync feed now carries only the scope's sessions and credentials; the user graph never leaves the upstream. Each session upsert embeds its master-computed ApiSessionContext (identity display data + effective permissions for the session's host); user-graph mutations eagerly re-push affected sessions with fresh contexts, so revocations land within ~1 RTT. /check and /user-info are proxied to the upstream (they need the user graph). Satellite side: replicas keyed by rp-id (no more shared-replica config conflicts — each remote domain has its own token), the replica refuses to go live on a ready-message scope mismatch, write-backs are confined to the token's scope server-side. Admin dialog: one sync-URL paste field (token split from userinfo, write-only), uplink verification required to save (reachability, rp-id match, no nesting, auth host present); a verified uplink takes over the domain's origins (**.<rp-id> + upstream auth host) and auth host. New domains adopt the upstream's rp-id and name.
This commit is contained in:
1 parent
da0b3e0e8b
commit
2209e2395c
14 files changed
+1159
-330
No files matched your search
@@ -2,7 +2,8 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import AdminDialog from './AdminDialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { compareOrigins } from '@/utils/helpers'
|
||||
import { compareOrigins, formatDate } from '@/utils/helpers'
|
||||
import { apiJson } from 'paskia'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: { type: Object, required: true }
|
||||
@@ -22,9 +23,11 @@ const dialogRpId = computed(() => (props.dialog.data?.rp_id || '').trim().toLowe
|
||||
//
|
||||
// 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. The remote must accept our sync token via its
|
||||
// PASKIA_SYNC_TOKENS environment variable. An auth host (the remote's) is
|
||||
// required — profile, admin and sign-in pages live there.
|
||||
// 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 => {
|
||||
@@ -34,39 +37,74 @@ const remoteEnabled = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const remoteUrlInvalid = computed(() => {
|
||||
const url = props.dialog.data?.remote?.url?.trim()
|
||||
if (!url) return false
|
||||
return !/^https?:\/\/[^\s/]+/.test(url)
|
||||
// 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)
|
||||
})
|
||||
|
||||
// Remote domains must mark an auth host (the server rejects the save)
|
||||
const remoteMissingAuthHost = computed(
|
||||
() => !!props.dialog.data?.remote && !props.dialog.data?.auth_host
|
||||
)
|
||||
|
||||
// --- Uplink verification ---
|
||||
// Fetch the remote's /auth/api/settings (CORS-open), like the origin
|
||||
// connectivity probes: proves reachability, that the peer is a paskia
|
||||
// instance, and that it serves this very domain authoritatively — not as
|
||||
// a satellite itself (nested satellites are not supported). Results are
|
||||
// warnings, never submit blockers (the remote may not be deployed yet).
|
||||
const uplinkCheck = ref(null) // null|validating|valid|unreachable|mismatch|nested
|
||||
let uplinkTimer = null
|
||||
let uplinkSeq = 0
|
||||
// Keep the submit model in sync with the pasted URL
|
||||
watch(
|
||||
() => [props.dialog.data?.remote?.url, dialogRpId.value],
|
||||
() => {
|
||||
clearTimeout(uplinkTimer)
|
||||
uplinkCheck.value = null
|
||||
const url = props.dialog.data?.remote?.url?.trim().replace(/\/+$/, '')
|
||||
if (!url || remoteUrlInvalid.value) return
|
||||
uplinkTimer = setTimeout(() => checkUplink(url), 600)
|
||||
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 }
|
||||
)
|
||||
|
||||
async function checkUplink(url) {
|
||||
// --- 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 {
|
||||
@@ -76,8 +114,28 @@ async function checkUplink(url) {
|
||||
if (seq !== uplinkSeq) return // url/rp-id changed while fetching
|
||||
if (!response.ok) throw new Error('not ok')
|
||||
const data = await response.json()
|
||||
uplinkCheck.value =
|
||||
data.rp_id !== dialogRpId.value ? 'mismatch' : data.remote ? 'nested' : 'valid'
|
||||
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'
|
||||
}
|
||||
@@ -85,19 +143,22 @@ async function checkUplink(url) {
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
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
|
||||
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||
if (lockoutWarning.value) return true
|
||||
if (remoteUrlInvalid.value || remoteMissingAuthHost.value) return true
|
||||
if (d.remote && !d.remote.url?.trim()) return true
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -141,6 +202,68 @@ 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
|
||||
@@ -410,7 +533,8 @@ 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'.
|
||||
// don't prefill a broken '**.something'. Remote domains derive their
|
||||
// origins from the uplink instead.
|
||||
function looksCompleteDomain(value) {
|
||||
const host = (value || '').trim().replace(/\.$/, '')
|
||||
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
||||
@@ -419,7 +543,7 @@ function looksCompleteDomain(value) {
|
||||
let seededOrigin = null
|
||||
watch(dialogRpId, rp => {
|
||||
const d = props.dialog.data
|
||||
if (!d?.isNew) return
|
||||
if (!d?.isNew || d.remote) return
|
||||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||||
const seed = '**.' + rp.trim().replace(/\.$/, '')
|
||||
if (!d.origins.length) {
|
||||
@@ -554,35 +678,37 @@ function onRemoveOrigin(i) {
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
<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">
|
||||
@@ -591,20 +717,27 @@ function onRemoveOrigin(i) {
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="dialog.data.remote">
|
||||
<label>Remote URL
|
||||
<input v-model="dialog.data.remote.url" placeholder="https://auth.example.com" data-form-type="other" :class="{ 'input-error': remoteUrlInvalid }" />
|
||||
<label>Sync URL
|
||||
<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 }"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="remoteUrlInvalid" class="small error">Must be an http(s) URL.</p>
|
||||
<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 — the remote serves this domain authoritatively.</p>
|
||||
<p v-else-if="uplinkCheck === 'unreachable'" class="small">The remote is unreachable — the uplink cannot be verified. Save anyway only if it is not deployed yet.</p>
|
||||
<p v-else-if="uplinkCheck === 'mismatch'" class="small error">The remote does not serve {{ dialogRpId }} — check the URL. Connect directly to the authoritative instance.</p>
|
||||
<p v-else-if="uplinkCheck === 'nested'" class="small error">The remote is itself a satellite for this domain — nested satellites are not supported. Point directly at the authoritative instance.</p>
|
||||
<p v-if="remoteMissingAuthHost" class="small error">A remote domain must mark an auth host above — profile, admin and sign-in pages live there (typically the remote's own site).</p>
|
||||
<label>Sync token
|
||||
<input v-model="dialog.data.remote.token" type="password" placeholder="Token in the remote's PASKIA_SYNC_TOKENS" autocomplete="off" data-form-type="other" />
|
||||
</label>
|
||||
<p class="small muted">Accepted by the remote via its PASKIA_SYNC_TOKENS environment variable.<template v-if="!dialog.data.isNew"> Leave empty to keep the stored token.</template></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>
|
||||
@@ -612,9 +745,33 @@ function onRemoveOrigin(i) {
|
||||
<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 remote (sub-millisecond). If the connection is down longer than the staleness limit, checks fail closed (503).
|
||||
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>
|
||||
</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>
|
||||
</AdminDialog>
|
||||
</template>
|
||||
|
||||
@@ -641,6 +798,12 @@ function onRemoveOrigin(i) {
|
||||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
.remote-toggle { display: flex; align-items: center; gap: var(--space-xs); font-weight: 600; font-size: 0.95rem; }
|
||||
/* 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