checkpoint before gorm fix

This commit is contained in:
tdv
2025-09-15 15:20:36 +03:00
parent 0f266d45a6
commit 673971deb8
6 changed files with 153 additions and 84 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<title>Snoop</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,44 +1,69 @@
<script setup lang="ts">
import {
DropdownMenu, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuContent
} from '@/components/ui/dropdown-menu';
DropdownMenu, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuContent
} from '@/components/ui/dropdown-menu'
import EditUserDialog from './EditUserDialog.vue';
import DeleteUserDialog from './DeleteUserDialog.vue';
import { Ellipsis } from 'lucide-vue-next';
import { ref } from 'vue';
import EditUserDialog from './EditUserDialog.vue'
import DeleteUserDialog from './DeleteUserDialog.vue'
import { Ellipsis } from 'lucide-vue-next'
import { ref } from 'vue'
import { api } from '@/lib/api' // <-- use your axios/fetch wrapper
const props = defineProps<{ userId: string }>()
const emit = defineEmits<{
(e: 'deleted', id: string): void
(e: 'error', err: unknown): void
}>()
const isEditOpen = ref(false)
const isDeleteOpen = ref(false)
const deleting = ref(false)
// your actual delete logic
function onDeleteConfirmed() {
// e.g. await api.deleteUser(props.userId)
isDeleteOpen.value = false
// DELETE /users/:id
async function onDeleteConfirmed() {
try {
deleting.value = true
await api.delete(`/users/${encodeURIComponent(props.userId)}`)
emit('deleted', props.userId)
} catch (err) {
console.error(err)
emit('error', err)
} finally {
deleting.value = false
// The dialog already closes itself on confirm; nothing else required.
}
}
function onEditConfirm() {
isEditOpen.value = false
isEditOpen.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">
Edit
</DropdownMenuItem>
<DropdownMenuItem @click.prevent="isDeleteOpen = true">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditUserDialog v-model:modelValue="isEditOpen" @confirm="onEditConfirm()" />
<DeleteUserDialog v-model:modelValue="isDeleteOpen" @confirm="onDeleteConfirmed" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button class="p-2 rounded hover:bg-muted" :disabled="deleting">
<Ellipsis />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-[160px]">
<DropdownMenuItem @click.prevent="isEditOpen = true">
Edit
</DropdownMenuItem>
<DropdownMenuItem @click.prevent="isDeleteOpen = true" :disabled="deleting">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditUserDialog v-model:modelValue="isEditOpen" @confirm="onEditConfirm()" />
<!-- pass 'deleting' to disable the confirm button during request -->
<DeleteUserDialog
v-model:modelValue="isDeleteOpen"
:loading="deleting"
@confirm="onDeleteConfirmed"
/>
</template>

View File

@@ -12,10 +12,8 @@ import {
import { defineProps, defineEmits, type PropType } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean as PropType<boolean>,
required: true,
},
modelValue: { type: Boolean as PropType<boolean>, required: true },
loading: { type: Boolean as PropType<boolean>, default: false },
})
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
@@ -36,10 +34,12 @@ const emit = defineEmits<{
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel :disabled="props.loading">Cancel</AlertDialogCancel>
<AlertDialogAction
:disabled="props.loading"
@click="() => { emit('confirm'); emit('update:modelValue', false) }"
> Delete
>
{{ props.loading ? 'Deleting…' : 'Delete' }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -1,57 +1,73 @@
<script setup lang="ts">
// import { Card, CardHeader, CardContent } from '@/components/ui/card';
import {
DropdownMenu, DropdownMenuContent,
DropdownMenuTrigger, DropdownMenuSeparator,
DropdownMenuItem,
DropdownMenuLabel
} from '@/components/ui/dropdown-menu';
DropdownMenu, DropdownMenuContent,
DropdownMenuTrigger, DropdownMenuSeparator,
DropdownMenuItem, DropdownMenuLabel
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { Settings } from 'lucide-vue-next';
import { Settings } from 'lucide-vue-next'
import { RouterLink, useRoute } from 'vue-router'
const { customComponent } = defineProps<{ customComponent?: any }>()
const route = useRoute()
function isActive(prefix: string) {
const p = route.path.replace(/\/+$/, '')
const tgt = prefix.replace(/\/+$/, '')
return p === tgt || p.startsWith(tgt + '/')
}
function navLinkClass(prefix: string) {
return cn(
'text-sm font-medium transition-colors',
isActive(prefix) ? 'text-primary' : 'text-muted-foreground hover:text-primary'
)
}
</script>
<template>
<div class="flex flex-col min-h-screen">
<div class="flex items-center justify-end space-x-6 p-4">
<nav :class="cn('flex items-center space-x-4 lg:space-x-6', $attrs.class ?? '')">
<a href="/admin" class="text-sm font-medium transition-colors hover:text-primary">
Admin
</a>
<a href="/devices"
class="text-sm font-medium text-muted-foreground transition-colors hover:text-primary">
Devices
</a>
</nav>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button class="p-2 rounded hover:bg-muted">
<Settings />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48">
<DropdownMenuLabel>
Admin
</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="/settings">
<DropdownMenuItem>
Settings
</DropdownMenuItem>
</a>
<DropdownMenuSeparator />
<a href="/login">
<DropdownMenuItem>
Logout
</DropdownMenuItem>
</a>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="flex flex-1 flex-col gap-4 p-4">
<!-- <component v-if="customComponent" :is="customComponent" /> -->
<slot></slot>
</div>
<div class="flex flex-col min-h-screen">
<div class="flex items-center justify-end space-x-6 p-4">
<nav :class="cn('flex items-center space-x-4 lg:space-x-6', $attrs.class ?? '')">
<RouterLink
to="/admin"
:class="navLinkClass('/admin')"
:aria-current="isActive('/admin') ? 'page' : undefined"
>
Admin
</RouterLink>
<RouterLink
to="/devices"
:class="navLinkClass('/devices')"
:aria-current="isActive('/devices') ? 'page' : undefined"
>
Devices
</RouterLink>
</nav>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button class="p-2 rounded hover:bg-muted">
<Settings />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48">
<DropdownMenuLabel>Admin</DropdownMenuLabel>
<DropdownMenuSeparator />
<RouterLink to="/settings">
<DropdownMenuItem>Settings</DropdownMenuItem>
</RouterLink>
<DropdownMenuSeparator />
<RouterLink to="/login">
<DropdownMenuItem>Logout</DropdownMenuItem>
</RouterLink>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="flex flex-1 flex-col gap-4 p-4">
<slot></slot>
</div>
</div>
</template>

View File

@@ -96,3 +96,30 @@ func (h *UsersHandler) Create(c *gin.Context) {
}
c.JSON(http.StatusCreated, dto.MapUser(u))
}
// DELETE /users/:id (admin) — delete user and clear device relations
func (h *UsersHandler) Delete(c *gin.Context) {
idStr := c.Param("id")
id, _ := strconv.Atoi(idStr)
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var u models.User
if err := h.db.Preload("Devices").First(&u, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// optional safeguard: prevent self-delete; uncomment if desired
// if ClaimUserID(MustClaims(c)) == u.ID { c.JSON(http.StatusBadRequest, gin.H{"error":"cannot delete yourself"}); return }
if err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&u).Association("Devices").Clear(); err != nil {
return err
}
return tx.Delete(&u).Error
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -41,6 +41,7 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
r.POST("/users/:id/set_role", authMW, adminOnly, usersH.SetRole)
r.GET("/users", authMW, adminOnly, usersH.List)
r.POST("/users/create", authMW, adminOnly, usersH.Create)
r.DELETE("/users/:id", authMW, adminOnly, usersH.Delete)
r.GET("/devices", authMW, middleware.DeviceAccessFilter(), devH.List)
r.POST("/devices/create", authMW, devH.Create)