+
+
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) {
-
-
- 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. ๐ means related host requiring WebAuthn ROR setup. ๐ is the dedicated Paskia host for all account management.
-