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