feat(users): add drag-and-drop reordering for PICs
Introduce pic_sort_order to persist custom user ordering Replace data table with a custom draggable grid layout Add API endpoint to handle bulk order updates
This commit is contained in:
@@ -49,92 +49,121 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<UTable
|
<div class="min-w-[900px]" aria-label="Users">
|
||||||
:data="filteredUsers"
|
<div class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 border-b border-default bg-muted px-4 py-2 text-xs font-semibold uppercase text-muted">
|
||||||
:columns="columns"
|
<div>Order</div>
|
||||||
:loading="loadingUsers"
|
<div>Display Name</div>
|
||||||
:empty="searchQuery.trim() ? 'No matching users found.' : 'No users available.'"
|
<div>PIC Phone</div>
|
||||||
sticky="header"
|
<div>Role</div>
|
||||||
caption="Users"
|
<div>Status</div>
|
||||||
class="min-w-[820px]"
|
<div>Last Login</div>
|
||||||
>
|
<div class="text-right">Actions</div>
|
||||||
<template #fullName-cell="{ row }">
|
</div>
|
||||||
<div class="min-w-0 space-y-0.5 py-1">
|
|
||||||
<div class="font-semibold leading-tight text-highlighted">
|
<TransitionGroup tag="div" name="user-list" class="divide-y divide-default">
|
||||||
{{ row.original.fullName }}
|
<div
|
||||||
|
v-for="user in filteredUsers"
|
||||||
|
:key="user.id"
|
||||||
|
:draggable="canReorderUsers"
|
||||||
|
class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 bg-default px-4 py-3 transition-[background,box-shadow,transform,opacity] duration-200"
|
||||||
|
:class="{
|
||||||
|
'cursor-grab hover:bg-muted/60': canReorderUsers,
|
||||||
|
'cursor-not-allowed opacity-70': !canReorderUsers,
|
||||||
|
'scale-[0.99] opacity-60 shadow-lg': draggedUserId === user.id,
|
||||||
|
'bg-muted': dragOverUserId === user.id
|
||||||
|
}"
|
||||||
|
@dragstart="startUserDrag($event, user)"
|
||||||
|
@dragover.prevent="overUserDrag($event, user)"
|
||||||
|
@drop.prevent="dropUserDrag"
|
||||||
|
@dragend="endUserDrag"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UTooltip :text="canReorderUsers ? 'Drag to reorder' : 'Clear search to reorder'">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-md border border-default bg-default text-muted transition hover:text-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
:class="canReorderUsers ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed opacity-50'"
|
||||||
|
:aria-label="`Drag ${user.fullName} to reorder PIC list`"
|
||||||
|
:disabled="!canReorderUsers"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted">
|
|
||||||
@{{ row.original.username }}
|
<div class="min-w-0 space-y-0.5 py-1">
|
||||||
|
<div class="font-semibold leading-tight text-highlighted">
|
||||||
|
{{ user.fullName }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
@{{ user.username }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #phoneNumber-cell="{ row }">
|
<span class="text-sm" :class="user.phoneNumber ? 'text-default' : 'text-muted'">
|
||||||
<span class="text-sm" :class="row.original.phoneNumber ? 'text-default' : 'text-muted'">
|
{{ user.phoneNumber || 'Not set' }}
|
||||||
{{ row.original.phoneNumber || 'Not set' }}
|
</span>
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #role-cell="{ row }">
|
<div>
|
||||||
<UBadge
|
|
||||||
:label="row.original.role === 'super_admin' ? 'Super Admin' : 'Staff'"
|
|
||||||
:color="row.original.role === 'super_admin' ? 'primary' : 'neutral'"
|
|
||||||
variant="soft"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #status-cell="{ row }">
|
|
||||||
<div class="space-y-1.5 py-1">
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<UBadge
|
<UBadge
|
||||||
:label="row.original.mustChangePassword ? 'Password reset' : 'Password ready'"
|
:label="user.role === 'super_admin' ? 'Super Admin' : 'Staff'"
|
||||||
:color="row.original.mustChangePassword ? 'warning' : 'success'"
|
:color="user.role === 'super_admin' ? 'primary' : 'neutral'"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<UBadge
|
|
||||||
:label="row.original.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
|
|
||||||
:color="row.original.passkeyCount > 0 ? 'success' : 'neutral'"
|
|
||||||
variant="soft"
|
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-muted">
|
<div class="space-y-1.5 py-1">
|
||||||
{{ row.original.passkeyCount }} passkey{{ row.original.passkeyCount === 1 ? '' : 's' }}
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<UBadge
|
||||||
|
:label="user.mustChangePassword ? 'Password reset' : 'Password ready'"
|
||||||
|
:color="user.mustChangePassword ? 'warning' : 'success'"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
:label="user.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
|
||||||
|
:color="user.passkeyCount > 0 ? 'success' : 'neutral'"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{{ user.passkeyCount }} passkey{{ user.passkeyCount === 1 ? '' : 's' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-xs text-muted sm:text-sm">
|
||||||
|
{{ formatDateTime(user.lastLoginAt, 'Never') }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-end gap-1.5 py-1">
|
||||||
|
<UButton
|
||||||
|
label="Edit"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
size="sm"
|
||||||
|
@click="openEditModal(user)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
label="Reset Password"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-key-round"
|
||||||
|
size="sm"
|
||||||
|
:loading="resettingUserId === user.id"
|
||||||
|
@click="resetPassword(user)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</TransitionGroup>
|
||||||
|
|
||||||
<template #lastLoginAt-cell="{ row }">
|
<div v-if="!filteredUsers.length" class="px-4 py-10 text-center text-sm text-muted">
|
||||||
<span class="text-xs text-muted sm:text-sm">
|
{{ searchQuery.trim() ? 'No matching users found.' : 'No users available.' }}
|
||||||
{{ formatDateTime(row.original.lastLoginAt, 'Never') }}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions-cell="{ row }">
|
|
||||||
<div class="flex flex-wrap justify-end gap-1.5 py-1">
|
|
||||||
<UButton
|
|
||||||
label="Edit"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
icon="i-lucide-pencil-line"
|
|
||||||
size="sm"
|
|
||||||
@click="openEditModal(row.original)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
label="Reset Password"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
icon="i-lucide-key-round"
|
|
||||||
size="sm"
|
|
||||||
:loading="resettingUserId === row.original.id"
|
|
||||||
@click="resetPassword(row.original)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UTable>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
@@ -233,12 +262,17 @@ const auth = useAuth()
|
|||||||
const users = ref<ManagedUser[]>([])
|
const users = ref<ManagedUser[]>([])
|
||||||
const loadingUsers = ref(false)
|
const loadingUsers = ref(false)
|
||||||
const savingUser = ref(false)
|
const savingUser = ref(false)
|
||||||
|
const savingPicOrder = ref(false)
|
||||||
const resettingUserId = ref<string | null>(null)
|
const resettingUserId = ref<string | null>(null)
|
||||||
const issuedPasswordMessage = ref('')
|
const issuedPasswordMessage = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const editorOpen = ref(false)
|
const editorOpen = ref(false)
|
||||||
const editorMode = ref<'create' | 'edit'>('create')
|
const editorMode = ref<'create' | 'edit'>('create')
|
||||||
const editingUserId = ref<string | null>(null)
|
const editingUserId = ref<string | null>(null)
|
||||||
|
const draggedUserId = ref<string | null>(null)
|
||||||
|
const dragOverUserId = ref<string | null>(null)
|
||||||
|
const dragSaveStarted = ref(false)
|
||||||
|
let usersBeforeDrag: ManagedUser[] = []
|
||||||
|
|
||||||
const userForm = reactive({
|
const userForm = reactive({
|
||||||
fullName: '',
|
fullName: '',
|
||||||
@@ -252,19 +286,13 @@ const roleOptions = [
|
|||||||
{ label: 'Super Admin', value: 'super_admin' }
|
{ label: 'Super Admin', value: 'super_admin' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ accessorKey: 'fullName', header: 'Display Name' },
|
|
||||||
{ accessorKey: 'phoneNumber', header: 'PIC Phone' },
|
|
||||||
{ accessorKey: 'role', header: 'Role' },
|
|
||||||
{ id: 'status', header: 'Status' },
|
|
||||||
{ accessorKey: 'lastLoginAt', header: 'Last Login' },
|
|
||||||
{ id: 'actions', header: 'Actions' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const isEditMode = computed(() => editorMode.value === 'edit')
|
const isEditMode = computed(() => editorMode.value === 'edit')
|
||||||
const isEditingCurrentUser = computed(() => {
|
const isEditingCurrentUser = computed(() => {
|
||||||
return isEditMode.value && editingUserId.value === auth.user.value?.id
|
return isEditMode.value && editingUserId.value === auth.user.value?.id
|
||||||
})
|
})
|
||||||
|
const canReorderUsers = computed(() => {
|
||||||
|
return !searchQuery.value.trim() && !loadingUsers.value && !savingPicOrder.value
|
||||||
|
})
|
||||||
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
const keyword = searchQuery.value.trim().toLowerCase()
|
const keyword = searchQuery.value.trim().toLowerCase()
|
||||||
@@ -358,6 +386,131 @@ async function refreshUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveUserNearTarget(sourceUserId: string, targetUserId: string, insertAfterTarget: boolean) {
|
||||||
|
if (sourceUserId === targetUserId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIndex = users.value.findIndex((user) => user.id === sourceUserId)
|
||||||
|
const currentUserIds = users.value.map((user) => user.id)
|
||||||
|
|
||||||
|
if (sourceIndex === -1 || !currentUserIds.includes(targetUserId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedUsers = [...users.value]
|
||||||
|
const [movedUser] = reorderedUsers.splice(sourceIndex, 1)
|
||||||
|
const targetIndex = reorderedUsers.findIndex((user) => user.id === targetUserId)
|
||||||
|
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderedUsers.splice(insertAfterTarget ? targetIndex + 1 : targetIndex, 0, movedUser)
|
||||||
|
|
||||||
|
const nextUserIds = reorderedUsers.map((user) => user.id)
|
||||||
|
|
||||||
|
if (nextUserIds.join('|') === currentUserIds.join('|')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users.value = reorderedUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDragState() {
|
||||||
|
draggedUserId.value = null
|
||||||
|
dragOverUserId.value = null
|
||||||
|
dragSaveStarted.value = false
|
||||||
|
usersBeforeDrag = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUserDrag(event: DragEvent, user: ManagedUser) {
|
||||||
|
if (!canReorderUsers.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedUserId.value = user.id
|
||||||
|
dragOverUserId.value = user.id
|
||||||
|
dragSaveStarted.value = false
|
||||||
|
usersBeforeDrag = [...users.value]
|
||||||
|
event.dataTransfer?.setData('text/plain', user.id)
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function overUserDrag(event: DragEvent, user: ManagedUser) {
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canReorderUsers.value || !draggedUserId.value || draggedUserId.value === user.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.currentTarget
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
const insertAfterTarget = event.clientY > rect.top + rect.height / 2
|
||||||
|
|
||||||
|
dragOverUserId.value = user.id
|
||||||
|
moveUserNearTarget(draggedUserId.value, user.id, insertAfterTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropUserDrag() {
|
||||||
|
if (!draggedUserId.value || savingPicOrder.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dragSaveStarted.value = true
|
||||||
|
savingPicOrder.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users/order', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
userIds: users.value.map((user) => user.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
users.value = response.users
|
||||||
|
toast.add({
|
||||||
|
title: 'PIC order saved',
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-check-circle-2'
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
users.value = usersBeforeDrag
|
||||||
|
toast.add({
|
||||||
|
title: 'PIC order could not be saved',
|
||||||
|
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-alert'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
savingPicOrder.value = false
|
||||||
|
resetDragState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endUserDrag() {
|
||||||
|
if (dragSaveStarted.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedUserId.value && usersBeforeDrag.length) {
|
||||||
|
users.value = usersBeforeDrag
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDragState()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -466,3 +619,22 @@ async function resetPassword(user: ManagedUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-list-move,
|
||||||
|
.user-list-enter-active,
|
||||||
|
.user-list-leave-active {
|
||||||
|
transition: transform 180ms ease, opacity 160ms ease, background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-enter-from,
|
||||||
|
.user-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
24
server/api/admin/users/order.patch.ts
Normal file
24
server/api/admin/users/order.patch.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { requireRole } from '../../../utils/auth'
|
||||||
|
import { assertBadRequest } from '../../../utils/http'
|
||||||
|
import { listUsers, reorderUsers } from '../../../utils/user-repository'
|
||||||
|
import { parseUserOrderInput } from '../../../utils/users'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireRole(event, 'super_admin')
|
||||||
|
|
||||||
|
const body = await readBody<{
|
||||||
|
userIds?: unknown
|
||||||
|
}>(event)
|
||||||
|
const { userIds } = parseUserOrderInput(body)
|
||||||
|
const users = await listUsers()
|
||||||
|
const existingIds = new Set(users.map((user) => user.id))
|
||||||
|
|
||||||
|
assertBadRequest(userIds.length === users.length, 'User order must include every user')
|
||||||
|
assertBadRequest(userIds.every((userId) => existingIds.has(userId)), 'User order contains an unknown user')
|
||||||
|
|
||||||
|
await reorderUsers(userIds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: await listUsers()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -41,6 +41,29 @@ async function initializeDatabase() {
|
|||||||
add column if not exists phone_number text
|
add column if not exists phone_number text
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table users
|
||||||
|
add column if not exists pic_sort_order integer not null default 0
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update users
|
||||||
|
set pic_sort_order = seed.sort_order
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
row_number() over (
|
||||||
|
order by
|
||||||
|
case when role = 'super_admin' then 0 else 1 end,
|
||||||
|
created_at asc,
|
||||||
|
full_name asc
|
||||||
|
) as sort_order
|
||||||
|
from users
|
||||||
|
) as seed
|
||||||
|
where users.id = seed.id
|
||||||
|
and users.pic_sort_order = 0
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
create table if not exists user_passkeys (
|
create table if not exists user_passkeys (
|
||||||
id text primary key,
|
id text primary key,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type DbUserRow = {
|
|||||||
full_name: string
|
full_name: string
|
||||||
phone_number: string | null
|
phone_number: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
pic_sort_order: number | string
|
||||||
password_hash: string
|
password_hash: string
|
||||||
must_change_password: boolean
|
must_change_password: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -83,6 +84,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
|
|||||||
fullName: row.full_name,
|
fullName: row.full_name,
|
||||||
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
|
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
|
picSortOrder: parseInteger(row.pic_sort_order),
|
||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
mustChangePassword: row.must_change_password,
|
mustChangePassword: row.must_change_password,
|
||||||
needsPasskeySetup: passkeyCount === 0,
|
needsPasskeySetup: passkeyCount === 0,
|
||||||
@@ -147,6 +149,7 @@ export async function getUserById(userId: string): Promise<UserAuthRecord | null
|
|||||||
users.full_name,
|
users.full_name,
|
||||||
users.phone_number,
|
users.phone_number,
|
||||||
users.role,
|
users.role,
|
||||||
|
users.pic_sort_order,
|
||||||
users.password_hash,
|
users.password_hash,
|
||||||
users.must_change_password,
|
users.must_change_password,
|
||||||
users.is_active,
|
users.is_active,
|
||||||
@@ -178,6 +181,7 @@ export async function getUserByUsername(username: string): Promise<UserAuthRecor
|
|||||||
users.full_name,
|
users.full_name,
|
||||||
users.phone_number,
|
users.phone_number,
|
||||||
users.role,
|
users.role,
|
||||||
|
users.pic_sort_order,
|
||||||
users.password_hash,
|
users.password_hash,
|
||||||
users.must_change_password,
|
users.must_change_password,
|
||||||
users.is_active,
|
users.is_active,
|
||||||
@@ -209,6 +213,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
|||||||
users.full_name,
|
users.full_name,
|
||||||
users.phone_number,
|
users.phone_number,
|
||||||
users.role,
|
users.role,
|
||||||
|
users.pic_sort_order,
|
||||||
users.password_hash,
|
users.password_hash,
|
||||||
users.must_change_password,
|
users.must_change_password,
|
||||||
users.is_active,
|
users.is_active,
|
||||||
@@ -223,7 +228,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
|||||||
group by user_id
|
group by user_id
|
||||||
) as passkey_totals on passkey_totals.user_id = users.id
|
) as passkey_totals on passkey_totals.user_id = users.id
|
||||||
order by
|
order by
|
||||||
case when users.role = 'super_admin' then 0 else 1 end,
|
users.pic_sort_order asc,
|
||||||
users.created_at asc
|
users.created_at asc
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -234,18 +239,19 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
|||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|
||||||
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
|
||||||
select
|
select
|
||||||
users.id,
|
users.id,
|
||||||
users.full_name,
|
users.full_name,
|
||||||
users.phone_number,
|
users.phone_number,
|
||||||
users.role
|
users.role,
|
||||||
|
users.pic_sort_order
|
||||||
from users
|
from users
|
||||||
where users.is_active = true
|
where users.is_active = true
|
||||||
and users.phone_number is not null
|
and users.phone_number is not null
|
||||||
and users.phone_number <> ''
|
and users.phone_number <> ''
|
||||||
order by
|
order by
|
||||||
case when users.role = 'super_admin' then 0 else 1 end,
|
users.pic_sort_order asc,
|
||||||
users.full_name asc
|
users.full_name asc
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -253,7 +259,8 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
fullName: row.full_name,
|
fullName: row.full_name,
|
||||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||||
role: row.role
|
role: row.role,
|
||||||
|
picSortOrder: parseInteger(row.pic_sort_order)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,12 +268,13 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
|||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|
||||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
|
||||||
select
|
select
|
||||||
users.id,
|
users.id,
|
||||||
users.full_name,
|
users.full_name,
|
||||||
users.phone_number,
|
users.phone_number,
|
||||||
users.role
|
users.role,
|
||||||
|
users.pic_sort_order
|
||||||
from users
|
from users
|
||||||
where users.id = ${contactId}
|
where users.id = ${contactId}
|
||||||
and users.is_active = true
|
and users.is_active = true
|
||||||
@@ -283,7 +291,8 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
fullName: row.full_name,
|
fullName: row.full_name,
|
||||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||||
role: row.role
|
role: row.role,
|
||||||
|
picSortOrder: parseInteger(row.pic_sort_order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +314,7 @@ export async function createUser(input: {
|
|||||||
full_name,
|
full_name,
|
||||||
phone_number,
|
phone_number,
|
||||||
role,
|
role,
|
||||||
|
pic_sort_order,
|
||||||
password_hash,
|
password_hash,
|
||||||
must_change_password,
|
must_change_password,
|
||||||
is_active,
|
is_active,
|
||||||
@@ -316,6 +326,7 @@ export async function createUser(input: {
|
|||||||
${input.fullName},
|
${input.fullName},
|
||||||
${input.phoneNumber},
|
${input.phoneNumber},
|
||||||
${input.role},
|
${input.role},
|
||||||
|
(select coalesce(max(pic_sort_order), 0) + 1 from users),
|
||||||
${input.passwordHash},
|
${input.passwordHash},
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
@@ -327,6 +338,7 @@ export async function createUser(input: {
|
|||||||
full_name,
|
full_name,
|
||||||
phone_number,
|
phone_number,
|
||||||
role,
|
role,
|
||||||
|
pic_sort_order,
|
||||||
password_hash,
|
password_hash,
|
||||||
must_change_password,
|
must_change_password,
|
||||||
is_active,
|
is_active,
|
||||||
@@ -361,6 +373,21 @@ export async function updateUserProfile(input: {
|
|||||||
return getUserById(input.userId)
|
return getUserById(input.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderUsers(userIds: string[]) {
|
||||||
|
await ensureDatabaseReady()
|
||||||
|
const sql = getSqlClient()
|
||||||
|
|
||||||
|
for (const [index, userId] of userIds.entries()) {
|
||||||
|
await sql`
|
||||||
|
update users
|
||||||
|
set
|
||||||
|
pic_sort_order = ${index + 1},
|
||||||
|
updated_at = now()
|
||||||
|
where id = ${userId}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateUserPassword(input: {
|
export async function updateUserPassword(input: {
|
||||||
userId: string
|
userId: string
|
||||||
passwordHash: string
|
passwordHash: string
|
||||||
|
|||||||
@@ -73,3 +73,19 @@ export function parseUserProfileInput(body: {
|
|||||||
role
|
role
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseUserOrderInput(body: {
|
||||||
|
userIds?: unknown
|
||||||
|
}) {
|
||||||
|
assertBadRequest(Array.isArray(body.userIds), 'User ids must be an array')
|
||||||
|
assertBadRequest(body.userIds.every((value) => typeof value === 'string' && value.trim()), 'Every user id is required')
|
||||||
|
|
||||||
|
const userIds = body.userIds.map((value) => value.trim())
|
||||||
|
const uniqueUserIds = new Set(userIds)
|
||||||
|
|
||||||
|
assertBadRequest(uniqueUserIds.size === userIds.length, 'User ids must be unique')
|
||||||
|
|
||||||
|
return {
|
||||||
|
userIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface AuthUser {
|
|||||||
fullName: string
|
fullName: string
|
||||||
phoneNumber: string | null
|
phoneNumber: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
picSortOrder: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
mustChangePassword: boolean
|
mustChangePassword: boolean
|
||||||
needsPasskeySetup: boolean
|
needsPasskeySetup: boolean
|
||||||
@@ -81,6 +82,7 @@ export interface PublicContact {
|
|||||||
fullName: string
|
fullName: string
|
||||||
phoneNumber: string
|
phoneNumber: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
picSortOrder: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasskeySummary {
|
export interface PasskeySummary {
|
||||||
|
|||||||
Reference in New Issue
Block a user