409 lines
18 KiB
Vue
409 lines
18 KiB
Vue
<script setup>
|
||
import { computed } from 'vue'
|
||
import Modal from '@/components/Modal.vue'
|
||
import NameEditForm from '@/components/NameEditForm.vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const props = defineProps({
|
||
dialog: Object,
|
||
PERMISSION_ID_PATTERN: String
|
||
})
|
||
|
||
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||
|
||
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 realm being edited in the 'realm-edit' dialog
|
||
const realmRpId = computed(() => props.dialog?.data?.rp_id || '')
|
||
|
||
// Initialize validation properties
|
||
if (props.dialog?.data && props.dialog.type === 'realm-edit') {
|
||
if (!('authHostValidation' in props.dialog.data)) {
|
||
props.dialog.data.authHostValidation = null
|
||
}
|
||
if (!('originValidation' in props.dialog.data)) {
|
||
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
|
||
}
|
||
if (!('relatedValidation' in props.dialog.data)) {
|
||
props.dialog.data.relatedValidation = (props.dialog.data.related_origins || []).map(() => null)
|
||
}
|
||
}
|
||
|
||
// Block submit on hard errors: malformed entries, entries filed under the
|
||
// wrong list, auth-host outside the rp-id domain, or validation still in
|
||
// flight. Connectivity and rp-id mismatch results are warnings only (e.g.
|
||
// related domains hosted elsewhere, or a new realm whose DNS is not routed
|
||
// to this instance yet).
|
||
const isValidationInvalid = computed(() => {
|
||
if (props.dialog?.type !== 'realm-edit') return false
|
||
const d = props.dialog.data
|
||
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
|
||
const bad = v => v === 'invalid' || v === 'invalid-domain' || v === 'validating'
|
||
if (d.originValidation?.some(bad) || d.relatedValidation?.some(bad)) return true
|
||
if (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||
return false
|
||
})
|
||
|
||
// Well-known URL that must list any related (cross-domain) origins.
|
||
// Browsers always fetch it from the rp-id domain, never the auth host.
|
||
const wellKnownUrl = computed(() => {
|
||
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||
return host ? `https://${host}/.well-known/webauthn` : ''
|
||
})
|
||
|
||
// Copy-to-clipboard helper
|
||
const authStore = useAuthStore()
|
||
function copyText(value, label) {
|
||
navigator.clipboard.writeText(value).then(() => {
|
||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||
})
|
||
}
|
||
|
||
// The two origin lists are separate concerns: an in-domain allow-list of
|
||
// sign-in sites, and cross-domain related origins (WebAuthn ROR).
|
||
const LIST_VALIDATION = { origins: 'originValidation', related_origins: 'relatedValidation' }
|
||
|
||
function addEntry(field) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
// Prefill the in-domain list with the rp-id; related domains start blank
|
||
d[field].push(field === 'origins' ? realmRpId.value : '')
|
||
d[LIST_VALIDATION[field]].push(null)
|
||
const i = d[field].length - 1
|
||
if (d[field][i]) validateEntry(field, i)
|
||
}
|
||
function removeEntry(field, i) {
|
||
const d = props.dialog?.data
|
||
if (d) {
|
||
d[field].splice(i, 1)
|
||
d[LIST_VALIDATION[field]].splice(i, 1)
|
||
}
|
||
}
|
||
function focusOriginStart(e) {
|
||
e.target.setSelectionRange(0, 0)
|
||
}
|
||
|
||
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'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function originHostname(origin) {
|
||
if (!origin.trim()) return null
|
||
try {
|
||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||
return url.hostname || null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function isWithinDomain(origin, rpId) {
|
||
const hostname = originHostname(origin)
|
||
if (!hostname) return false
|
||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||
}
|
||
|
||
async function validateEntryConnectivity(field, i) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const value = d[field][i]
|
||
const vlist = d[LIST_VALIDATION[field]]
|
||
|
||
vlist[i] = 'validating'
|
||
try {
|
||
const cleanValue = value.replace(/\/+$/, '')
|
||
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
|
||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||
method: 'GET',
|
||
headers: { 'Accept': 'application/json' }
|
||
})
|
||
if (d[field][i] !== value) return // entry changed while validating
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// Valid when the entry is served by this instance for the edited realm
|
||
vlist[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
||
} else {
|
||
vlist[i] = 'unreachable'
|
||
}
|
||
} catch (e) {
|
||
if (d[field][i] === value) {
|
||
vlist[i] = 'unreachable'
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateEntry(field, i) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
const value = d[field][i]
|
||
const vlist = d[LIST_VALIDATION[field]]
|
||
|
||
if (!originHostname(value)) {
|
||
vlist[i] = 'invalid'
|
||
return
|
||
}
|
||
// Each entry must be filed under the right list: the in-domain allow-list
|
||
// only covers the rp-id domain; related domains must be outside it.
|
||
const within = isWithinDomain(value, realmRpId.value)
|
||
if (field === 'origins' && !within) {
|
||
vlist[i] = 'invalid-domain'
|
||
return
|
||
}
|
||
if (field === 'related_origins' && within) {
|
||
vlist[i] = 'invalid-domain'
|
||
return
|
||
}
|
||
validateEntryConnectivity(field, i)
|
||
}
|
||
|
||
async function validateAuthHostConnectivity(authHost) {
|
||
const d = props.dialog?.data
|
||
if (!d) return
|
||
|
||
d.authHostValidation = 'validating'
|
||
try {
|
||
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
||
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||
method: 'GET',
|
||
headers: { 'Accept': 'application/json' }
|
||
})
|
||
if (d.auth_host !== authHost) return // auth_host changed while validating
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
d.authHostValidation = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
||
} else {
|
||
d.authHostValidation = 'unreachable'
|
||
}
|
||
} catch (e) {
|
||
if (d.auth_host === authHost) {
|
||
d.authHostValidation = 'unreachable'
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateAuthHost() {
|
||
const d = props.dialog?.data
|
||
if (!d || !d.auth_host?.trim()) {
|
||
if (d) d.authHostValidation = null // Allow empty
|
||
return
|
||
}
|
||
|
||
if (isWithinDomain(d.auth_host, realmRpId.value)) {
|
||
validateAuthHostConnectivity(d.auth_host)
|
||
} else {
|
||
d.authHostValidation = 'invalid-domain'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||
<h3 class="modal-title">
|
||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||
<template v-else-if="dialog.type==='role-create'">Create Role</template>
|
||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||
<template v-else-if="dialog.type==='realm-edit'">{{ dialog.data?.isNew ? 'Add Realm' : 'Edit Realm' }}</template>
|
||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||
</h3>
|
||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||
<template v-if="dialog.type==='org-create'">
|
||
<label>Name
|
||
<input ref="nameInput" v-model="dialog.data.name" required />
|
||
</label>
|
||
</template>
|
||
<template v-else-if="dialog.type==='org-update'">
|
||
<NameEditForm
|
||
label="Organization Name"
|
||
v-model="dialog.data.name"
|
||
:busy="dialog.busy"
|
||
:error="dialog.error"
|
||
@cancel="$emit('closeDialog')"
|
||
/>
|
||
</template>
|
||
<template v-else-if="dialog.type==='role-create'">
|
||
<label>Role Name
|
||
<input v-model="dialog.data.name" placeholder="Role name" required />
|
||
</label>
|
||
</template>
|
||
<template v-else-if="dialog.type==='role-update'">
|
||
<NameEditForm
|
||
label="Role Name"
|
||
v-model="dialog.data.name"
|
||
:busy="dialog.busy"
|
||
:error="dialog.error"
|
||
@cancel="$emit('closeDialog')"
|
||
/>
|
||
</template>
|
||
<template v-else-if="dialog.type==='user-create'">
|
||
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
|
||
<label>Display Name
|
||
<input v-model="dialog.data.name" placeholder="User display name" required />
|
||
</label>
|
||
</template>
|
||
<template v-else-if="dialog.type==='user-update-name'">
|
||
<NameEditForm
|
||
label="Display Name"
|
||
v-model="dialog.data.name"
|
||
:busy="dialog.busy"
|
||
:error="dialog.error"
|
||
@cancel="$emit('closeDialog')"
|
||
/>
|
||
</template>
|
||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">
|
||
<label>Display Name
|
||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||
</label>
|
||
<label>Permission Scope
|
||
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||
</label>
|
||
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
||
<label>Domain Scope
|
||
<input v-model="dialog.data.domain" data-form-type="other" />
|
||
</label>
|
||
<p class="small muted">A domain restricts this permission to that host (any configured realm's rp-id or a subdomain of it). An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||
</template>
|
||
<template v-else-if="dialog.type==='realm-edit'">
|
||
<template v-if="dialog.data.isNew">
|
||
<label>Domain (rp-id)
|
||
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
|
||
</label>
|
||
<p class="small muted">The domain name this realm's passkeys belong to — they work on this domain and its subdomains, and never on other realms. Cannot be changed later.</p>
|
||
</template>
|
||
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
|
||
<label>Display Name (rp-name)
|
||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||
</label>
|
||
<label>Dedicated Authentication Site (auth-host)
|
||
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation === 'invalid-domain' }" />
|
||
</label>
|
||
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Must be {{ dialog.data.rp_id }} or a subdomain of it.</p>
|
||
<p v-else-if="dialog.data.authHostValidation === 'unreachable'" class="small muted">Well-formed but unreachable — make sure it is routed to this instance.</p>
|
||
<p v-else-if="dialog.data.authHostValidation === 'mismatch'" class="small muted">Reachable, but does not serve this realm.</p>
|
||
<p v-else class="small muted">Optional. Moves the account and admin interface to this one hostname. Sign-in works on every site regardless.</p>
|
||
|
||
<div class="origin-label">
|
||
Allowed Sign-in Sites
|
||
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('origins')" aria-label="Add site" title="Add site">➕</button>
|
||
</div>
|
||
<div v-if="dialog.data.origins.length" class="origin-list">
|
||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||
<input
|
||
:value="dialog.data.origins[i]"
|
||
@input="e => { dialog.data.origins[i] = e.target.value; validateEntry('origins', i) }"
|
||
@focus="focusOriginStart"
|
||
class="origin-input"
|
||
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.originValidation[i]) }"
|
||
/>
|
||
<button type="button" class="icon-btn delete-icon" @click="removeEntry('origins', i)" aria-label="Remove site" title="Remove site">❌</button>
|
||
</div>
|
||
<p v-if="dialog.data.originValidation.some(v => v === 'invalid-domain')" class="small muted">Sites must be on {{ dialog.data.rp_id }} or a subdomain of it — use Related Domains below for other domain names.</p>
|
||
<p v-else-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 realm.</p>
|
||
</div>
|
||
<p v-if="!dialog.data.origins.length" class="small muted">All of <strong>{{ dialog.data.rp_id }}</strong> and its subdomains may sign in (default). Add entries to restrict sign-in to specific sites on this domain.</p>
|
||
<p v-else class="small muted">Only the listed sites may sign in with this realm's passkeys.</p>
|
||
|
||
<div class="origin-label">
|
||
Related Domains
|
||
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('related_origins')" aria-label="Add related domain" title="Add related domain">➕</button>
|
||
</div>
|
||
<div v-if="dialog.data.related_origins.length" class="origin-list">
|
||
<div v-for="(_, i) in dialog.data.related_origins" :key="i" class="origin-row">
|
||
<input
|
||
v-model="dialog.data.related_origins[i]"
|
||
@input="validateEntry('related_origins', i)"
|
||
placeholder="other-domain.com"
|
||
class="origin-input"
|
||
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.relatedValidation[i]) }"
|
||
/>
|
||
<button type="button" class="icon-btn delete-icon" @click="removeEntry('related_origins', i)" aria-label="Remove related domain" title="Remove related domain">❌</button>
|
||
</div>
|
||
<p v-if="dialog.data.relatedValidation.some(v => v === 'invalid-domain')" class="small muted">That entry is inside {{ dialog.data.rp_id }} — subdomains are already covered by the realm itself.</p>
|
||
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'unreachable')" class="small muted">Some domains are unreachable — make sure they are routed to this instance.</p>
|
||
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'mismatch')" class="small muted">Some domains are reachable but do not serve this realm.</p>
|
||
</div>
|
||
<p class="small muted">
|
||
Other domain names that may use this realm's passkeys (WebAuthn Related Origins, max 5). List only domains you trust as much as {{ dialog.data.rp_id }} itself.
|
||
<template v-if="dialog.data.related_origins.length">
|
||
Browsers verify the list at
|
||
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
|
||
— served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise copy the document there.
|
||
</template>
|
||
</p>
|
||
</template>
|
||
<template v-else-if="dialog.type==='confirm'">
|
||
<p>{{ dialog.data.message }}</p>
|
||
</template>
|
||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||
<button
|
||
type="button"
|
||
class="btn-secondary"
|
||
@click="$emit('closeDialog')"
|
||
:disabled="dialog.busy"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="btn-primary"
|
||
:disabled="dialog.busy || isValidationInvalid"
|
||
>
|
||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||
</button>
|
||
</div>
|
||
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
@click="$emit('closeDialog')"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
|
||
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
|
||
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
|
||
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
|
||
.oidc-groups { cursor: default; }
|
||
.oidc-group { cursor: pointer; }
|
||
.oidc-group output { white-space: normal; word-break: break-all; }
|
||
|
||
/* Server config origins */
|
||
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||
.origin-row .delete-icon { flex-shrink: 0; }
|
||
.origin-add-btn { font-size: 1.2rem; }
|
||
|
||
.input-error {
|
||
border-color: var(--color-error);
|
||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||
}
|
||
</style>
|