Admin OIDC Client editing moved to its own page.

This commit is contained in:
Leo Vasanko
2026-02-16 20:32:55 +00:00
parent 09049d3094
commit 30aeb9a310
12 changed files with 603 additions and 173 deletions
+280
View File
@@ -0,0 +1,280 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
client: Object,
permissions: Array,
isNew: { type: Boolean, default: false },
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
const authStore = useAuthStore()
const headerRef = ref(null)
// Local form state
const name = ref('')
const redirectUris = ref('')
const clientSecret = ref(null)
// Computed
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
// Groups (permissions) scoped to this client
const clientGroups = computed(() => {
if (!props.client || !props.permissions) return []
const clientUuid = props.client.uuid || props.client.client_id
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
})
// Initialize form data from props
watch(() => props.client, (c) => {
if (c) {
name.value = c.name || ''
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
clientSecret.value = c.client_secret || null
}
}, { immediate: true })
// Copy-to-clipboard helper
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function handleResetSecret() {
emit('resetSecret', clientId.value)
}
// When parent resets secret, update local state
watch(() => props.client?.client_secret, (newSecret) => {
if (newSecret) {
clientSecret.value = newSecret
}
})
function handleSave() {
const trimmedName = name.value.trim()
if (!trimmedName) {
authStore.showMessage('Client name is required', 'error')
return
}
const uris = redirectUris.value.trim()
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
emit('save', {
client_id: clientId.value,
client_secret: clientSecret.value,
name: trimmedName,
redirect_uris,
isNew: props.isNew
})
}
function handleDelete() {
emit('delete', props.client)
}
function handleCreatePermission() {
emit('createPermission', clientId.value)
}
function handleCancel() {
emit('cancel')
}
// Keyboard navigation
function handleHeaderKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
}
}
function focusFirstElement() {
const firstFocusable = headerRef.value?.querySelector('button, a, input')
if (firstFocusable) firstFocusable.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="oidc-detail">
<div class="oidc-header" ref="headerRef" @keydown="handleHeaderKeydown">
<h2>{{ isNew ? 'New OIDC Client' : (client?.name || 'OIDC Client') }}</h2>
</div>
<form @submit.prevent="handleSave" class="oidc-form">
<!-- Client credentials section -->
<section class="oidc-credentials">
<h3>Client Credentials</h3>
<p class="section-description">Configure these values in the client application.</p>
<dl class="oidc-dl">
<dt>Auth Name (opt)</dt>
<dd @click="copyText('paskia', 'Authentication Name')" title="Click to copy"><output>paskia</output></dd>
<dt>Discovery URL</dt>
<dd @click="copyText(discoveryUrl, 'OpenID Connect Discovery URL')" title="Click to copy"><output>{{ discoveryUrl }}</output></dd>
<dt>Client ID</dt>
<dd @click="copyText(clientId, 'Client ID')" title="Click to copy"><output>{{ clientId }}</output></dd>
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
<dd v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy"><output>{{ clientSecret }}</output></dd>
<dd v-else class="muted small">(only stored in hashed form)</dd>
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client"></button></dt>
<dd class="oidc-groups">
<template v-if="clientGroups.length">
<div v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">
<output>{{ group.scope }}</output>
</div>
</template>
<span v-else class="small muted">No permissions defined.</span>
</dd>
</dl>
<p v-if="clientSecret && isNew" class="warning-text"><strong> Save the secret now it cannot be retrieved later.</strong></p>
<p v-else-if="clientSecret && !isNew" class="warning-text"><strong> Saving will prevent access with the old secret.</strong></p>
</section>
<hr class="oidc-divider" />
<!-- Editable fields -->
<section class="oidc-settings">
<h3>Client Settings</h3>
<label>Client Name
<input v-model="name" placeholder="My Application" required />
</label>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
</label>
</section>
<!-- Actions -->
<div class="oidc-actions">
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</template>
<style scoped>
.oidc-detail {
max-width: 600px;
}
.oidc-header {
margin-bottom: var(--space-lg);
}
.oidc-header h2 {
margin: 0;
}
.oidc-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-credentials,
.oidc-settings {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.oidc-credentials h3,
.oidc-settings h3 {
margin: 0 0 var(--space-xs) 0;
font-size: 1rem;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.section-description {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.oidc-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
align-items: baseline;
margin: var(--space-sm) 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;
}
.warning-text {
font-size: 0.9rem;
margin: var(--space-sm) 0 0 0;
}
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
.oidc-divider {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-sm) 0;
}
.oidc-settings label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.oidc-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
}
</style>