minor: new nav system (#5)
All checks were successful
ci/woodpecker/push/push Pipeline was successful

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-15 04:04:54 +00:00
parent 9261ba92bf
commit 0aab29ea72
27 changed files with 1198 additions and 688 deletions

View File

@@ -4,6 +4,51 @@
Account
</h2>
<section
v-if="user"
class="mb-8"
>
<h3 class="kestrel-section-label">
Profile photo
</h3>
<div class="kestrel-card flex items-center gap-4 p-4">
<div class="flex h-16 w-16 shrink-0 overflow-hidden rounded-full border border-kestrel-border bg-kestrel-border">
<img
v-if="user.avatar_url"
:src="`${user.avatar_url}${avatarBust ? `?t=${avatarBust}` : ''}`"
alt=""
class="h-full w-full object-cover"
>
<span
v-else
class="flex h-full w-full items-center justify-center text-lg font-medium text-kestrel-text"
>
{{ accountInitials }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<label class="kestrel-btn-secondary cursor-pointer">
<input
type="file"
accept="image/jpeg,image/png"
class="sr-only"
:disabled="avatarLoading"
@change="onAvatarFileChange"
>
{{ avatarLoading ? 'Uploading…' : 'Upload' }}
</label>
<button
type="button"
class="kestrel-btn-secondary disabled:opacity-50"
:disabled="avatarLoading || !user.avatar_url"
@click="onRemoveAvatar"
>
Remove
</button>
</div>
</div>
</section>
<section class="mb-8">
<h3 class="kestrel-section-label">
Profile
@@ -126,8 +171,10 @@
</template>
<script setup>
const { user } = useUser()
const { user, refresh } = useUser()
const avatarBust = ref(0)
const avatarLoading = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
@@ -135,6 +182,45 @@ const passwordLoading = ref(false)
const passwordSuccess = ref(false)
const passwordError = ref('')
const accountInitials = computed(() => {
const id = user.value?.identifier ?? ''
const parts = id.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return id.slice(0, 2).toUpperCase() || '?'
})
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
avatarLoading.value = true
try {
const form = new FormData()
form.append('avatar', file, file.name)
await $fetch('/api/me/avatar', { method: 'PUT', body: form, credentials: 'include' })
avatarBust.value = Date.now()
await refresh()
}
catch {
// Error surfaced by refresh or network
}
finally {
avatarLoading.value = false
e.target.value = ''
}
}
async function onRemoveAvatar() {
avatarLoading.value = true
try {
await $fetch('/api/me/avatar', { method: 'DELETE', credentials: 'include' })
avatarBust.value = Date.now()
await refresh()
}
finally {
avatarLoading.value = false
}
}
async function onChangePassword() {
passwordError.value = ''
passwordSuccess.value = false

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
<div class="relative h-2/3 w-full md:h-full md:flex-1">
<div class="flex h-full w-full flex-col md:flex-row">
<div class="relative min-h-0 flex-1">
<ClientOnly>
<KestrelMap
:devices="devices ?? []"

View File

@@ -34,357 +34,38 @@
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 shadow-glow-dropdown"
: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>
<MembersTable
:users="users"
:role-by-user-id="roleByUserId"
:role-options="roleOptions"
:is-admin="isAdmin"
:current-user-id="user?.id ?? null"
:open-role-dropdown-id="openRoleDropdownId"
@toggle-role-dropdown="toggleRoleDropdown"
@close-role-dropdown="openRoleDropdownId = null"
@select-role="selectRole"
@save-role="saveRole"
@edit-user="openEditUser"
@delete-confirm="openDeleteConfirm"
/>
<!-- 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="kestrel-card-modal w-full max-w-sm p-4"
@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="kestrel-input"
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="kestrel-input"
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="kestrel-input"
>
<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="kestrel-btn-secondary"
@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="kestrel-card-modal w-full max-w-sm p-4"
@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="kestrel-btn-secondary"
@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="kestrel-card-modal w-full max-w-sm p-4"
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="kestrel-input"
>
</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="kestrel-input"
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="kestrel-btn-secondary"
@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>
<AddUserModal
:show="addUserModalOpen"
:submit-error="createError"
@close="closeAddUserModal"
@submit="onAddUserSubmit"
/>
<DeleteUserConfirmModal
:user="deleteConfirmUser"
@close="deleteConfirmUser = null"
@confirm="confirmDeleteUser"
/>
<EditUserModal
:user="editUserModal"
:submit-error="editError"
@close="editUserModal = null"
@submit="onEditUserSubmit"
/>
</template>
</div>
</template>
@@ -406,80 +87,26 @@ const roleByUserId = computed(() => {
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
else {
dropdownWrapRefs.value = Object.fromEntries(
Object.entries(dropdownWrapRefs.value).filter(([k]) => k !== userId),
)
}
}
watch(user, () => {
if (canEditPois.value) 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 }
}
})
openRoleDropdownId.value = openRoleDropdownId.value === userId ? null : userId
}
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
@@ -497,7 +124,6 @@ async function saveRole(id) {
function openAddUserModal() {
addUserModalOpen.value = true
newUser.value = { identifier: '', password: '', role: 'member' }
createError.value = ''
}
@@ -506,15 +132,15 @@ function closeAddUserModal() {
createError.value = ''
}
async function submitAddUser() {
async function onAddUserSubmit(payload) {
createError.value = ''
try {
await $fetch('/api/users', {
method: 'POST',
body: {
identifier: newUser.value.identifier.trim(),
password: newUser.value.password,
role: newUser.value.role,
identifier: payload.identifier,
password: payload.password,
role: payload.role,
},
})
closeAddUserModal()
@@ -527,21 +153,19 @@ async function submitAddUser() {
function openEditUser(u) {
editUserModal.value = u
editForm.value = { identifier: u.identifier, password: '' }
editError.value = ''
}
async function submitEditUser() {
if (!editUserModal.value) return
async function onEditUserSubmit(payload) {
const u = editUserModal.value
if (!u) return
editError.value = ''
const id = editUserModal.value.id
const body = { identifier: editForm.value.identifier.trim() }
if (editForm.value.password) body.password = editForm.value.password
const body = { identifier: payload.identifier.trim() }
if (payload.password) body.password = payload.password
try {
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
await $fetch(`/api/users/${u.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) {