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) {
Add User To Role
Edit User Name
{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}
- {{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}
{{ dialog.data?.isNew ? 'Add Domain' : `Edit Domain: ${dialog.data?.rp_id}` }}
Confirm
@@ -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.
- Browsers support at most 5 related origins — {{ relatedEntries.length }} listed, extras will not work.
+ At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.
Related origins are verified by browsers against
{{ wellKnownUrl }}
@@ -474,7 +496,7 @@ function onRemoveOrigin(i) {
{{ dialog.data.message }}
{{ dialog.error }}
-