Compare commits
No commits in common. "37eaffff3f93dca6ba894669ad318ff056a9e1e9" and "21035568f90300bde992f5a05b3efa03acc14aa1" have entirely different histories.
37eaffff3f
...
21035568f9
@ -23,7 +23,6 @@ const newPermId = ref('')
|
||||
const newPermName = ref('')
|
||||
const editingPermId = ref(null)
|
||||
const renameIdValue = ref('')
|
||||
const dialog = ref({ type: null, data: null, busy: false, error: '' })
|
||||
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
|
||||
|
||||
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
|
||||
@ -81,9 +80,28 @@ function availableOrgsForPermission(pid) {
|
||||
return orgs.value.filter(o => !o.permissions.includes(pid))
|
||||
}
|
||||
|
||||
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) }
|
||||
async function renamePermissionDisplay(p) {
|
||||
const newName = prompt('New display name', p.display_name)
|
||||
if (!newName || newName === p.display_name) return
|
||||
try {
|
||||
const body = { id: p.id, display_name: newName }
|
||||
const res = await fetch(`/auth/admin/permission?permission_id=${encodeURIComponent(p.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await refreshPermissionsContext()
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to rename display name')
|
||||
}
|
||||
}
|
||||
|
||||
function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id }
|
||||
function startRenamePermissionId(p) {
|
||||
editingPermId.value = p.id
|
||||
renameIdValue.value = p.id
|
||||
}
|
||||
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
|
||||
async function submitRenamePermissionId(p) {
|
||||
const newId = renameIdValue.value.trim()
|
||||
@ -94,7 +112,9 @@ async function submitRenamePermissionId(p) {
|
||||
let data; try { data = await res.json() } catch(_) { data = {} }
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
|
||||
await refreshPermissionsContext(); cancelRenameId()
|
||||
} catch (e) { authStore.showMessage(e?.message || 'Rename failed') }
|
||||
} catch (e) {
|
||||
alert(e?.message || 'Failed to rename permission id')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPermissionsContext() {
|
||||
@ -111,22 +131,21 @@ async function attachPermissionToOrg(pid, orgUuid) {
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to add permission to org')
|
||||
alert(e.message || 'Failed to add permission to org')
|
||||
}
|
||||
}
|
||||
|
||||
async function detachPermissionFromOrg(pid, orgUuid) {
|
||||
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
||||
}
|
||||
} })
|
||||
if (!confirm('Remove permission from this org?')) return
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to remove permission from org')
|
||||
}
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
@ -190,20 +209,56 @@ async function load() {
|
||||
}
|
||||
|
||||
// Org actions
|
||||
function createOrg() { openDialog('org-create', {}) }
|
||||
|
||||
function updateOrg(org) { openDialog('org-update', { org }) }
|
||||
|
||||
function deleteOrg(org) {
|
||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} })
|
||||
async function createOrg() {
|
||||
const name = prompt('New organization display name:')
|
||||
if (!name) return
|
||||
const res = await fetch('/auth/admin/orgs', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name, permissions: [] })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
}
|
||||
|
||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||
async function updateOrg(org) {
|
||||
const name = prompt('Organization display name:', org.display_name)
|
||||
if (!name) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name, permissions: org.permissions })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
async function deleteOrg(org) {
|
||||
if (!info.value?.is_global_admin) {
|
||||
alert('Only global admins may delete organizations.')
|
||||
return
|
||||
}
|
||||
if (!confirm(`Delete organization ${org.display_name}?`)) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
async function createUserInRole(org, role) {
|
||||
const displayName = prompt(`New member display name for role "${role.display_name}":`)
|
||||
if (!displayName) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: displayName, role: role.display_name })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
@ -213,7 +268,7 @@ async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
body: JSON.stringify({ role: targetRoleDisplayName })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) { authStore.showMessage(data.detail); return }
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
@ -237,22 +292,57 @@ function onRoleDrop(e, org, role) {
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// (legacy function retained but unused in UI)
|
||||
async function addOrgPermission() { /* obsolete */ }
|
||||
async function addOrgPermission(org) {
|
||||
const id = prompt('Permission ID to add:', permissions.value[0]?.id || '')
|
||||
if (!id) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permissions/${encodeURIComponent(id)}`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
async function removeOrgPermission() { /* obsolete */ }
|
||||
async function removeOrgPermission(org, permId) {
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permissions/${encodeURIComponent(permId)}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
// Role actions
|
||||
function createRole(org) { openDialog('role-create', { org }) }
|
||||
async function createRole(org) {
|
||||
const name = prompt('New role display name:')
|
||||
if (!name) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name, permissions: [] })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
function updateRole(role) { openDialog('role-update', { role }) }
|
||||
async function updateRole(role) {
|
||||
const name = prompt('Role display name:', role.display_name)
|
||||
if (!name) return
|
||||
const csv = prompt('Permission IDs (comma-separated):', role.permissions.join(', ')) || ''
|
||||
const perms = csv.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const res = await fetch(`/auth/admin/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name, permissions: perms })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
function deleteRole(role) {
|
||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
||||
const res = await fetch(`/auth/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} })
|
||||
async function deleteRole(role) {
|
||||
if (!confirm(`Delete role ${role.display_name}?`)) return
|
||||
const res = await fetch(`/auth/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
// Permission actions
|
||||
@ -261,20 +351,28 @@ async function submitCreatePermission() {
|
||||
const name = newPermName.value.trim()
|
||||
if (!id || !name) return
|
||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
|
||||
const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return }
|
||||
const data = await res.json(); if (data.detail) { alert(data.detail); return }
|
||||
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
|
||||
}
|
||||
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
|
||||
|
||||
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
||||
async function updatePermission(p) {
|
||||
const name = prompt('Permission display name:', p.display_name)
|
||||
if (!name) return
|
||||
const params = new URLSearchParams({ permission_id: p.id, display_name: name })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadPermissions()
|
||||
}
|
||||
|
||||
function deletePermission(p) {
|
||||
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
||||
const params = new URLSearchParams({ permission_id: p.id })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadPermissions()
|
||||
} })
|
||||
async function deletePermission(p) {
|
||||
if (!confirm(`Delete permission ${p.id}?`)) return
|
||||
const params = new URLSearchParams({ permission_id: p.id })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) return alert(data.detail)
|
||||
await loadPermissions()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -356,80 +454,10 @@ async function toggleRolePermission(role, permId, checked) {
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||
alert(e.message || 'Failed to update role permission')
|
||||
role.permissions = prev // revert
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
||||
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
||||
|
||||
// Admin user rename
|
||||
const editingUserName = ref(false)
|
||||
const editUserNameValue = ref('')
|
||||
const editUserNameValid = computed(()=> editUserNameValue.value.trim().length > 0 && editUserNameValue.value.trim().length <= 64)
|
||||
function beginEditUserName() {
|
||||
if (!selectedUser.value) return
|
||||
editingUserName.value = true
|
||||
editUserNameValue.value = userDetail.value?.display_name || selectedUser.value.display_name || ''
|
||||
}
|
||||
function cancelEditUserName() { editingUserName.value = false }
|
||||
async function submitEditUserName() {
|
||||
if (!editingUserName.value || !editUserNameValid.value) return
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/users/${selectedUser.value.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: editUserNameValue.value.trim() }) })
|
||||
const data = await res.json(); if (!res.ok || data.detail) throw new Error(data.detail || 'Rename failed')
|
||||
editingUserName.value = false
|
||||
await loadOrgs()
|
||||
const r = await fetch(`/auth/admin/users/${selectedUser.value.uuid}`)
|
||||
const jd = await r.json(); if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed')
|
||||
userDetail.value = jd
|
||||
authStore.showMessage('User renamed', 'success', 1500)
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Rename failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!dialog.value.type || dialog.value.busy) return
|
||||
dialog.value.busy = true; dialog.value.error = ''
|
||||
try {
|
||||
const t = dialog.value.type
|
||||
if (t === 'org-create') {
|
||||
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch('/auth/admin/orgs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await Promise.all([loadOrgs(), loadPermissions()])
|
||||
} else if (t === 'org-update') {
|
||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: org.permissions }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'role-create') {
|
||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'role-update') {
|
||||
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const permsCsv = dialog.value.data.perms || ''
|
||||
const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean)
|
||||
const res = await fetch(`/auth/admin/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'user-create') {
|
||||
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'perm-display') {
|
||||
const { permission } = dialog.value.data; const display = dialog.value.data.display_name?.trim(); if (!display) throw new Error('Display name required')
|
||||
const params = new URLSearchParams({ permission_id: permission.id, display_name: display })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadPermissions()
|
||||
} else if (t === 'confirm') {
|
||||
const action = dialog.value.data.action; if (action) await action()
|
||||
}
|
||||
closeDialog()
|
||||
} catch (e) {
|
||||
dialog.value.error = e.message || 'Error'
|
||||
} finally { dialog.value.busy = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -484,14 +512,7 @@ async function submitDialog() {
|
||||
|
||||
<!-- User Detail Page -->
|
||||
<div v-if="selectedUser" class="card user-detail">
|
||||
<h2 class="user-title">
|
||||
<span v-if="!editingUserName">{{ userDetail?.display_name || selectedUser.display_name }} <button class="icon-btn" @click="beginEditUserName" title="Rename user">✏️</button></span>
|
||||
<span v-else>
|
||||
<input v-model="editUserNameValue" maxlength="64" @keyup.enter="submitEditUserName" />
|
||||
<button class="icon-btn" @click="submitEditUserName" :disabled="!editUserNameValid">💾</button>
|
||||
<button class="icon-btn" @click="cancelEditUserName">✖</button>
|
||||
</span>
|
||||
</h2>
|
||||
<h2 class="user-title"><span>{{ userDetail?.display_name || selectedUser.display_name }}</span></h2>
|
||||
<div v-if="userDetail && !userDetail.error" class="user-meta">
|
||||
<p class="small">Organization: {{ userDetail.org.display_name }}</p>
|
||||
<p class="small">Role: {{ userDetail.role }}</p>
|
||||
@ -574,7 +595,7 @@ async function submitDialog() {
|
||||
<div class="role-header">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
<span>{{ r.display_name }}</span>
|
||||
<button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
||||
<button @click="updateRole(r)" class="icon-btn" aria-label="Rename role" title="Rename role">✏️</button>
|
||||
</strong>
|
||||
<div class="role-actions">
|
||||
<button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
@ -662,18 +683,16 @@ async function submitDialog() {
|
||||
</div>
|
||||
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
|
||||
<div class="perm-cell perm-actions center">
|
||||
<div class="perm-actions-inner" :class="{ editing: editingPermId === p.id }">
|
||||
<div class="actions-view">
|
||||
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button>
|
||||
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
|
||||
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
</div>
|
||||
<form class="inline-id-form overlay" @submit.prevent="submitRenamePermissionId(p)">
|
||||
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
|
||||
<button type="submit" class="icon-btn" aria-label="Save">✔</button>
|
||||
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel">✖</button>
|
||||
</form>
|
||||
</div>
|
||||
<template v-if="editingPermId !== p.id">
|
||||
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button>
|
||||
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
|
||||
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
</template>
|
||||
<form v-else class="inline-id-form" @submit.prevent="submitRenamePermissionId(p)">
|
||||
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
|
||||
<button type="submit" class="icon-btn" aria-label="Save">✔</button>
|
||||
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel">✖</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -682,59 +701,6 @@ async function submitDialog() {
|
||||
</div>
|
||||
</div>
|
||||
<StatusMessage />
|
||||
<div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="closeDialog" tabindex="-1">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<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==='perm-display'">Edit Permission Display</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="submitDialog" class="modal-form">
|
||||
<template v-if="dialog.type==='org-create' || dialog.type==='org-update'">
|
||||
<label>Name
|
||||
<input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required />
|
||||
</label>
|
||||
</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'">
|
||||
<label>Role Name
|
||||
<input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required />
|
||||
</label>
|
||||
<label>Permissions (comma separated)
|
||||
<textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea>
|
||||
</label>
|
||||
</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==='perm-display'">
|
||||
<p class="small muted">ID: {{ dialog.data.permission.id }}</p>
|
||||
<label>Display Name
|
||||
<input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
<div v-if="dialog.error" class="error small">{{ dialog.error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button>
|
||||
<button type="button" @click="closeDialog" :disabled="dialog.busy">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -832,27 +798,6 @@ button, .perm-actions button, .org-actions button, .role-actions button { width:
|
||||
.permission-grid .center { justify-content: center; }
|
||||
.permission-grid .perm-actions { gap: .25rem; }
|
||||
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
|
||||
/* Inline edit overlay to avoid layout shift */
|
||||
.perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; }
|
||||
.perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; }
|
||||
.perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; }
|
||||
.perm-actions-inner.editing .actions-view { visibility:hidden; }
|
||||
/* Inline forms */
|
||||
.inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; }
|
||||
.inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; }
|
||||
.inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; }
|
||||
.inline-id-form .id-input { width:120px; }
|
||||
/* Modal */
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; }
|
||||
.modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; }
|
||||
@keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } }
|
||||
.modal-title { margin:0 0 .65rem; font-size:1rem; }
|
||||
.modal-form { display:flex; flex-direction:column; gap:.65rem; }
|
||||
.modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; }
|
||||
.modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; }
|
||||
.modal-form textarea { resize:vertical; }
|
||||
.modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; }
|
||||
.modal-actions button { font-size:.65rem; }
|
||||
/* Org pill editing */
|
||||
.perm-orgs { flex-wrap: wrap; gap: .25rem; }
|
||||
.perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; }
|
||||
|
@ -3,15 +3,7 @@
|
||||
<div class="view active">
|
||||
<h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1>
|
||||
<div v-if="authStore.userInfo?.user" class="user-info">
|
||||
<h3>
|
||||
👤
|
||||
<template v-if="!editingName">{{ authStore.userInfo.user.user_name }} <button class="mini-btn" @click="startEdit" title="Edit name">✏️</button></template>
|
||||
<template v-else>
|
||||
<input v-model="newName" :disabled="authStore.isLoading" maxlength="64" @keyup.enter="saveName" />
|
||||
<button class="mini-btn" @click="saveName" :disabled="!validName || authStore.isLoading">💾</button>
|
||||
<button class="mini-btn" @click="cancelEdit" :disabled="authStore.isLoading">✖</button>
|
||||
</template>
|
||||
</h3>
|
||||
<h3>👤 {{ authStore.userInfo.user.user_name }}</h3>
|
||||
<span><strong>Visits:</strong></span>
|
||||
<span>{{ authStore.userInfo.user.visits || 0 }}</span>
|
||||
<span><strong>Registered:</strong></span>
|
||||
@ -155,25 +147,6 @@ const logout = async () => {
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
||||
|
||||
// Name editing state & actions
|
||||
const editingName = ref(false)
|
||||
const newName = ref('')
|
||||
const validName = computed(() => newName.value.trim().length > 0 && newName.value.trim().length <= 64)
|
||||
function startEdit() { editingName.value = true; newName.value = authStore.userInfo?.user?.user_name || '' }
|
||||
function cancelEdit() { editingName.value = false }
|
||||
async function saveName() {
|
||||
if (!validName.value) return
|
||||
try {
|
||||
authStore.isLoading = true
|
||||
const res = await fetch('/auth/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: newName.value.trim() }) })
|
||||
const data = await res.json(); if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
await authStore.loadUserInfo()
|
||||
editingName.value = false
|
||||
authStore.showMessage('Name updated', 'success', 1500)
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { authStore.isLoading = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -188,7 +161,6 @@ async function saveName() {
|
||||
.user-info span {
|
||||
text-align: left;
|
||||
}
|
||||
.mini-btn { font-size: 0.7em; margin-left: 0.3em; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
|
@ -2,17 +2,8 @@
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🔑 Add New Credential</h1>
|
||||
<label class="name-edit">
|
||||
<span>👤 Name:</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="user_name"
|
||||
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
|
||||
:disabled="authStore.isLoading"
|
||||
maxlength="64"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
</label>
|
||||
<h3>👤 {{ authStore.userInfo?.user?.user_name }}</h3>
|
||||
<!-- TODO: allow editing name <input type="text" v-model="user_name" required :disabled="authStore.isLoading"> -->
|
||||
<p>Proceed to complete {{authStore.userInfo?.session_type}}:</p>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@ -28,26 +19,15 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const user_name = ref('')
|
||||
|
||||
// Initialize local name from store (once loaded)
|
||||
watchEffect(() => {
|
||||
if (!user_name.value && authStore.userInfo?.user?.user_name) {
|
||||
user_name.value = authStore.userInfo.user.user_name
|
||||
}
|
||||
})
|
||||
|
||||
async function register() {
|
||||
authStore.isLoading = true
|
||||
authStore.showMessage('Starting registration...', 'info')
|
||||
|
||||
try {
|
||||
const trimmed = (user_name.value || '').trim()
|
||||
const nameToSend = trimmed.length ? trimmed : null
|
||||
const result = await passkey.register(authStore.resetToken, nameToSend)
|
||||
const result = await passkey.register(authStore.resetToken)
|
||||
console.log("Result", result)
|
||||
await authStore.setSessionCookie(result.session_token)
|
||||
// resetToken cleared by setSessionCookie; ensure again
|
||||
|
@ -1,12 +1,8 @@
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
|
||||
export async function register(resetToken = null, displayName = null) {
|
||||
let params = []
|
||||
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
||||
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
||||
const qs = params.length ? `?${params.join('&')}` : ''
|
||||
const url = `/auth/ws/register${qs}`
|
||||
export async function register(resetToken = null) {
|
||||
const url = resetToken ? `/auth/ws/register?reset=${encodeURIComponent(resetToken)}` : "/auth/ws/register"
|
||||
const ws = await aWebSocket(url)
|
||||
try {
|
||||
const optionsJSON = await ws.receive_json()
|
||||
|
@ -100,10 +100,6 @@ class DatabaseInterface(ABC):
|
||||
async def create_user(self, user: User) -> None:
|
||||
"""Create a new user."""
|
||||
|
||||
@abstractmethod
|
||||
async def update_user_display_name(self, user_uuid: UUID, display_name: str) -> None:
|
||||
"""Update a user's display name."""
|
||||
|
||||
# Role operations
|
||||
@abstractmethod
|
||||
async def create_role(self, role: Role) -> None:
|
||||
@ -316,27 +312,6 @@ class DatabaseInterface(ABC):
|
||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
||||
"""Get complete session context including user, organization, role, and permissions."""
|
||||
|
||||
# Combined atomic operations
|
||||
@abstractmethod
|
||||
async def create_credential_session(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
session_expires: datetime,
|
||||
session_info: dict,
|
||||
display_name: str | None = None,
|
||||
) -> None:
|
||||
"""Atomically add a credential and create a session.
|
||||
|
||||
Steps (single transaction):
|
||||
1. Insert credential
|
||||
2. Optionally delete old session (e.g. reset token) if provided
|
||||
3. Optionally update user's display name
|
||||
4. Insert new session referencing the credential
|
||||
"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
|
@ -271,17 +271,6 @@ class DB(DatabaseInterface):
|
||||
async with self.session() as session:
|
||||
session.add(UserModel.from_dataclass(user))
|
||||
|
||||
async def update_user_display_name(self, user_uuid: UUID, display_name: str) -> None:
|
||||
async with self.session() as session:
|
||||
stmt = (
|
||||
update(UserModel)
|
||||
.where(UserModel.uuid == user_uuid.bytes)
|
||||
.values(display_name=display_name)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount == 0: # type: ignore[attr-defined]
|
||||
raise ValueError("User not found")
|
||||
|
||||
async def create_role(self, role: Role) -> None:
|
||||
async with self.session() as session:
|
||||
# Create role record
|
||||
@ -400,55 +389,6 @@ class DB(DatabaseInterface):
|
||||
)
|
||||
session.add(credential_model)
|
||||
|
||||
async def create_credential_session(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
session_expires: datetime,
|
||||
session_info: dict,
|
||||
display_name: str | None = None,
|
||||
) -> None:
|
||||
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
|
||||
async with self.session() as session:
|
||||
# Insert credential
|
||||
session.add(
|
||||
CredentialModel(
|
||||
uuid=credential.uuid.bytes,
|
||||
credential_id=credential.credential_id,
|
||||
user_uuid=credential.user_uuid.bytes,
|
||||
aaguid=credential.aaguid.bytes,
|
||||
public_key=credential.public_key,
|
||||
sign_count=credential.sign_count,
|
||||
created_at=credential.created_at,
|
||||
last_used=credential.last_used,
|
||||
last_verified=credential.last_verified,
|
||||
)
|
||||
)
|
||||
# Delete old session if provided
|
||||
if reset_key:
|
||||
await session.execute(
|
||||
delete(SessionModel).where(SessionModel.key == reset_key)
|
||||
)
|
||||
# Optional rename
|
||||
if display_name:
|
||||
await session.execute(
|
||||
update(UserModel)
|
||||
.where(UserModel.uuid == user_uuid.bytes)
|
||||
.values(display_name=display_name)
|
||||
)
|
||||
# New session
|
||||
session.add(
|
||||
SessionModel(
|
||||
key=session_key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
credential_uuid=credential.uuid.bytes,
|
||||
expires=session_expires,
|
||||
info=session_info,
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None:
|
||||
async with self.session() as session:
|
||||
stmt = (
|
||||
|
@ -487,40 +487,6 @@ def register_api_routes(app: FastAPI):
|
||||
"aaguid_info": aaguid_info,
|
||||
}
|
||||
|
||||
@app.put("/auth/user/display-name")
|
||||
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
||||
"""Authenticated user updates their own display name."""
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||
s = await get_session(auth)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/auth/admin/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
):
|
||||
"""Admin updates a user's display name."""
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
await db.instance.update_user_display_name(user_uuid, new_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Admin API: Permissions (global)
|
||||
|
||||
@app.get("/auth/admin/permissions")
|
||||
|
@ -5,10 +5,9 @@ from uuid import UUID
|
||||
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from ..authsession import create_session, expires, get_reset, get_session
|
||||
from ..authsession import create_session, get_reset, get_session
|
||||
from ..globals import db, passkey
|
||||
from ..util import passphrase
|
||||
from ..util.tokens import create_token, session_key
|
||||
from .session import infodict
|
||||
|
||||
|
||||
@ -56,7 +55,7 @@ async def register_chat(
|
||||
@app.websocket("/register")
|
||||
@websocket_error_handler
|
||||
async def websocket_register_add(
|
||||
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
|
||||
ws: WebSocket, reset: str | None = None, auth=Cookie(None)
|
||||
):
|
||||
"""Register a new credential for an existing user.
|
||||
|
||||
@ -65,9 +64,11 @@ async def websocket_register_add(
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
is_reset = False
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
is_reset = True
|
||||
s = await get_reset(reset)
|
||||
else:
|
||||
if not auth:
|
||||
@ -75,30 +76,23 @@ async def websocket_register_add(
|
||||
s = await get_session(auth)
|
||||
user_uuid = s.user_uuid
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
# Get user information to get the user_name
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
user_name = user.display_name
|
||||
if name is not None:
|
||||
stripped = name.strip()
|
||||
if stripped:
|
||||
user_name = stripped
|
||||
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
||||
# IMPORTANT: Insert the credential before creating a session that references it
|
||||
# to satisfy the sessions.credential_uuid foreign key (now enforced).
|
||||
await db.instance.create_credential(credential)
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
await db.instance.create_credential_session( # type: ignore[attr-defined]
|
||||
user_uuid=user_uuid,
|
||||
credential=credential,
|
||||
reset_key=(s.key if reset is not None else None),
|
||||
session_key=session_key(token),
|
||||
session_expires=expires(),
|
||||
session_info=infodict(ws, "authenticated"),
|
||||
display_name=user_name,
|
||||
)
|
||||
auth = token
|
||||
if is_reset:
|
||||
# Invalidate the one-time reset session only after credential persisted
|
||||
await db.instance.delete_session(s.key)
|
||||
auth = await create_session(
|
||||
user_uuid, credential.uuid, infodict(ws, "authenticated")
|
||||
)
|
||||
|
||||
assert isinstance(auth, str) and len(auth) == 16
|
||||
await ws.send_json(
|
||||
|
@ -7,5 +7,4 @@ def assert_safe(value: str, *, field: str = "value") -> None:
|
||||
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
||||
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
|
||||
|
||||
|
||||
__all__ = ["assert_safe"]
|
||||
|
Loading…
x
Reference in New Issue
Block a user