initial commit
This commit is contained in:
565
app/pages/members.vue
Normal file
565
app/pages/members.vue
Normal file
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
|
||||
Members
|
||||
</h2>
|
||||
<p
|
||||
v-if="!user"
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
Sign in to view members.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!canViewMembers"
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
You don't have access to the members list.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="isAdmin"
|
||||
class="mb-3 text-xs text-kestrel-muted"
|
||||
>
|
||||
Only admins can change roles and manage local users. OIDC users are managed via your identity provider.
|
||||
</p>
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="mb-3 flex justify-start"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-accent bg-kestrel-accent/10 px-3 py-1.5 text-sm font-medium text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
@click="openAddUserModal"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Identifier
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Auth
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2 font-medium text-kestrel-text"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
class="border-b border-kestrel-border"
|
||||
>
|
||||
<td class="px-4 py-2 text-kestrel-text">
|
||||
{{ u.identifier }}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
|
||||
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
|
||||
>
|
||||
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
:ref="el => setDropdownWrapRef(u.id, el)"
|
||||
class="relative inline-block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
|
||||
:aria-expanded="openRoleDropdownId === u.id"
|
||||
:aria-haspopup="true"
|
||||
aria-label="Change role"
|
||||
@click.stop="toggleRoleDropdown(u.id)"
|
||||
>
|
||||
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="openRoleDropdownId === u.id && 'rotate-180'"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-kestrel-muted"
|
||||
>{{ u.role }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="roleByUserId[u.id] !== u.role"
|
||||
type="button"
|
||||
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
@click="saveRole(u.id)"
|
||||
>
|
||||
Save role
|
||||
</button>
|
||||
<template v-if="u.auth_provider !== 'oidc'">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
|
||||
@click="openEditUser(u)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
v-if="u.id !== user?.id"
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
|
||||
@click="openDeleteConfirm(u)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="openRoleDropdownId && dropdownPlacement"
|
||||
ref="dropdownMenuRef"
|
||||
role="menu"
|
||||
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
|
||||
:style="{
|
||||
top: `${dropdownPlacement.top}px`,
|
||||
left: `${dropdownPlacement.left}px`,
|
||||
minWidth: `${dropdownPlacement.minWidth}px`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="role in roleOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
|
||||
:class="roleByUserId[openRoleDropdownId] === role
|
||||
? 'bg-kestrel-accent-dim text-kestrel-accent'
|
||||
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click.stop="selectRole(openRoleDropdownId, role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<!-- Add user modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="addUserModalOpen"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-user-title"
|
||||
@click.self="closeAddUserModal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
id="add-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Add user
|
||||
</h3>
|
||||
<form @submit.prevent="submitAddUser">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Username</label>
|
||||
<input
|
||||
id="add-identifier"
|
||||
v-model="newUser.identifier"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Password</label>
|
||||
<input
|
||||
id="add-password"
|
||||
v-model="newUser.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-role"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Role</label>
|
||||
<select
|
||||
id="add-role"
|
||||
v-model="newUser.role"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
>
|
||||
<option value="member">
|
||||
member
|
||||
</option>
|
||||
<option value="leader">
|
||||
leader
|
||||
</option>
|
||||
<option value="admin">
|
||||
admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
v-if="createError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ createError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
@click="closeAddUserModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Delete user confirmation modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="deleteConfirmUser"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-user-title"
|
||||
@click.self="deleteConfirmUser = null"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
id="delete-user-title"
|
||||
class="mb-2 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Delete user?
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
Are you sure you want to delete <strong class="text-kestrel-text">{{ deleteConfirmUser?.identifier }}</strong>? They will not be able to sign in again.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
@click="deleteConfirmUser = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
|
||||
@click="confirmDeleteUser"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="editUserModal"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="editUserModal = null"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-user-title"
|
||||
>
|
||||
<h3
|
||||
id="edit-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Edit local user
|
||||
</h3>
|
||||
<form @submit.prevent="submitEditUser">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Identifier</label>
|
||||
<input
|
||||
id="edit-identifier"
|
||||
v-model="editForm.identifier"
|
||||
type="text"
|
||||
required
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>New password (leave blank to keep)</label>
|
||||
<input
|
||||
id="edit-password"
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<p class="mt-0.5 text-xs text-kestrel-muted">
|
||||
If you change your password, use the new one next time you sign in.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="editError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ editError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
|
||||
@click="editUserModal = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user, isAdmin, refresh: refreshUser } = useUser()
|
||||
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
|
||||
|
||||
const { data: usersData, refresh: refreshUsers } = useAsyncData(
|
||||
'users',
|
||||
() => $fetch('/api/users').catch(() => []),
|
||||
{ default: () => [] },
|
||||
)
|
||||
const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : []))
|
||||
|
||||
const roleOptions = ['admin', 'leader', 'member']
|
||||
const pendingRoleUpdates = ref({})
|
||||
const roleByUserId = computed(() => {
|
||||
const base = Object.fromEntries(users.value.map(u => [u.id, u.role]))
|
||||
return { ...base, ...pendingRoleUpdates.value }
|
||||
})
|
||||
const openRoleDropdownId = ref(null)
|
||||
const dropdownWrapRefs = ref({})
|
||||
const dropdownPlacement = ref(null)
|
||||
const dropdownMenuRef = ref(null)
|
||||
|
||||
const addUserModalOpen = ref(false)
|
||||
const newUser = ref({ identifier: '', password: '', role: 'member' })
|
||||
const createError = ref('')
|
||||
const editUserModal = ref(null)
|
||||
const editForm = ref({ identifier: '', password: '' })
|
||||
const editError = ref('')
|
||||
const deleteConfirmUser = ref(null)
|
||||
|
||||
function setDropdownWrapRef(userId, el) {
|
||||
if (el) dropdownWrapRefs.value[userId] = el
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
else delete dropdownWrapRefs.value[userId]
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers()
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleRoleDropdown(userId) {
|
||||
if (openRoleDropdownId.value === userId) {
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
return
|
||||
}
|
||||
openRoleDropdownId.value = userId
|
||||
nextTick(() => {
|
||||
const wrap = dropdownWrapRefs.value[userId]
|
||||
if (wrap) {
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
dropdownPlacement.value = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
minWidth: Math.max(rect.width, 96),
|
||||
}
|
||||
}
|
||||
else {
|
||||
dropdownPlacement.value = { top: 0, left: 0, minWidth: 96 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function selectRole(userId, role) {
|
||||
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
}
|
||||
|
||||
function onDocumentClick(e) {
|
||||
const openId = openRoleDropdownId.value
|
||||
if (openId == null) return
|
||||
const wrap = dropdownWrapRefs.value[openId]
|
||||
const menu = dropdownMenuRef.value
|
||||
const inTrigger = wrap && wrap.contains(e.target)
|
||||
const inMenu = menu && menu.contains(e.target)
|
||||
if (!inTrigger && !inMenu) {
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
async function saveRole(id) {
|
||||
const role = roleByUserId.value[id]
|
||||
if (!role) return
|
||||
try {
|
||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body: { role } })
|
||||
await refreshUsers()
|
||||
const { [id]: _, ...rest } = pendingRoleUpdates.value
|
||||
pendingRoleUpdates.value = rest
|
||||
}
|
||||
catch {
|
||||
// could set error state
|
||||
}
|
||||
}
|
||||
|
||||
function openAddUserModal() {
|
||||
addUserModalOpen.value = true
|
||||
newUser.value = { identifier: '', password: '', role: 'member' }
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
function closeAddUserModal() {
|
||||
addUserModalOpen.value = false
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
async function submitAddUser() {
|
||||
createError.value = ''
|
||||
try {
|
||||
await $fetch('/api/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
identifier: newUser.value.identifier.trim(),
|
||||
password: newUser.value.password,
|
||||
role: newUser.value.role,
|
||||
},
|
||||
})
|
||||
closeAddUserModal()
|
||||
await refreshUsers()
|
||||
}
|
||||
catch (e) {
|
||||
createError.value = e.data?.message || e.message || 'Failed to create user'
|
||||
}
|
||||
}
|
||||
|
||||
function openEditUser(u) {
|
||||
editUserModal.value = u
|
||||
editForm.value = { identifier: u.identifier, password: '' }
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
async function submitEditUser() {
|
||||
if (!editUserModal.value) return
|
||||
editError.value = ''
|
||||
const id = editUserModal.value.id
|
||||
const body = { identifier: editForm.value.identifier.trim() }
|
||||
if (editForm.value.password) body.password = editForm.value.password
|
||||
try {
|
||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
|
||||
editUserModal.value = null
|
||||
await refreshUsers()
|
||||
// If you edited yourself, refresh current user so the header/nav shows the new identifier
|
||||
await refreshUser()
|
||||
}
|
||||
catch (e) {
|
||||
editError.value = e.data?.message || e.message || 'Failed to update user'
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(u) {
|
||||
deleteConfirmUser.value = u
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
const u = deleteConfirmUser.value
|
||||
if (!u) return
|
||||
try {
|
||||
await $fetch(`/api/users/${u.id}`, { method: 'DELETE' })
|
||||
deleteConfirmUser.value = null
|
||||
await refreshUsers()
|
||||
}
|
||||
catch (e) {
|
||||
alert(e.data?.message || e.message || 'Failed to remove user')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user