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)