diff --git a/management-ui/package-lock.json b/management-ui/package-lock.json
index bed06ce..7d7c889 100644
--- a/management-ui/package-lock.json
+++ b/management-ui/package-lock.json
@@ -20,6 +20,7 @@
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"uuid": "^11.1.0",
+ "vaul-vue": "^0.4.1",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
@@ -2624,6 +2625,114 @@
"uuid": "dist/esm/bin/uuid"
}
},
+ "node_modules/vaul-vue": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/vaul-vue/-/vaul-vue-0.4.1.tgz",
+ "integrity": "sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==",
+ "dependencies": {
+ "@vueuse/core": "^10.8.0",
+ "reka-ui": "^2.0.0",
+ "vue": "^3.4.5"
+ },
+ "peerDependencies": {
+ "reka-ui": "^2.0.0",
+ "vue": "^3.3.0"
+ }
+ },
+ "node_modules/vaul-vue/node_modules/@types/web-bluetooth": {
+ "version": "0.0.20",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
+ "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
+ "license": "MIT"
+ },
+ "node_modules/vaul-vue/node_modules/@vueuse/core": {
+ "version": "10.11.1",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
+ "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.20",
+ "@vueuse/metadata": "10.11.1",
+ "@vueuse/shared": "10.11.1",
+ "vue-demi": ">=0.14.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/vaul-vue/node_modules/@vueuse/core/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vaul-vue/node_modules/@vueuse/metadata": {
+ "version": "10.11.1",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
+ "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/vaul-vue/node_modules/@vueuse/shared": {
+ "version": "10.11.1",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
+ "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
+ "license": "MIT",
+ "dependencies": {
+ "vue-demi": ">=0.14.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/vaul-vue/node_modules/@vueuse/shared/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vite": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
diff --git a/management-ui/package.json b/management-ui/package.json
index c3f96ee..17b3c9e 100644
--- a/management-ui/package.json
+++ b/management-ui/package.json
@@ -21,6 +21,7 @@
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"uuid": "^11.1.0",
+ "vaul-vue": "^0.4.1",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
diff --git a/management-ui/src/components/ui/drawer/Drawer.vue b/management-ui/src/components/ui/drawer/Drawer.vue
new file mode 100644
index 0000000..5f3bedd
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/Drawer.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerClose.vue b/management-ui/src/components/ui/drawer/DrawerClose.vue
new file mode 100644
index 0000000..7b810fe
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerClose.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerContent.vue b/management-ui/src/components/ui/drawer/DrawerContent.vue
new file mode 100644
index 0000000..ea69404
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerContent.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerDescription.vue b/management-ui/src/components/ui/drawer/DrawerDescription.vue
new file mode 100644
index 0000000..856e601
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerDescription.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerFooter.vue b/management-ui/src/components/ui/drawer/DrawerFooter.vue
new file mode 100644
index 0000000..9336864
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerFooter.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerHeader.vue b/management-ui/src/components/ui/drawer/DrawerHeader.vue
new file mode 100644
index 0000000..ce16c2e
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerHeader.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerOverlay.vue b/management-ui/src/components/ui/drawer/DrawerOverlay.vue
new file mode 100644
index 0000000..a68c507
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerOverlay.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerTitle.vue b/management-ui/src/components/ui/drawer/DrawerTitle.vue
new file mode 100644
index 0000000..e64352f
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerTitle.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/DrawerTrigger.vue b/management-ui/src/components/ui/drawer/DrawerTrigger.vue
new file mode 100644
index 0000000..56e7ed3
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/DrawerTrigger.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/management-ui/src/components/ui/drawer/index.ts b/management-ui/src/components/ui/drawer/index.ts
new file mode 100644
index 0000000..7c9cfcf
--- /dev/null
+++ b/management-ui/src/components/ui/drawer/index.ts
@@ -0,0 +1,9 @@
+export { default as Drawer } from "./Drawer.vue"
+export { default as DrawerClose } from "./DrawerClose.vue"
+export { default as DrawerContent } from "./DrawerContent.vue"
+export { default as DrawerDescription } from "./DrawerDescription.vue"
+export { default as DrawerFooter } from "./DrawerFooter.vue"
+export { default as DrawerHeader } from "./DrawerHeader.vue"
+export { default as DrawerOverlay } from "./DrawerOverlay.vue"
+export { default as DrawerTitle } from "./DrawerTitle.vue"
+export { default as DrawerTrigger } from "./DrawerTrigger.vue"
diff --git a/server/internal/handlers/devices.go b/server/internal/handlers/devices.go
index da95db0..7fe3e9f 100644
--- a/server/internal/handlers/devices.go
+++ b/server/internal/handlers/devices.go
@@ -26,12 +26,52 @@ func (h *DevicesHandler) List(c *gin.Context) {
limit = 50
}
- var total int64
- h.db.Model(&models.Device{}).Count(&total)
+ // Get user context
+ userContext, exists := c.Get("user")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ return
+ }
+ user, ok := userContext.(UserContext)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user data"})
+ return
+ }
+
+ var total int64
var devs []models.Device
- if err := h.db.Preload("Users").Offset(offset).Limit(limit).Find(&devs).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
+ var err error
+
+ if user.Role == models.RoleAdmin {
+ // Admin user - show all devices
+ err = h.db.Model(&models.Device{}).Count(&total).Error
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "count query failed: " + err.Error()})
+ return
+ }
+
+ err = h.db.Preload("Users").Offset(offset).Limit(limit).Find(&devs).Error
+ } else {
+ err = h.db.Model(&models.Device{}).
+ Joins("INNER JOIN user_devices ON user_devices.id = devices.guid").
+ Where("user_devices.guid = ?", user.ID).
+ Count(&total).Error
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "count query failed: " + err.Error()})
+ return
+ }
+
+ err = h.db.Preload("Users").
+ Joins("INNER JOIN user_devices ON user_devices.id = devices.guid").
+ Where("user_devices.guid = ?", user.ID).
+ Offset(offset).Limit(limit).
+ Find(&devs).Error
+ }
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed: " + err.Error()})
return
}
@@ -39,6 +79,7 @@ func (h *DevicesHandler) List(c *gin.Context) {
for _, d := range devs {
out = append(out, dto.MapDevice(d))
}
+
c.JSON(http.StatusOK, dto.DeviceListDto{Devices: out, Offset: offset, Limit: limit, Total: total})
}
diff --git a/server/internal/handlers/helpers.go b/server/internal/handlers/helpers.go
index 7ae9e13..ed28f07 100644
--- a/server/internal/handlers/helpers.go
+++ b/server/internal/handlers/helpers.go
@@ -3,12 +3,20 @@ package handlers
import (
"net/http"
"smoop-api/internal/crypto"
+ "smoop-api/internal/models"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
+// UserContext holds structured user information from JWT
+type UserContext struct {
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Role models.Role `json:"role"`
+}
+
func Auth(jwtMgr *crypto.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
@@ -23,6 +31,12 @@ func Auth(jwtMgr *crypto.JWTManager) gin.HandlerFunc {
return
}
claims, _ := token.Claims.(jwt.MapClaims)
+ userContext := UserContext{
+ ID: uint(claims["sub"].(float64)),
+ Username: claims["name"].(string),
+ Role: models.Role(claims["role"].(string)),
+ }
+ c.Set("user", userContext)
c.Set("claims", claims)
c.Next()
}
@@ -30,8 +44,16 @@ func Auth(jwtMgr *crypto.JWTManager) gin.HandlerFunc {
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
- claims := MustClaims(c)
- if ClaimRole(claims) != role {
+ userContext, exists := c.Get("user")
+ if !exists {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ }
+ user, ok := userContext.(UserContext)
+ if !ok {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid user data"})
+ return
+ }
+ if string(user.Role) != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
@@ -76,3 +98,14 @@ func ClaimRole(claims map[string]interface{}) string {
}
return ""
}
+
+// New helper to get UserContext from context
+func GetUserContext(c *gin.Context) (UserContext, bool) {
+ userContext, exists := c.Get("user")
+ if !exists {
+ return UserContext{}, false
+ }
+
+ user, ok := userContext.(UserContext)
+ return user, ok
+}
diff --git a/server/internal/middleware/access.go b/server/internal/middleware/access.go
new file mode 100644
index 0000000..4d0b2e8
--- /dev/null
+++ b/server/internal/middleware/access.go
@@ -0,0 +1,37 @@
+package middleware
+
+import (
+ "smoop-api/internal/handlers"
+ "smoop-api/internal/models"
+
+ "github.com/gin-gonic/gin"
+)
+
+// DeviceAccessFilter middleware sets filtering context for device access
+func DeviceAccessFilter() 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
+ if user.Role == models.RoleAdmin {
+ c.Set("filterDevices", false) // Admin sees all devices
+ } else {
+ c.Set("filterDevices", true) // Regular user needs filtering
+ c.Set("userID", user.ID) // Store user ID for filtering
+ }
+
+ c.Next()
+ }
+}
diff --git a/server/internal/router/router.go b/server/internal/router/router.go
index 3d3ae20..f3c3408 100644
--- a/server/internal/router/router.go
+++ b/server/internal/router/router.go
@@ -11,6 +11,7 @@ import (
"smoop-api/internal/config"
"smoop-api/internal/crypto"
"smoop-api/internal/handlers"
+ "smoop-api/internal/middleware"
)
func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
@@ -41,7 +42,7 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
r.GET("/users", authMW, adminOnly, usersH.List)
r.POST("/users/create", authMW, adminOnly, usersH.Create)
- r.GET("/devices", authMW, devH.List)
+ r.GET("/devices", authMW, middleware.DeviceAccessFilter(), devH.List)
r.POST("/devices/create", authMW, devH.Create)
r.POST("/devices/:guid/rename", authMW, devH.Rename)
r.POST("/devices/:guid/add_to_user", authMW, devH.AddToUser)