Admin OIDC Client editing moved to its own page.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user