created tracker api endpoint and created UI interface for trackers
This commit is contained in:
@@ -117,13 +117,71 @@ async function loadDevices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Trackers
|
||||||
|
type ApiTracker = { guid: string; name: string; users?: ApiDeviceUser[] }
|
||||||
|
type TrackersResponse = { trackers: ApiTracker[]; offset: number; limit: number; total: number }
|
||||||
|
|
||||||
|
function isTrackersResponse(t: unknown): t is TrackersResponse {
|
||||||
|
const x = t as any
|
||||||
|
return !!x && Array.isArray(x.trackers) &&
|
||||||
|
x.trackers.every((dev: any) =>
|
||||||
|
dev &&
|
||||||
|
typeof dev.guid === 'string' &&
|
||||||
|
typeof dev.name === 'string' &&
|
||||||
|
(
|
||||||
|
dev.users === undefined ||
|
||||||
|
(
|
||||||
|
Array.isArray(dev.users) &&
|
||||||
|
dev.users.every((u: any) => u && typeof u.username === 'string')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackers_data = ref<Device[]>([])
|
||||||
|
const trackersLoading = ref(false)
|
||||||
|
const trackersError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const tracker_columns = [
|
||||||
|
{ accessorKey: 'guid', header: 'GUID' },
|
||||||
|
{ accessorKey: 'devicename', header: 'Tracker' },
|
||||||
|
{ accessorKey: 'assigned_users', header: 'Users' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let treckerCtrl: AbortController | null = null
|
||||||
|
async function loadTrackers() {
|
||||||
|
trackersError.value = null
|
||||||
|
trackersLoading.value = true
|
||||||
|
try {
|
||||||
|
treckerCtrl?.abort()
|
||||||
|
treckerCtrl = new AbortController()
|
||||||
|
const { data } = await api.get('/trackers', { signal: treckerCtrl.signal })
|
||||||
|
|
||||||
|
if (!isTrackersResponse(data)) throw new Error('Unexpected trackers response')
|
||||||
|
|
||||||
|
// Transform API -> table shape
|
||||||
|
trackers_data.value = data.trackers.map((d) => ({
|
||||||
|
guid: d.guid,
|
||||||
|
devicename: d.name,
|
||||||
|
assigned_users: Array.isArray(d.users) ? d.users.map(u => u.username).join(', ') : '',
|
||||||
|
}))
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.message === 'canceled') return
|
||||||
|
trackersError.value = 'Failed to load trackers.'
|
||||||
|
} finally {
|
||||||
|
trackersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
loadDevices()
|
loadDevices()
|
||||||
|
loadTrackers()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
ctrl?.abort()
|
ctrl?.abort()
|
||||||
devicesCtrl?.abort()
|
devicesCtrl?.abort()
|
||||||
|
treckerCtrl?.abort()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -150,5 +208,9 @@ onBeforeUnmount(() => {
|
|||||||
<DataTableNoCheckbox :columns="device_columns" :data="device_data"
|
<DataTableNoCheckbox :columns="device_columns" :data="device_data"
|
||||||
:dropdownComponent="AdminDeviceDropdown" />
|
:dropdownComponent="AdminDeviceDropdown" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="trackers">
|
||||||
|
<DataTableNoCheckbox :columns="tracker_columns" :data="trackers_data"
|
||||||
|
:dropdownComponent="AdminDeviceDropdown" />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</template>
|
</template>
|
||||||
@@ -44,6 +44,13 @@ function navLinkClass(prefix: string) {
|
|||||||
>
|
>
|
||||||
Devices
|
Devices
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/trackers"
|
||||||
|
:class="navLinkClass('/trackers')"
|
||||||
|
:aria-current="isActive('/trackers') ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
Trackers
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
103
management-ui/src/customcompometns/TrackerGrid.vue
Normal file
103
management-ui/src/customcompometns/TrackerGrid.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||||
|
import type { Device } from '@/lib/interfaces'
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
}>()
|
||||||
|
// ----- API response models (same as before) -----
|
||||||
|
type ApiDeviceUser = { id: number; username: string; role: string }
|
||||||
|
type ApiTracker = { guid: string; name: string; users?: ApiDeviceUser[] }
|
||||||
|
type TrackersResponse = { trackers: ApiTracker[]; offset: number; limit: number; total: number }
|
||||||
|
|
||||||
|
function isTrackersResponse(t: unknown): t is TrackersResponse {
|
||||||
|
const x = t as any
|
||||||
|
return !!x && Array.isArray(x.trackers) &&
|
||||||
|
x.trackers.every((dev: any) =>
|
||||||
|
dev &&
|
||||||
|
typeof dev.guid === 'string' &&
|
||||||
|
typeof dev.name === 'string' &&
|
||||||
|
(
|
||||||
|
dev.users === undefined ||
|
||||||
|
(
|
||||||
|
Array.isArray(dev.users) &&
|
||||||
|
dev.users.every((u: any) => u && typeof u.username === 'string')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackers_data = ref<Device[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const tracker_columns = [
|
||||||
|
{ accessorKey: 'guid', header: 'GUID' },
|
||||||
|
{ accessorKey: 'devicename', header: 'Tracker' },
|
||||||
|
{ accessorKey: 'assigned_users', header: 'Users' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let treckerCtrl: AbortController | null = null
|
||||||
|
async function loadTrackers() {
|
||||||
|
error.value = null
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
treckerCtrl?.abort()
|
||||||
|
treckerCtrl = new AbortController()
|
||||||
|
const { data } = await api.get('/trackers', { signal: treckerCtrl.signal })
|
||||||
|
|
||||||
|
if (!isTrackersResponse(data)) throw new Error('Unexpected trackers response')
|
||||||
|
|
||||||
|
// Transform API -> table shape
|
||||||
|
trackers_data.value = data.trackers.map((d) => ({
|
||||||
|
guid: d.guid,
|
||||||
|
devicename: d.name,
|
||||||
|
assigned_users: Array.isArray(d.users) ? d.users.map(u => u.username).join(', ') : '',
|
||||||
|
}))
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'CanceledError' || e?.message === 'canceled') return
|
||||||
|
error.value = 'Failed to load trackers.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(loadTrackers)
|
||||||
|
onBeforeUnmount(() => treckerCtrl?.abort())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="loading">Loading devices…</div>
|
||||||
|
<div v-else-if="error" class="text-red-600">{{ error }}</div>
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
<router-link v-for="device in trackers_data" :key="device.guid"
|
||||||
|
:to="{ name: 'DeviceView', params: { guid: device.guid } }" class="block group hover:no-underline">
|
||||||
|
<Card class="h-full transition-shadow group-hover:shadow-lg">
|
||||||
|
<CardHeader class="bg-primary/5">
|
||||||
|
<CardTitle class="text-lg">
|
||||||
|
{{ device.devicename }}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="text-sm text-muted-foreground mb-2">
|
||||||
|
<span class="font-medium">GUID:</span> {{ device.guid }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="font-medium">Assigned Users:</span><br />
|
||||||
|
{{ device.assigned_users || '—' }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ensure cards stretch to fill the anchor height */
|
||||||
|
a>.Card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,6 +27,11 @@ type CreateUserPayload = {
|
|||||||
role: string // 'admin' | 'user'
|
role: string // 'admin' | 'user'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateTrackerPayload = {
|
||||||
|
guid: string
|
||||||
|
name: string
|
||||||
|
userIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -49,6 +54,11 @@ const user_form = reactive({
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tracker_form = reactive({
|
||||||
|
guid: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
})
|
||||||
|
|
||||||
// userIds from AssignDevice (expects v-model of string[] ids)
|
// userIds from AssignDevice (expects v-model of string[] ids)
|
||||||
const selectedUserIds = ref<string[]>([])
|
const selectedUserIds = ref<string[]>([])
|
||||||
|
|
||||||
@@ -65,6 +75,8 @@ watch(
|
|||||||
userError.value = null
|
userError.value = null
|
||||||
userSubmitting.value = false
|
userSubmitting.value = false
|
||||||
userSuccess.value = null
|
userSuccess.value = null
|
||||||
|
// tracker_form.guid = uuidv4()
|
||||||
|
// tracker_form.name = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -151,6 +163,35 @@ async function submitUser() {
|
|||||||
userSubmitting.value = false
|
userSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitTracker() {
|
||||||
|
errorMsg.value = null
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
errorMsg.value = 'Please provide a valid GUID and name.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds: number[] = selectedUserIds.value
|
||||||
|
.map((s) => Number(s))
|
||||||
|
.filter((n) => Number.isFinite(n) && n >= 0)
|
||||||
|
|
||||||
|
const payload: CreateDevicePayload = {
|
||||||
|
guid: device_form.guid,
|
||||||
|
name: device_form.name.trim(),
|
||||||
|
userIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/trackers/create', payload)
|
||||||
|
router.replace('/admin')
|
||||||
|
} catch (e: any) {
|
||||||
|
// keep client error generic
|
||||||
|
errorMsg.value = e?.response?.status === 403 ? 'Access denied.' : 'Failed to create device.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -239,6 +280,43 @@ async function submitUser() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card class="h-full flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create tracker</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="flex-1">
|
||||||
|
<!-- add vertical spacing between rows -->
|
||||||
|
<div class="grid gap-5">
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="guid" class="text-right">GUID</Label>
|
||||||
|
<Input id="guid" class="col-span-3 w-full" v-model="device_form.guid" />
|
||||||
|
</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 w-full" v-model="device_form.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="users" class="text-right">Allowed users</Label>
|
||||||
|
<!-- make the component span and fill -->
|
||||||
|
<AssignDevice id="users" class="col-span-3 w-full" v-model="selectedUserIds" />
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMsg" class="text-sm text-red-600" aria-live="assertive">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter>
|
||||||
|
<Button :disabled="!canSubmit" @click="submitTracker">
|
||||||
|
{{ submitting ? 'Saving…' : 'Save' }}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
14
management-ui/src/pages/Trackers.vue
Normal file
14
management-ui/src/pages/Trackers.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useColorMode } from '@vueuse/core'
|
||||||
|
import Navbar from '@/customcompometns/Navbar.vue';
|
||||||
|
import DevicesGrid from '@/customcompometns/DevicesGrid.vue';
|
||||||
|
import TrackerGrid from '@/customcompometns/TrackerGrid.vue';
|
||||||
|
const mode = useColorMode()
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Navbar>
|
||||||
|
<TrackerGrid />
|
||||||
|
</Navbar>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import Devices from '@/pages/Devices.vue';
|
|||||||
import DeviceView from './pages/DeviceView.vue';
|
import DeviceView from './pages/DeviceView.vue';
|
||||||
import Forbidden from './pages/Forbidden.vue';
|
import Forbidden from './pages/Forbidden.vue';
|
||||||
import Create from './pages/Create.vue';
|
import Create from './pages/Create.vue';
|
||||||
|
import Trackers from '@/pages/Trackers.vue';
|
||||||
import { auth } from './lib/auth';
|
import { auth } from './lib/auth';
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +65,13 @@ const routes = [
|
|||||||
name: 'Create',
|
name: 'Create',
|
||||||
component: Create,
|
component: Create,
|
||||||
meta: { requiresAuth: true, roles: ['admin'] }
|
meta: { requiresAuth: true, roles: ['admin'] }
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
path: '/trackers',
|
||||||
|
name: 'Trackers',
|
||||||
|
component: Trackers,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ server {
|
|||||||
ssl_verify_depth 3;
|
ssl_verify_depth 3;
|
||||||
|
|
||||||
# Client cert revocation (optional)
|
# Client cert revocation (optional)
|
||||||
ssl_crl /etc/nginx/ssl/iot.crl;
|
# ssl_crl /etc/nginx/ssl/iot.crl;
|
||||||
|
|
||||||
# Forward client cert details upstream
|
# Forward client cert details upstream
|
||||||
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
||||||
|
|||||||
@@ -17,5 +17,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.Device{},
|
&models.Device{},
|
||||||
&models.Record{},
|
&models.Record{},
|
||||||
&models.UserDevice{},
|
&models.UserDevice{},
|
||||||
|
&models.Tracker{},
|
||||||
|
&models.UserTracker{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
44
server/internal/dto/tracker.go
Normal file
44
server/internal/dto/tracker.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "smoop-api/internal/models"
|
||||||
|
|
||||||
|
type TrackerDto struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Users []UserDto `json:"users,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackerListDto struct {
|
||||||
|
Trackers []TrackerDto `json:"trackers"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateTrackerDto struct {
|
||||||
|
GUID string `json:"guid" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
UserIDs []uint `json:"userIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenameTrackerDto struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetTrackerUsersDto struct {
|
||||||
|
UserIDs []uint `json:"userIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapTracker(t models.Tracker) TrackerDto {
|
||||||
|
out := TrackerDto{
|
||||||
|
GUID: t.GUID,
|
||||||
|
Name: t.Name,
|
||||||
|
}
|
||||||
|
if len(t.Users) > 0 {
|
||||||
|
out.Users = make([]UserDto, 0, len(t.Users))
|
||||||
|
for _, u := range t.Users {
|
||||||
|
out.Users = append(out.Users, MapUser(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
212
server/internal/handlers/trackers.go
Normal file
212
server/internal/handlers/trackers.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"smoop-api/internal/dto"
|
||||||
|
"smoop-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackersHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrackersHandler(db *gorm.DB) *TrackersHandler { return &TrackersHandler{db: db} }
|
||||||
|
|
||||||
|
// GET /trackers — list trackers available for user (admin: all)
|
||||||
|
func (h *TrackersHandler) List(c *gin.Context) {
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior (fallback if middleware not present): use user context
|
||||||
|
isFilter := true
|
||||||
|
var userID uint
|
||||||
|
|
||||||
|
if v, ok := c.Get("filterTrackers"); ok {
|
||||||
|
if b, ok2 := v.(bool); ok2 {
|
||||||
|
isFilter = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := c.Get("userID"); ok {
|
||||||
|
if id, ok2 := v.(uint); ok2 {
|
||||||
|
userID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to claims if middleware wasn’t applied
|
||||||
|
if _, exists := c.Get("filterTrackers"); !exists {
|
||||||
|
if uc, ok := GetUserContext(c); ok {
|
||||||
|
if uc.Role == models.RoleAdmin {
|
||||||
|
isFilter = false
|
||||||
|
} else {
|
||||||
|
isFilter = true
|
||||||
|
userID = uc.ID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
total int64
|
||||||
|
list []models.Tracker
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if !isFilter {
|
||||||
|
// Admin: all trackers
|
||||||
|
err = h.db.Model(&models.Tracker{}).Count(&total).Error
|
||||||
|
if err == nil {
|
||||||
|
err = h.db.Preload("Users").Offset(offset).Limit(limit).Find(&list).Error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Filtered by userID
|
||||||
|
q := h.db.Model(&models.Tracker{}).
|
||||||
|
Joins("JOIN user_trackers ut ON ut.tracker_guid = trackers.guid").
|
||||||
|
Where("ut.user_id = ?", userID)
|
||||||
|
if err = q.Count(&total).Error; err == nil {
|
||||||
|
err = h.db.Preload("Users").
|
||||||
|
Joins("JOIN user_trackers ut ON ut.tracker_guid = trackers.guid").
|
||||||
|
Where("ut.user_id = ?", userID).
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&list).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]dto.TrackerDto, 0, len(list))
|
||||||
|
for _, t := range list {
|
||||||
|
out = append(out, dto.MapTracker(t))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, dto.TrackerListDto{
|
||||||
|
Trackers: out, Offset: offset, Limit: limit, Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /trackers/create — create tracker; optional initial user assignments
|
||||||
|
func (h *TrackersHandler) Create(c *gin.Context) {
|
||||||
|
var req dto.CreateTrackerDto
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := models.Tracker{GUID: req.GUID, Name: req.Name}
|
||||||
|
if err := h.db.Create(&t).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tracker exists?"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// optional users
|
||||||
|
if len(req.UserIDs) > 0 {
|
||||||
|
users, err := h.fetchUsers(req.UserIDs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.Model(&t).Association("Users").Append(&users); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "link failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var withUsers models.Tracker
|
||||||
|
if err := h.db.Preload("Users").Where("guid = ?", t.GUID).First(&withUsers).Error; err != nil {
|
||||||
|
c.JSON(http.StatusCreated, dto.TrackerDto{GUID: t.GUID, Name: t.Name})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, dto.MapTracker(withUsers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /trackers/:guid/rename — rename tracker
|
||||||
|
func (h *TrackersHandler) Rename(c *gin.Context) {
|
||||||
|
guid := c.Param("guid")
|
||||||
|
var t models.Tracker
|
||||||
|
if err := h.db.Where("guid = ?", guid).First(&t).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "tracker not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.RenameTrackerDto
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Name = req.Name
|
||||||
|
if err := h.db.Save(&t).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, dto.TrackerDto{GUID: t.GUID, Name: t.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /trackers/:guid/set_users — replace full user list (admin)
|
||||||
|
func (h *TrackersHandler) SetUsers(c *gin.Context) {
|
||||||
|
guid := c.Param("guid")
|
||||||
|
var t models.Tracker
|
||||||
|
if err := h.db.Where("guid = ?", guid).First(&t).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "tracker not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.SetTrackerUsersDto
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users := []models.User{}
|
||||||
|
if len(req.UserIDs) > 0 {
|
||||||
|
found, err := h.fetchUsers(req.UserIDs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users = found
|
||||||
|
}
|
||||||
|
if err := h.db.Model(&t).Association("Users").Clear(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "clear failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(users) > 0 {
|
||||||
|
if err := h.db.Model(&t).Association("Users").Append(&users); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "link failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var withUsers models.Tracker
|
||||||
|
_ = h.db.Preload("Users").Where("guid = ?", t.GUID).First(&withUsers).Error
|
||||||
|
c.JSON(http.StatusCreated, dto.MapTracker(withUsers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// local helper (same as devices.go)
|
||||||
|
func (h *TrackersHandler) fetchUsers(ids []uint) ([]models.User, error) {
|
||||||
|
unique := make(map[uint]struct{}, len(ids))
|
||||||
|
clean := make([]uint, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id != 0 {
|
||||||
|
if _, ok := unique[id]; !ok {
|
||||||
|
unique[id] = struct{}{}
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var users []models.User
|
||||||
|
if err := h.db.Find(&users, clean).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(users) != len(clean) {
|
||||||
|
return nil, fmt.Errorf("some users not found")
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
@@ -35,3 +35,32 @@ func DeviceAccessFilter() gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrackerAccessFilter middleware sets filtering context for tracker access
|
||||||
|
func TrackerAccessFilter() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userContext, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(401, gin.H{"error": "unauthorized"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok := userContext.(handlers.UserContext)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(401, gin.H{"error": "invalid user data"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set filter flag and user ID in context (mirrors devices)
|
||||||
|
if user.Role == models.RoleAdmin {
|
||||||
|
c.Set("filterTrackers", false) // Admin sees all trackers
|
||||||
|
} else {
|
||||||
|
c.Set("filterTrackers", true) // Regular user needs filtering
|
||||||
|
c.Set("userID", user.ID) // Store user ID for filtering (same key as devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
11
server/internal/models/tracker.go
Normal file
11
server/internal/models/tracker.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Tracker struct {
|
||||||
|
GUID string `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"size:255;not null"`
|
||||||
|
Users []User `gorm:"many2many:user_trackers;constraint:OnDelete:CASCADE;"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -10,11 +10,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
Username string `gorm:"uniqueIndex;size:255;not null"`
|
Username string `gorm:"uniqueIndex;size:255;not null"`
|
||||||
Password string `gorm:"not null"`
|
Password string `gorm:"not null"`
|
||||||
Role Role `gorm:"type:varchar(16);not null;default:'user'"`
|
Role Role `gorm:"type:varchar(16);not null;default:'user'"`
|
||||||
Devices []Device `gorm:"many2many:user_devices;constraint:OnDelete:CASCADE;"`
|
Devices []Device `gorm:"many2many:user_devices;constraint:OnDelete:CASCADE;"`
|
||||||
|
Trackers []Tracker `gorm:"many2many:user_trackers;constraint:OnDelete:CASCADE;"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
12
server/internal/models/user_tracker.go
Normal file
12
server/internal/models/user_tracker.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type UserTracker struct {
|
||||||
|
UserID uint `gorm:"primaryKey;index;not null"`
|
||||||
|
TrackerGUID string `gorm:"primaryKey;size:64;index;not null"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
|
||||||
|
User User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
|
Tracker Tracker `gorm:"constraint:OnDelete:CASCADE;foreignKey:TrackerGUID;references:GUID"`
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
|||||||
// --- MediaMTX handler
|
// --- MediaMTX handler
|
||||||
mediamtxH := handlers.NewMediaMTXHandler(db, jwtMgr, cfg.MediaMTX)
|
mediamtxH := handlers.NewMediaMTXHandler(db, jwtMgr, cfg.MediaMTX)
|
||||||
|
|
||||||
|
/// --- GPS tracker handler
|
||||||
|
trackersH := handlers.NewTrackersHandler(db)
|
||||||
|
|
||||||
// --- Public auth
|
// --- Public auth
|
||||||
r.POST("/auth/signup", authH.SignUp)
|
r.POST("/auth/signup", authH.SignUp)
|
||||||
r.POST("/auth/signin", authH.SignIn)
|
r.POST("/auth/signin", authH.SignIn)
|
||||||
@@ -73,6 +76,10 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
|||||||
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
|
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
|
||||||
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)
|
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)
|
||||||
|
|
||||||
|
r.GET("/trackers", authMW, middleware.TrackerAccessFilter(), trackersH.List)
|
||||||
|
r.POST("/trackers/create", authMW, trackersH.Create)
|
||||||
|
r.POST("/trackers/:guid/rename", authMW, trackersH.Rename)
|
||||||
|
r.POST("/trackers/:guid/set_users", authMW, adminOnly, trackersH.SetUsers)
|
||||||
// sensible defaults
|
// sensible defaults
|
||||||
r.MaxMultipartMemory = 64 << 20 // 64 MiB
|
r.MaxMultipartMemory = 64 << 20 // 64 MiB
|
||||||
_ = time.Now() // appease linters
|
_ = time.Now() // appease linters
|
||||||
|
|||||||
Reference in New Issue
Block a user