From 0d1d18e8a7deacc9d4ce320e74051012cc97ef29 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 13:24:19 +0000 Subject: [PATCH] Frontend: domain dialog fixes and dead-code removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Empty-origins default shows as a '*' placeholder row that is not persisted unless edited (open+save no longer tightens any-scheme to https-only) - Foreign wildcards are flagged invalid instead of being classified as related origins; over-cap related list disables Save - Single-label rp-ids accepted (matching backend validate_rp_id) - Auth-host mark follows row edits; row menu state resets on dialog close - rp-id/origin keys lowercased for classification and submit - settings cache: stale in-flight responses no longer overwrite a forced refresh - Remove the dead oidc-edit dialog path and other unused code; fix stale comments (realm→domain, '*' semantics, per-domain discovery URLs) --- frontend/auth/admin/AdminApp.vue | 54 ++++--------- frontend/src/admin/AdminDialogs.vue | 101 +++++++++++++------------ frontend/src/admin/AdminOidcDetail.vue | 2 +- frontend/src/admin/AdminOverview.vue | 7 +- frontend/src/utils/helpers.js | 14 ++-- frontend/src/utils/settings.js | 9 ++- frontend/vite.config.js | 2 +- paskia/domains.py | 8 +- 8 files changed, 87 insertions(+), 110 deletions(-) diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 913fc82..a1e18f1 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -17,7 +17,7 @@ import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia' import { updateThemeFromSession } from '@/utils/theme' import { uuidv7 } from 'uuidv7' import { getDirection } from '@/utils/keynav' -import { goBack, originDisplayEntries } from '@/utils/helpers' +import { originDisplayEntries } from '@/utils/helpers' const info = ref(null) const loading = ref(true) @@ -465,10 +465,6 @@ function resetOidcSecret(clientId) { if (editingOidcClient.value?.client_id === clientId) { editingOidcClient.value = { ...editingOidcClient.value, client_secret } } - // Also update dialog if open (for backwards compatibility) - if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) { - dialog.value.data.client_secret = client_secret - } } function createPermissionForClient(clientId) { @@ -483,14 +479,15 @@ function createDomain() { auth_host: '', origins: [], originValidation: [], + originPlaceholders: [], wellKnownCheck: null, }) } function openDomain(domain) { // One combined list for editing, in display order: in-domain sites and - // related origins, classified by hostname. The default is always shown - // explicitly as the '*' entry. + // related origins, classified by hostname. An empty origins object shows + // as a '*' placeholder row, omitted again on submit unless edited. const rows = originDisplayEntries(domain) openDialog('domain-edit', { isNew: false, @@ -499,6 +496,7 @@ function openDomain(domain) { auth_host: rows.find(r => r.auth)?.key || '', origins: rows.map(r => r.key), originValidation: rows.map(() => null), + originPlaceholders: rows.map(r => !!r.placeholder), wellKnownCheck: null, }) } @@ -910,49 +908,27 @@ async function submitDialog() { authStore.showMessage(e.message || 'Failed to create permission', 'error') }) return // Don't call closeDialog() again - } else if (t === 'oidc-edit') { - const { client_id, client_secret, isNew } = dialog.value.data - const name = dialog.value.data.name?.trim() - const uris = dialog.value.data.redirect_uris?.trim() - if (!name) throw new Error('Client name required') - - const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : [] - - // Close dialog immediately, then perform async operation - closeDialog() - - const req = client_secret - ? sha256Hex(client_secret).then(secret_hash => isNew - ? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } }) - : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })) - : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) - req - .then(() => { - authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) - loadAdminData() - }) - .catch(e => { - authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') - }) - return // Don't call closeDialog() again } else if (t === 'domain-edit') { const d = dialog.value.data const rp_id = d.rp_id?.trim().toLowerCase() if (!rp_id) throw new Error('Domain (rp-id) required') const rp_name = d.rp_name?.trim() || '' - const auth_host = d.auth_host?.trim() || '' + const auth_host = d.auth_host?.trim().toLowerCase() || '' // The combined origins list is split by hostname: entries on the // rp-id domain form the in-domain origins object (the auth host // entry is marked), entries elsewhere are related origins (ROR). // Wildcards ('*.app.example.com') classify by their base domain. - // Keys are stored without the https:// scheme. - const keyOf = o => o.replace(/^https:\/\//, '').replace(/\/+$/, '') + // Keys are stored lowercased, without the https:// scheme. + const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase() const origins = {} const related = {} - for (const o of (d.origins || []).map(o => o.trim()).filter(o => o)) { - const key = keyOf(o) + for (const [i, o] of (d.origins || []).entries()) { + const key = keyOf(o.trim()) + // An untouched placeholder row only displays the empty-origins + // default (any scheme in-domain) — don't persist it as '*' + if (!key || d.originPlaceholders?.[i]) continue if (key === '*') { - origins['*'] = true // anything in-domain, any scheme/port + origins['*'] = true // shorthand for '*.{rp-id}' (https-only outside localhost) continue } let hn = null @@ -1119,8 +1095,6 @@ async function submitDialog() { :permission-id-pattern="PERMISSION_ID_PATTERN" @submit-dialog="submitDialog" @close-dialog="closeDialog" - @reset-oidc-secret="resetOidcSecret" - @create-permission-for-client="createPermissionForClient" /> diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index c9eaf66..2e155d3 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -10,34 +10,26 @@ const props = defineProps({ PERMISSION_ID_PATTERN: String }) -const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient']) +defineEmits(['submitDialog', 'closeDialog']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) -const NO_SUBMIT_TYPES = new Set([]) -const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`) // The rp-id of the domain being edited in the 'domain-edit' dialog -const dialogRpId = computed(() => props.dialog?.data?.rp_id || '') +// (lowercased: classification compares against it, and hosts are +// case-insensitive) +const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase()) -// Initialize validation properties -if (props.dialog?.data && props.dialog.type === 'domain-edit') { - if (!('originValidation' in props.dialog.data)) { - props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null) - } - if (!('wellKnownCheck' in props.dialog.data)) { - props.dialog.data.wellKnownCheck = null - } -} - -// Block submit on hard errors: malformed entries 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). +// Block submit on hard errors: malformed entries, an over-cap related list +// (the server rejects the save), 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(() => { if (props.dialog?.type !== 'domain-edit') return false const d = props.dialog.data 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 return false }) @@ -47,6 +39,8 @@ const isValidationInvalid = computed(() => { // names are related origins (WebAuthn ROR). Classification is automatic // from the hostname — the submit handler splits the two lists apart. function isRelatedEntry(origin) { + const v = origin.trim() + if (v === '*' || v.startsWith('*.')) return false // wildcards are never related const h = originHostname(origin) return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value)) } @@ -86,9 +80,10 @@ function addOrigin() { // Prefill with the origin the admin is currently on when editing that // very domain (so saving never locks them out), else with the rp-id // (https default). - const onThisDomain = authStore.settings?.rp_id && d.rp_id === authStore.settings.rp_id + const onThisDomain = authStore.settings?.rp_id && dialogRpId.value === authStore.settings.rp_id d.origins.push(onThisDomain ? window.location.origin : dialogRpId.value) d.originValidation.push(null) + d.originPlaceholders.push(false) validateOrigin(d.origins.length - 1) } function removeOrigin(i) { @@ -96,6 +91,7 @@ function removeOrigin(i) { if (d) { d.origins.splice(i, 1) d.originValidation.splice(i, 1) + d.originPlaceholders.splice(i, 1) } } function focusOriginStart(e) { @@ -106,7 +102,9 @@ function isWellFormedDomain(value) { if (!value.trim()) return false try { const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value) - return url.hostname.includes('.') || url.hostname === 'localhost' + // Any DNS label sequence (matching backend validate_rp_id): labels of + // 1-63 alnum/hyphen chars, no leading/trailing hyphen, dot-separated + return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(url.hostname) } catch { return false } @@ -116,7 +114,7 @@ function originHostname(origin) { if (!origin.trim()) return null if (origin.trim() === '*') return '*' if (origin.trim().startsWith('*.')) { - const base = origin.trim().slice(2).replace(/\.+$/, '') + const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase() return isWellFormedDomain(base) ? base : null } try { @@ -168,6 +166,7 @@ function onOriginInput(i, e) { const d = props.dialog?.data if (!d) return const el = e.target + const oldKey = entryKey(d.origins[i]) let value = el.value if (value === '*' && dialogRpId.value) { const pos = el.selectionStart @@ -176,6 +175,13 @@ function onOriginInput(i, e) { el.setSelectionRange(pos, pos) } d.origins[i] = value + d.originPlaceholders[i] = false + // Keep the auth-host mark on a renamed entry, unless it no longer + // qualifies (wildcards and related origins cannot be the auth host) + if (d.auth_host && oldKey === d.auth_host) { + const key = entryKey(value) + d.auth_host = key && !key.startsWith('*') && !isRelatedEntry(value) ? key : '' + } validateOrigin(i) } @@ -188,7 +194,9 @@ function validateOrigin(i) { return } if (value.trim() === '*' || value.trim().startsWith('*.')) { - d.originValidation[i] = null // wildcards have no concrete site to probe + // Wildcards have no concrete site to probe, and are only allowed + // within the domain (related origins are individual hosts) + d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid' return } validateOriginConnectivity(i) @@ -245,6 +253,7 @@ watch(dialogRpId, rp => { if (!d.origins.length) { d.origins.push(seed) d.originValidation.push(null) + d.originPlaceholders.push(false) seededOrigin = seed } else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) { d.origins[0] = seed @@ -256,6 +265,10 @@ watch(dialogRpId, rp => { const openMenu = ref(null) +// The component stays mounted across dialogs; a closed dialog (including +// Escape in Modal) must not leave a row menu open +watch(() => props.dialog?.type, () => { openMenu.value = null }) + // Close the popup on any click outside it (the toggle button stops // propagation, so it never reaches this listener). function onDocumentClick(e) { @@ -288,6 +301,7 @@ function setAuthHost(i) { if (!d.origins.some(o => entryKey(o) === key)) { d.origins.push(key) d.originValidation.push(null) + d.originPlaceholders.push(false) added = true } } @@ -311,14 +325,21 @@ function resortOrigins() { const d = props.dialog?.data if (!d) return const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2 - const pairs = d.origins.map((o, i) => [o, d.originValidation[i]]) + const pairs = d.origins.map((o, i) => [o, d.originValidation[i], d.originPlaceholders[i]]) pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0])) d.origins = pairs.map(p => p[0]) d.originValidation = pairs.map(p => p[1]) + d.originPlaceholders = pairs.map(p => p[2]) } const inDomainCount = computed(() => - (props.dialog?.data?.origins || []).filter(o => originHostname(o) && !isRelatedEntry(o)).length + (props.dialog?.data?.origins || []).filter(o => originHostname(o) && isWithinDomain(o, dialogRpId.value)).length +) + +// True while the list holds the synthesized row for an empty origins +// object (the rp-id and all subdomains, any scheme) +const hasPlaceholderRow = computed(() => + !!props.dialog?.data?.originPlaceholders?.some(Boolean) ) function canRemoveOrigin(i) { @@ -326,7 +347,7 @@ function canRemoveOrigin(i) { if (o === undefined) return false // Never empty the in-domain list — that would silently mean the wildcard // default on the server; keep at least one in-domain entry visible - return isRelatedEntry(o) || !originHostname(o) || inDomainCount.value > 1 + return !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1 } function onRemoveOrigin(i) { @@ -349,7 +370,6 @@ function onRemoveOrigin(i) { - @@ -445,6 +465,7 @@ function onRemoveOrigin(i) { +

Some entries are invalid — wildcards are only allowed within the domain.

Some sites are unreachable — make sure they are routed to this instance.

Some sites are reachable but do not serve this domain.

@@ -452,9 +473,10 @@ function onRemoveOrigin(i) { Only the listed sites may sign in with this domain's passkeys — * is shorthand for *.{{ dialog.data.rp_id }}: the domain and all its subdomains over https (any scheme and port under localhost); list a full origin like http://localhost:8080 for other exceptions. Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮).

+

The * row is the default of a domain without configured origins: the domain and all its subdomains may sign in on any scheme. Saving keeps this default only while the row is left unedited.

{{ dialog.error }}
-