Frontend: single origins table; lockout guard in the domain editor

- Domain dialog submits one origins map (in-domain + related together);
  classification is derived, the submit-time split is gone
- Placeholder-row machinery deleted: an empty list now means 'nothing
  allowed'; new domains get a real pre-filled '*.{rp-id}' row that
  follows rp-id edits until touched
- Plain '*' is invalid; wildcards only within the domain
- Editing the domain in use: when no auth host is marked and the admin's
  current page origin would no longer be allowed to run ceremonies, Save
  is disabled with an explanatory error (mirrors the backend guard)
- Origin list display: single table with derived related badges; '*'
  sort special case removed
This commit is contained in:
2026-09-07 14:54:46 +00:00
parent b901a34615
commit 7726203382
3 changed files with 114 additions and 98 deletions
+8 -31
View File
@@ -479,15 +479,13 @@ function createDomain() {
auth_host: '', auth_host: '',
origins: [], origins: [],
originValidation: [], originValidation: [],
originPlaceholders: [],
wellKnownCheck: null, wellKnownCheck: null,
}) })
} }
function openDomain(domain) { function openDomain(domain) {
// One combined list for editing, in display order: in-domain sites and // One combined list for editing, in display order: in-domain sites and
// related origins, classified by hostname. An empty origins object shows // related origins, classified by hostname against the rp-id.
// as a '*' placeholder row, omitted again on submit unless edited.
const rows = originDisplayEntries(domain) const rows = originDisplayEntries(domain)
openDialog('domain-edit', { openDialog('domain-edit', {
isNew: false, isNew: false,
@@ -496,7 +494,6 @@ function openDomain(domain) {
auth_host: rows.find(r => r.auth)?.key || '', auth_host: rows.find(r => r.auth)?.key || '',
origins: rows.map(r => r.key), origins: rows.map(r => r.key),
originValidation: rows.map(() => null), originValidation: rows.map(() => null),
originPlaceholders: rows.map(r => !!r.placeholder),
wellKnownCheck: null, wellKnownCheck: null,
}) })
} }
@@ -914,41 +911,21 @@ async function submitDialog() {
if (!rp_id) throw new Error('Domain (rp-id) required') if (!rp_id) throw new Error('Domain (rp-id) required')
const rp_name = d.rp_name?.trim() || '' const rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim().toLowerCase() || '' const auth_host = d.auth_host?.trim().toLowerCase() || ''
// The combined origins list is split by hostname: entries on the // One origins object holds in-domain sites and related origins
// rp-id domain form the in-domain origins object (the auth host // (ROR) together; the server classifies each key against the rp-id.
// entry is marked), entries elsewhere are related origins (ROR).
// Wildcards ('*.app.example.com') classify by their base domain.
// Keys are stored lowercased, without the https:// scheme. // Keys are stored lowercased, without the https:// scheme.
const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase() const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase()
const origins = {} const origins = {}
const related = {} for (const o of d.origins || []) {
for (const [i, o] of (d.origins || []).entries()) {
const key = keyOf(o.trim()) const key = keyOf(o.trim())
// An untouched placeholder row only displays the empty-origins if (!key) continue
// default (any scheme in-domain) — don't persist it as '*' origins[key] = key === auth_host ? { auth_host: true } : true
if (!key || d.originPlaceholders?.[i]) continue
if (key === '*') {
origins['*'] = true // shorthand for '*.{rp-id}' (https-only outside localhost)
continue
}
let hn = null
if (key.startsWith('*.')) {
hn = key.slice(2).replace(/\.+$/, '')
} else {
try { hn = new URL(key.startsWith('http') ? key : 'https://' + key).hostname } catch { continue }
}
if (!hn) continue
if (hn === rp_id || hn.endsWith('.' + rp_id)) {
origins[key] = key === auth_host ? { auth_host: true } : true
} else {
related[key] = true
}
} }
closeDialog() closeDialog()
const req = d.isNew const req = d.isNew
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins, related } }) ? 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, related } }) : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } })
req req
.then(() => { .then(() => {
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
+78 -55
View File
@@ -19,8 +19,9 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
// case-insensitive) // case-insensitive)
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase()) const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
// Block submit on hard errors: malformed entries, an over-cap related list // Block submit on hard errors: malformed entries, an over-cap related
// (the server rejects the save), or validation still in flight. // 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 // 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 // be hosted elsewhere, or a new domain whose DNS is not routed to this
// instance yet). // instance yet).
@@ -31,16 +32,17 @@ const isValidationInvalid = computed(() => {
if (d.originValidation?.some(bad)) return true if (d.originValidation?.some(bad)) return true
if (relatedEntries.value.length > 5) return true if (relatedEntries.value.length > 5) return true
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
if (lockoutWarning.value) return true
return false return false
}) })
// A single origins list holds two kinds of entries: sites on the rp-id // A single origins list holds two kinds of entries: sites on the rp-id
// domain form the in-domain sign-in allow-list; entries on other domain // domain form the in-domain sign-in allow-list; entries on other domain
// names are related origins (WebAuthn ROR). Classification is automatic // names are related origins (WebAuthn ROR). Classification is automatic
// from the hostname — the submit handler splits the two lists apart. // from the hostname. A plain '*' is invalid (wildcards must sit under
// the rp-id) and never a related origin.
function isRelatedEntry(origin) { function isRelatedEntry(origin) {
const v = origin.trim() if (origin.trim().startsWith('*.')) return false // wildcards are never related
if (v === '*' || v.startsWith('*.')) return false // wildcards are never related
const h = originHostname(origin) const h = originHostname(origin)
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value)) return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
} }
@@ -74,6 +76,54 @@ function copyText(value, label) {
}) })
} }
// --- Lockout prevention (editing the domain in use) ---
// When the admin edits the domain they are currently signed in on and no
// auth host is marked (with one, ceremonies move there and saving is
// always allowed), their current page origin must stay allowed to run
// passkey ceremonies — otherwise saving locks them out. Mirrors the
// backend check (Passkey.validate_origin): an in-domain origin matches a
// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the
// base domain and its subdomains over https — any scheme and port under
// localhost); a related row matches only on exact equality (https://host).
const lockoutWarning = computed(() => {
const d = props.dialog?.data
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
const rpId = dialogRpId.value
if (!rpId || rpId !== authStore.settings?.rp_id) return null
return pageOriginAllowed(d.origins || [], rpId) ? null : window.location.host
})
function pageOriginAllowed(rows, rpId) {
const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key
const inDomain = []
const related = []
for (const row of rows) {
if (!originHostname(row)) continue
const key = entryKey(row).toLowerCase()
if (!key) continue
const bucket = isRelatedEntry(row) ? related : inDomain
bucket.push(toUrl(key))
}
const probe = origin => {
let hostname
try { hostname = new URL(origin).hostname } catch { return false }
if (hostname === rpId || hostname.endsWith('.' + rpId)) {
if (inDomain.includes(origin)) return true
return inDomain.some(e => {
if (!e.startsWith('*.')) return false
const base = e.slice(2).replace(/\.+$/, '')
if (hostname !== base && !hostname.endsWith('.' + base)) return false
// Under localhost a wildcard matches any scheme and port
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://')
})
}
return related.includes(origin)
}
// The page scheme may be http (e.g. on localhost) — probe both
return probe(`https://${window.location.host}`) || probe(`http://${window.location.host}`)
}
function addOrigin() { function addOrigin() {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return if (!d) return
@@ -83,7 +133,6 @@ function addOrigin() {
const onThisDomain = authStore.settings?.rp_id && dialogRpId.value === 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.origins.push(onThisDomain ? window.location.origin : dialogRpId.value)
d.originValidation.push(null) d.originValidation.push(null)
d.originPlaceholders.push(false)
validateOrigin(d.origins.length - 1) validateOrigin(d.origins.length - 1)
} }
function removeOrigin(i) { function removeOrigin(i) {
@@ -91,7 +140,6 @@ function removeOrigin(i) {
if (d) { if (d) {
d.origins.splice(i, 1) d.origins.splice(i, 1)
d.originValidation.splice(i, 1) d.originValidation.splice(i, 1)
d.originPlaceholders.splice(i, 1)
} }
} }
function focusOriginStart(e) { function focusOriginStart(e) {
@@ -111,14 +159,14 @@ function isWellFormedDomain(value) {
} }
function originHostname(origin) { function originHostname(origin) {
if (!origin.trim()) return null const v = origin.trim()
if (origin.trim() === '*') return '*' if (!v || v === '*') return null // a plain '*' is not a valid entry
if (origin.trim().startsWith('*.')) { if (v.startsWith('*.')) {
const base = origin.trim().slice(2).replace(/\.+$/, '').toLowerCase() const base = v.slice(2).replace(/\.+$/, '').toLowerCase()
return isWellFormedDomain(base) ? base : null return isWellFormedDomain(base) ? base : null
} }
try { try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin) const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
return url.hostname || null return url.hostname || null
} catch { } catch {
return null return null
@@ -126,7 +174,6 @@ function originHostname(origin) {
} }
function isWithinDomain(origin, rpId) { function isWithinDomain(origin, rpId) {
if (origin.trim() === '*') return true
const hostname = originHostname(origin) const hostname = originHostname(origin)
if (!hostname) return false if (!hostname) return false
return hostname === rpId || hostname.endsWith('.' + rpId) return hostname === rpId || hostname.endsWith('.' + rpId)
@@ -175,7 +222,6 @@ function onOriginInput(i, e) {
el.setSelectionRange(pos, pos) el.setSelectionRange(pos, pos)
} }
d.origins[i] = value d.origins[i] = value
d.originPlaceholders[i] = false
// Keep the auth-host mark on a renamed entry, unless it no longer // Keep the auth-host mark on a renamed entry, unless it no longer
// qualifies (wildcards and related origins cannot be the auth host) // qualifies (wildcards and related origins cannot be the auth host)
if (d.auth_host && oldKey === d.auth_host) { if (d.auth_host && oldKey === d.auth_host) {
@@ -193,7 +239,7 @@ function validateOrigin(i) {
d.originValidation[i] = 'invalid' d.originValidation[i] = 'invalid'
return return
} }
if (value.trim() === '*' || value.trim().startsWith('*.')) { if (value.trim().startsWith('*.')) {
// Wildcards have no concrete site to probe, and are only allowed // Wildcards have no concrete site to probe, and are only allowed
// within the domain (related origins are individual hosts) // within the domain (related origins are individual hosts)
d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid' d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid'
@@ -231,19 +277,18 @@ async function testWellKnown() {
} }
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true }) watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
// Seed the default '*.<rp-id>' entry for a new domain once its rp-id is // Prefill a new domain's list with the real '*.<rp-id>' row once its
// known, so the list always shows what is allowed ('*.x' = the domain and // rp-id is known ('*.x' = the domain and all its subdomains over https,
// all its subdomains; https only, any scheme/port under localhost). // any scheme and port under localhost). The row follows rp-id edits while
// Removing the last in-domain entry is blocked in the row menu, so the // it is still the untouched prefilled row; once the admin edits it, it is
// list never becomes empty afterwards. // left alone. Seeding waits for a complete-looking rp-id (letters after
// Seeding waits for a complete-looking rp-id (letters after the final dot) // the final dot) so mid-typing states like 'something.' don't prefill a
// so mid-typing states like 'something.' don't seed a broken '*.something'. // broken '*.something'.
function looksCompleteDomain(value) { function looksCompleteDomain(value) {
const host = (value || '').trim().replace(/\.$/, '') const host = (value || '').trim().replace(/\.$/, '')
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host) return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
} }
// Tracks the auto-seeded entry so it can be corrected if it was seeded // Tracks the prefilled row so rp-id edits can keep updating it.
// from an incomplete rp-id and the admin keeps typing.
let seededOrigin = null let seededOrigin = null
watch(dialogRpId, rp => { watch(dialogRpId, rp => {
const d = props.dialog?.data const d = props.dialog?.data
@@ -253,7 +298,6 @@ watch(dialogRpId, rp => {
if (!d.origins.length) { if (!d.origins.length) {
d.origins.push(seed) d.origins.push(seed)
d.originValidation.push(null) d.originValidation.push(null)
d.originPlaceholders.push(false)
seededOrigin = seed seededOrigin = seed
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) { } else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
d.origins[0] = seed d.origins[0] = seed
@@ -294,14 +338,12 @@ function setAuthHost(i) {
if (!d) return if (!d) return
let key = entryKey(d.origins[i]) let key = entryKey(d.origins[i])
let added = false let added = false
if (key === '*' || key.startsWith('*.')) { if (key.startsWith('*.')) {
// A wildcard cannot be the auth host — create a concrete auth.<base> entry // A wildcard cannot be the auth host — create a concrete auth.<base> entry
const base = key === '*' ? dialogRpId.value : key.slice(2) key = 'auth.' + key.slice(2)
key = 'auth.' + base
if (!d.origins.some(o => entryKey(o) === key)) { if (!d.origins.some(o => entryKey(o) === key)) {
d.origins.push(key) d.origins.push(key)
d.originValidation.push(null) d.originValidation.push(null)
d.originPlaceholders.push(false)
added = true added = true
} }
} }
@@ -325,34 +367,15 @@ function resortOrigins() {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return if (!d) return
const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2 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], d.originPlaceholders[i]]) const pairs = d.origins.map((o, i) => [o, d.originValidation[i]])
pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0])) pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0]))
d.origins = pairs.map(p => p[0]) d.origins = pairs.map(p => p[0])
d.originValidation = pairs.map(p => p[1]) 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) && 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) {
const o = props.dialog?.data?.origins[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 !originHostname(o) || !isWithinDomain(o, dialogRpId.value) || inDomainCount.value > 1
} }
function onRemoveOrigin(i) { function onRemoveOrigin(i) {
const d = props.dialog?.data const d = props.dialog?.data
if (!d || !canRemoveOrigin(i)) return if (!d) return
if (isAuthHostEntry(d.origins[i])) d.auth_host = '' if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
removeOrigin(i) removeOrigin(i)
openMenu.value = null openMenu.value = null
@@ -461,19 +484,19 @@ function onRemoveOrigin(i) {
<div v-if="openMenu === i" class="row-menu-popup"> <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-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 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)" :disabled="!canRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button> <button type="button" @click="onRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
</div> </div>
</div> </div>
</div> </div>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — wildcards are only allowed within the domain.</p> <p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.</p>
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p> <p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p> <p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
</div> </div>
<p class="small muted"> <p class="small muted">
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> is shorthand for <strong>*.{{ dialog.data.rp_id }}</strong>: the domain and all its subdomains over https (any scheme and port under localhost); list a full origin like <strong>http://localhost:8080</strong> for other exceptions. Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> allows the whole domain over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> for other exceptions.
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). An empty list allows nothing of this domain (related origins still work).
</p> </p>
<p v-if="hasPlaceholderRow" class="small muted">The <strong>*</strong> 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.</p> <p v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</p>
<template v-if="relatedEntries.length"> <template v-if="relatedEntries.length">
<p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p> <p v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</p>
+28 -12
View File
@@ -43,11 +43,10 @@ export const hostIP = ip => {
} }
// Display-time ordering of a domain's configured origins (the stored // Display-time ordering of a domain's configured origins (the stored
// objects are unordered): the auth host first (flagged), then in-domain // object is unordered): the auth host first (flagged), then in-domain
// entries (exact rp-id, then hierarchical), then related domains // entries (exact rp-id, then hierarchical), then related origins — hosts
// hierarchically. An empty origins object (the rp-id and all subdomains // outside the rp-id domain — hierarchically. An empty origins object
// may sign in on any scheme — deliberately broader than a literal '*', // allows nothing and shows as an empty list.
// which is https-only outside localhost) shows as a '*' placeholder row.
// Hierarchical origin comparison: split off scheme/port, compare hostnames // Hierarchical origin comparison: split off scheme/port, compare hostnames
// label by label from the TLD down, parents before their subdomains and a // label by label from the TLD down, parents before their subdomains and a
@@ -61,8 +60,7 @@ function originParts(key) {
let port = '' let port = ''
const pm = s.match(/:(\d+)$/) const pm = s.match(/:(\d+)$/)
if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) } if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) }
// A bare '*' (shorthand for '*.{rp-id}') sorts before all host entries const labels = s.split('.').reverse()
const labels = s === '*' ? [] : s.split('.').reverse()
return { labels, scheme, port } return { labels, scheme, port }
} }
@@ -87,21 +85,39 @@ export function compareOrigins(a, b) {
return A.port.localeCompare(B.port) return A.port.localeCompare(B.port)
} }
// An origins-table entry outside the rp-id domain is a related origin
// (WebAuthn ROR). Wildcards are never related — they are only valid
// under the rp-id.
function isRelatedKey(rpId, key) {
if (key.startsWith('*.')) return false
try {
const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname
return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)
} catch {
return false
}
}
export function originDisplayEntries(domain) { export function originDisplayEntries(domain) {
const origins = domain.origins || {} const origins = domain.origins || {}
const keys = Object.keys(origins) const keys = Object.keys(origins)
const authKey = keys.find(k => origins[k] !== true && origins[k]?.auth_host) const authKey = keys.find(k => origins[k] !== true && origins[k]?.auth_host)
const inDomain = keys.filter(k => k !== authKey).sort((a, b) => { const inDomain = []
const related = []
for (const k of keys) {
if (k === authKey) continue
const bucket = isRelatedKey(domain.rp_id, k) ? related : inDomain
bucket.push(k)
}
inDomain.sort((a, b) => {
if (a === domain.rp_id) return -1 if (a === domain.rp_id) return -1
if (b === domain.rp_id) return 1 if (b === domain.rp_id) return 1
return compareOrigins(a, b) return compareOrigins(a, b)
}) })
related.sort(compareOrigins)
const rows = [] const rows = []
if (authKey) rows.push({ key: authKey, auth: true }) if (authKey) rows.push({ key: authKey, auth: true })
for (const k of inDomain) rows.push({ key: k, auth: false }) for (const k of inDomain) rows.push({ key: k, auth: false })
if (!keys.length) rows.push({ key: '*', auth: false, placeholder: true }) for (const k of related) rows.push({ key: k, auth: false, related: true })
for (const k of Object.keys(domain.related || {}).sort(compareOrigins)) {
rows.push({ key: k, auth: false, related: true })
}
return rows return rows
} }