fixed user-device management
This commit is contained in:
@@ -12,12 +12,8 @@ import { Ellipsis } from 'lucide-vue-next'
|
||||
import type { Device, Users } from '@/lib/interfaces'
|
||||
// import { api } from '@/lib/api'
|
||||
|
||||
const props = defineProps<{
|
||||
row: Device
|
||||
allUsers?: Users[]
|
||||
onRowUpdated?: (row: Device, payload: { name: string; userIds: string[] }) => void
|
||||
onRowDeleted?: (row: Device) => void
|
||||
}>() // ← accept full row
|
||||
|
||||
const props = defineProps<{ row: Device; allUsers?: Users[] }>()
|
||||
|
||||
const isEditOpen = ref(false)
|
||||
const isDeleteOpen = ref(false)
|
||||
@@ -32,13 +28,11 @@ const emit = defineEmits<{
|
||||
function onDeleteConfirmed() {
|
||||
// await api.delete(`/devices/${encodeURIComponent(props.row.guid)}`)
|
||||
isDeleteOpen.value = false
|
||||
// emit('refresh')
|
||||
props.onRowDeleted?.(props.row)
|
||||
emit('deleted')
|
||||
}
|
||||
function onEditConfirm(_payload: { name: string; userIds: string[] }) {
|
||||
isEditOpen.value = false
|
||||
// emit('refresh')
|
||||
props.onRowUpdated?.(props.row, _payload)
|
||||
emit('updated', _payload)
|
||||
}
|
||||
|
||||
function onTaskConfirm() {
|
||||
|
||||
66
management-ui/src/customcompometns/AdminTrackerDropdown.vue
Normal file
66
management-ui/src/customcompometns/AdminTrackerDropdown.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuItem,
|
||||
DropdownMenuTrigger, DropdownMenuContent
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import EditTrackerDialog from './EditTrackerDialog.vue'
|
||||
import DeleteDeviceDialog from './DeleteDeviceDialog.vue'
|
||||
import DeviceCertificateDialog from './DeviceCertificateDialog.vue'
|
||||
import DeviceTasksDialog from './DeviceTasksDialog.vue'
|
||||
import { Ellipsis } from 'lucide-vue-next'
|
||||
import type { Device, Users } from '@/lib/interfaces'
|
||||
// import { api } from '@/lib/api'
|
||||
|
||||
|
||||
const props = defineProps<{ row: Device; allUsers?: Users[] }>()
|
||||
|
||||
const isEditOpen = ref(false)
|
||||
const isDeleteOpen = ref(false)
|
||||
const isTasksOpen = ref(false)
|
||||
const itCertsOpen = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated', payload: { name: string; userIds: string[] }): void
|
||||
(e: 'deleted'): void
|
||||
}>()
|
||||
|
||||
function onDeleteConfirmed() {
|
||||
// await api.delete(`/devices/${encodeURIComponent(props.row.guid)}`)
|
||||
isDeleteOpen.value = false
|
||||
emit('deleted')
|
||||
}
|
||||
function onEditConfirm(_payload: { name: string; userIds: string[] }) {
|
||||
isEditOpen.value = false
|
||||
emit('updated', _payload)
|
||||
}
|
||||
|
||||
function onTaskConfirm() {
|
||||
isTasksOpen.value = false
|
||||
}
|
||||
|
||||
function onCertsConfirm() {
|
||||
itCertsOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button class="p-2 rounded hover:bg-muted">
|
||||
<Ellipsis />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-[160px]">
|
||||
<DropdownMenuItem @click.prevent="isEditOpen = true">Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem @click.prevent="isTasksOpen = true">Tasks</DropdownMenuItem>
|
||||
<DropdownMenuItem @click.prevent="itCertsOpen = true">Certificates</DropdownMenuItem>
|
||||
<DropdownMenuItem class="text-destructive" @click.prevent="isDeleteOpen = true">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EditTrackerDialog v-model:modelValue="isEditOpen" :device="props.row" :all-users="props.allUsers" @updated="onEditConfirm" />
|
||||
<DeleteDeviceDialog v-model:modelValue="isDeleteOpen" :device="props.row" @confirm="onDeleteConfirmed" />
|
||||
<DeviceCertificateDialog v-model:modelValue="itCertsOpen" :device="props.row" @confirm="onCertsConfirm" />
|
||||
<DeviceTasksDialog v-model:modelValue="isTasksOpen" :device="props.row" @confirm="onTaskConfirm" />
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { Tabs, TabsContent, TabsTrigger, TabsList } from '@/components/ui/tabs'
|
||||
import DataTableNoCheckbox from './DataTableNoCheckbox.vue'
|
||||
import AdminUserDropdonw from './AdminUserDropdonw.vue'
|
||||
import AdminDeviceDropdown from './AdminDeviceDropdown.vue'
|
||||
import AdminTrackerDropdown from './AdminTrackerDropdown.vue'
|
||||
import type { Device, Users } from '@/lib/interfaces'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
|
||||
@@ -208,8 +209,8 @@ onBeforeUnmount(() => {
|
||||
:columns="device_columns"
|
||||
:data="device_data"
|
||||
:dropdownComponent="AdminDeviceDropdown"
|
||||
:onRowUpdated="handleDeviceUpdated"
|
||||
:onRowDeleted="handleDeviceDeleted"
|
||||
@row-updated="handleDeviceUpdated"
|
||||
@row-deleted="handleDeviceDeleted"
|
||||
:dropdownProps="{ allUsers: user_data }"
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -218,9 +219,9 @@ onBeforeUnmount(() => {
|
||||
<DataTableNoCheckbox
|
||||
:columns="tracker_columns"
|
||||
:data="trackers_data"
|
||||
:dropdownComponent="AdminDeviceDropdown"
|
||||
:onRowUpdated="handleTrackerUpdated"
|
||||
:onRowDeleted="handleTrackerDeleted"
|
||||
:dropdownComponent="AdminTrackerDropdown"
|
||||
@row-updated="handleTrackerUpdated"
|
||||
@row-deleted="handleTrackerDeleted"
|
||||
:dropdownProps="{ allUsers: user_data }"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -67,11 +67,6 @@ function initFromDevice() {
|
||||
selectedUserIds.value = [...originalIds.value]
|
||||
initedForGuid.value = guid.value
|
||||
|
||||
console.log('[EditDeviceDialog:init]', {
|
||||
guid: guid.value,
|
||||
originalName: originalName.value,
|
||||
originalIds: [...originalIds.value],
|
||||
})
|
||||
}
|
||||
|
||||
// When dialog opens, initialize once for this guid
|
||||
@@ -89,16 +84,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// If allUsers arrives later AND we haven't successfully initialized for this guid,
|
||||
// try again (but only when open).
|
||||
// watch(
|
||||
// () => props.allUsers,
|
||||
// () => {
|
||||
// if (props.modelValue && guid.value && initedForGuid.value !== guid.value) {
|
||||
// initFromDevice()
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
// If allUsers arrives later, and we couldn't map on first init, map now.
|
||||
watch(
|
||||
@@ -129,17 +114,12 @@ watch(
|
||||
if (mapped.length > 0) {
|
||||
originalIds.value = mapped
|
||||
selectedUserIds.value = [...mapped]
|
||||
console.log('[EditDeviceDialog:remap-after-allUsers]', { mapped })
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Just to see live edits from AssignDevice
|
||||
watch(selectedUserIds, (v) => {
|
||||
console.log('[EditDeviceDialog:selectedUserIds]', [...v])
|
||||
}, { deep: true })
|
||||
|
||||
// ---------- Save ----------
|
||||
const saving = ref(false)
|
||||
@@ -161,11 +141,6 @@ async function onSave() {
|
||||
errorText.value = null
|
||||
saving.value = true
|
||||
try {
|
||||
console.log('[EditDeviceDialog:compare]', {
|
||||
originalIds: [...originalIds.value],
|
||||
selectedUserIds: [...selectedUserIds.value],
|
||||
})
|
||||
|
||||
const ops: Promise<any>[] = []
|
||||
|
||||
const nameChanged = changedName()
|
||||
@@ -195,7 +170,6 @@ async function onSave() {
|
||||
emit('updated', { name: name.value, userIds: [...selectedUserIds.value] })
|
||||
emit('update:modelValue', false)
|
||||
} catch (err: any) {
|
||||
console.error('[EditDeviceDialog:onSave] error', err)
|
||||
errorText.value = err?.response?.data?.message || 'Failed to save changes.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
|
||||
216
management-ui/src/customcompometns/EditTrackerDialog.vue
Normal file
216
management-ui/src/customcompometns/EditTrackerDialog.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import AssignDevice from './AssignDevice.vue'
|
||||
import type { Device, Users } from '@/lib/interfaces'
|
||||
import { defineProps, defineEmits, ref, watch, computed } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
type DeviceWithUsers =
|
||||
& Partial<Device>
|
||||
& { guid?: string; name?: string; devicename?: string; assigned_users?: string }
|
||||
& { users?: Array<{ id: number; username: string; role?: string }> }
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean as PropType<boolean>, required: true },
|
||||
device: { type: Object as PropType<DeviceWithUsers>, required: true },
|
||||
allUsers: { type: Array as PropType<Users[]>, required: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'updated', payload: { name: string; userIds: string[] }): void
|
||||
}>()
|
||||
|
||||
const guid = computed(() => String(props.device?.guid ?? ''))
|
||||
const originalName = computed(() => (props.device?.devicename ?? props.device?.name ?? ''))
|
||||
|
||||
const originalIds = ref<string[]>([]) // baseline for compare
|
||||
const name = ref('')
|
||||
const selectedUserIds = ref<string[]>([]) // live edited selection
|
||||
|
||||
// ----- init control: only init once per open/guid -----
|
||||
const initedForGuid = ref<string | null>(null)
|
||||
|
||||
function usernameToId(u: string): string | null {
|
||||
const id = props.allUsers?.find(x => x.username === u)?.id
|
||||
return typeof id === 'number' ? String(id) : null
|
||||
}
|
||||
|
||||
function initFromDevice() {
|
||||
const dev = props.device
|
||||
name.value = originalName.value
|
||||
|
||||
if (Array.isArray(dev?.users) && dev!.users.length) {
|
||||
originalIds.value = dev!.users.map(u => String(u.id))
|
||||
} else {
|
||||
// Map from assigned_users (comma-separated usernames) -> ids (requires allUsers)
|
||||
const usernames = (dev?.assigned_users ?? '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (usernames.length && props.allUsers?.length) {
|
||||
originalIds.value = usernames
|
||||
.map(usernameToId)
|
||||
.filter((x): x is string => !!x)
|
||||
} else {
|
||||
originalIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
selectedUserIds.value = [...originalIds.value]
|
||||
initedForGuid.value = guid.value
|
||||
}
|
||||
|
||||
// When dialog opens, initialize once for this guid
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open && guid.value && initedForGuid.value !== guid.value) {
|
||||
initFromDevice()
|
||||
}
|
||||
if (!open) {
|
||||
// reset flag so next open re-inits
|
||||
initedForGuid.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
|
||||
// If allUsers arrives later, and we couldn't map on first init, map now.
|
||||
watch(
|
||||
() => props.allUsers,
|
||||
() => {
|
||||
if (!props.modelValue || !guid.value) return
|
||||
|
||||
const dev = props.device
|
||||
const hasUsersArray = Array.isArray(dev?.users) && dev!.users.length > 0
|
||||
const hasAssigned = typeof dev?.assigned_users === 'string' && dev!.assigned_users.trim().length > 0
|
||||
const canMapNow = Array.isArray(props.allUsers) && props.allUsers.length > 0
|
||||
|
||||
// Only re-map if we *didn't* have users[], we *do* have assigned usernames,
|
||||
// we *couldn't* map earlier (originalIds is empty), and now we have allUsers.
|
||||
if (!hasUsersArray && hasAssigned && originalIds.value.length === 0 && canMapNow) {
|
||||
const usernames = dev!.assigned_users!
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const mapped = usernames
|
||||
.map(u => {
|
||||
const id = props.allUsers!.find(x => x.username === u)?.id
|
||||
return typeof id === 'number' ? String(id) : null
|
||||
})
|
||||
.filter((x): x is string => !!x)
|
||||
|
||||
if (mapped.length > 0) {
|
||||
originalIds.value = mapped
|
||||
selectedUserIds.value = [...mapped]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// ---------- Save ----------
|
||||
const saving = ref(false)
|
||||
const errorText = ref<string | null>(null)
|
||||
|
||||
function changedName() {
|
||||
return name.value.trim() !== originalName.value.trim()
|
||||
}
|
||||
function changedUsers() {
|
||||
const a = new Set(originalIds.value)
|
||||
const b = new Set(selectedUserIds.value)
|
||||
if (a.size !== b.size) return true
|
||||
for (const id of a) if (!b.has(id)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!guid.value) return
|
||||
errorText.value = null
|
||||
saving.value = true
|
||||
try {
|
||||
const ops: Promise<any>[] = []
|
||||
|
||||
const nameChanged = changedName()
|
||||
const usersChanged = changedUsers()
|
||||
|
||||
if (nameChanged) {
|
||||
ops.push(api.post(`/trackers/${encodeURIComponent(guid.value)}/rename`, {
|
||||
name: name.value.trim(),
|
||||
} as { name: string }))
|
||||
}
|
||||
|
||||
if (usersChanged) {
|
||||
const userIdsNum = selectedUserIds.value
|
||||
.map(v => Number(v))
|
||||
.filter(n => Number.isFinite(n)) as number[]
|
||||
|
||||
ops.push(api.post(`/trackers/${encodeURIComponent(guid.value)}/set_users`, {
|
||||
userIds: userIdsNum,
|
||||
} as { userIds: number[] }))
|
||||
}
|
||||
|
||||
if (ops.length > 0) {
|
||||
await Promise.all(ops)
|
||||
}
|
||||
|
||||
emit('updated', { name: name.value, userIds: [...selectedUserIds.value] })
|
||||
emit('update:modelValue', false)
|
||||
} catch (err: any) {
|
||||
errorText.value = err?.response?.data?.message || 'Failed to save changes.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="props.modelValue" @update:open="(v:boolean)=>emit('update:modelValue', v)">
|
||||
<DialogContent class="sm:min-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit tracker</DialogTitle>
|
||||
<DialogDescription>Make changes to tracker settings below.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="guid" class="text-right">GUID</Label>
|
||||
<p id="guid" class="col-span-3 break-all text-muted-foreground">{{ guid }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="name" class="text-right">Name</Label>
|
||||
<Input id="name" class="col-span-3" v-model="name" :disabled="saving" placeholder="Tracker name" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="users" class="text-right">Allowed users</Label>
|
||||
<AssignDevice
|
||||
id="users"
|
||||
class="col-span-3 w-full"
|
||||
v-model="selectedUserIds"
|
||||
:all-users="props.allUsers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorText" class="text-sm text-red-600 mt-1 col-span-4">{{ errorText }}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button :disabled="saving" @click="onSave">
|
||||
{{ saving ? 'Saving…' : 'Save changes' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user