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

View File

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

View File

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

View File

@@ -1,28 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
// import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenu, DropdownMenuContent,
DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator,
DropdownMenuItem, DropdownMenuItem, DropdownMenuLabel
DropdownMenuLabel } from '@/components/ui/dropdown-menu'
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils' 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 { 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> </script>
<template> <template>
<div class="flex flex-col min-h-screen"> <div class="flex flex-col min-h-screen">
<div class="flex items-center justify-end space-x-6 p-4"> <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 ?? '')"> <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"> <RouterLink
to="/admin"
:class="navLinkClass('/admin')"
:aria-current="isActive('/admin') ? 'page' : undefined"
>
Admin Admin
</a> </RouterLink>
<a href="/devices" <RouterLink
class="text-sm font-medium text-muted-foreground transition-colors hover:text-primary"> to="/devices"
:class="navLinkClass('/devices')"
:aria-current="isActive('/devices') ? 'page' : undefined"
>
Devices Devices
</a> </RouterLink>
</nav> </nav>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button class="p-2 rounded hover:bg-muted"> <button class="p-2 rounded hover:bg-muted">
@@ -30,28 +53,21 @@ const { customComponent } = defineProps<{ customComponent?: any }>()
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="w-48"> <DropdownMenuContent class="w-48">
<DropdownMenuLabel> <DropdownMenuLabel>Admin</DropdownMenuLabel>
Admin
</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<a href="/settings"> <RouterLink to="/settings">
<DropdownMenuItem> <DropdownMenuItem>Settings</DropdownMenuItem>
Settings </RouterLink>
</DropdownMenuItem>
</a>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<a href="/login"> <RouterLink to="/login">
<DropdownMenuItem> <DropdownMenuItem>Logout</DropdownMenuItem>
Logout </RouterLink>
</DropdownMenuItem>
</a>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div class="flex flex-1 flex-col gap-4 p-4"> <div class="flex flex-1 flex-col gap-4 p-4">
<!-- <component v-if="customComponent" :is="customComponent" /> -->
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -96,3 +96,30 @@ func (h *UsersHandler) Create(c *gin.Context) {
} }
c.JSON(http.StatusCreated, dto.MapUser(u)) 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.POST("/users/:id/set_role", authMW, adminOnly, usersH.SetRole)
r.GET("/users", authMW, adminOnly, usersH.List) r.GET("/users", authMW, adminOnly, usersH.List)
r.POST("/users/create", authMW, adminOnly, usersH.Create) 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.GET("/devices", authMW, middleware.DeviceAccessFilter(), devH.List)
r.POST("/devices/create", authMW, devH.Create) r.POST("/devices/create", authMW, devH.Create)