diff --git a/.env.backend.example b/.env.backend.example new file mode 100644 index 0000000..cb59d6d --- /dev/null +++ b/.env.backend.example @@ -0,0 +1,13 @@ +SUMUP_API_KEY=sup_sk_xxxxx #https://me.sumup.com/settings/developer +SUMUP_RETURN_URL=https://fqdn.tld/api/payment/v1/callback #make sure this endpoint is reachable! +JWT_SECRET=changeme #generate a random alphanumeric 64-char string here at least. please. + +GIN_TRUSTED_PROXIES=localhost #change this to your reverse proxy ip if applicable +CORS_ALLOWED_ORIGINS=http://localhost:8080 #all allowed frontend origins, comma separated + +DB_HOST=localhost +POSTGRES_USER=drinks +POSTGRES_PASSWORD=xxx +POSTGRES_DATABASE=drinks +DB_PORT=5432 +DB_TIMEZONE=Europe/Vienna \ No newline at end of file diff --git a/.env.frontend.example b/.env.frontend.example new file mode 100644 index 0000000..5e0d0e6 --- /dev/null +++ b/.env.frontend.example @@ -0,0 +1,2 @@ +# In production, change this to your actual URL +NEXT_PUBLIC_API_URL=https://drinks.example.com \ No newline at end of file diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index 4a3176f..ff1344f 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -70,6 +70,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | + type=raw,prefix=backend-,value=latest,enable=${{ github.event_name != 'pull_request' }} type=ref,prefix=backend-,event=branch type=ref,prefix=backend-,event=tag type=ref,prefix=backend-pr-,event=pr diff --git a/.github/workflows/docker-publish-frontend.yml b/.github/workflows/docker-publish-frontend.yml index 42f7584..fe777d1 100644 --- a/.github/workflows/docker-publish-frontend.yml +++ b/.github/workflows/docker-publish-frontend.yml @@ -70,6 +70,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | + type=raw,prefix=frontend-,value=latest,enable=${{ github.event_name != 'pull_request' }} type=ref,prefix=frontend-,event=branch type=ref,prefix=frontend-,event=tag type=ref,prefix=frontend-pr-,event=pr diff --git a/.gitignore b/.gitignore index 42b2b19..6bd539d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,140 +1,2 @@ -.env - -### GoLand ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### GoLand Patch ### -*.iml -modules.xml -.idea/misc.xml -*.ipr - -# Sonarlint plugin -# https://plugins.jetbrains.com/plugin/7973-sonarlint -.idea/**/sonarlint/ - -# SonarQube Plugin -# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin -.idea/**/sonarIssues.xml - -# Markdown Navigator plugin -# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced -.idea/**/markdown-navigator.xml -.idea/**/markdown-navigator-enh.xml -.idea/**/markdown-navigator/ - -# CodeStream plugin -# https://plugins.jetbrains.com/plugin/12206-codestream -.idea/codestream.xml - -# Azure Toolkit for IntelliJ plugin -# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij -.idea/**/azureSettings.xml - -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### macOS Patch ### -# iCloud generated files -*.icloud \ No newline at end of file +.env.* +!.env.*.example \ No newline at end of file diff --git a/README.md b/README.md index 8e55ad1..9e98a8b 100644 --- a/README.md +++ b/README.md @@ -2,53 +2,14 @@ This project contains the backend and frontend code for the Metadrinks project. ## Setting up -Copy the contents of `.env.example` to `.env` and replace the values accordingly. +Copy `docker-compose.yml`, `.env.backend.example` and `.env.frontend.example` to your project root directory. Replace the values in the example files accordingly and remove the `.example` suffix. -### docker-compose.yml - -``` ---- -services: - postgres: - image: postgres:17-alpine - container_name: metadrinks-postgres - environment: - - TZ=Europe/Vienna - - POSTGRES_USER= - - POSTGRES_PASSWORD= - - POSTGRES_DB= - volumes: - - ./pg_data:/var/lib/postgresql/data - networks: - - backend - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "pg_isready -U -d "] - interval: 1s - retries: 10 - start_period: 10s - timeout: 30s - - backend: - image: ghcr.io/metalab/Metadrinks-backend:main - container_name: metadrinks-backend - networks: - - backend - ports: - - 8080:8080 - depends_on: - postgres: - condition: service_healthy - restart: true - restart: unless-stopped - -networks: - backend: -``` - -Using this example, your `DB_HOST` would be `metadrinks-postgres`, your `DB_PORT` would be `5432` and everything else would be what you replace the placeholder values with. +To start, execute `docker compose up -d`. If required or wanted, you can change the ports (in the format `host-port:container-port`, only change the host-port) or the version to ensure no unwanted upgrades happen. ## Usage +On first start, a default guest and admin user are generated. The admin password will be printed to your console ONCE (visible by either running `docker compose up` when starting or `docker compose logs` after start). + +After the first start, it is recommended to change the admin password via the admin interface under `/admin`. From this page, all management of items, users, readers and purchases takes place. ### API Docs -coming soon (when the api is semi-stable and tested) +Currently, the API docs are served by the backend under /docs/index.html with the file "../swapper.json". diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index ab0b069..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -SUMUP_API_KEY=sup_sk_xxxxx #https://me.sumup.com/settings/developer -SUMUP_RETURN_URL=https://fqdn.tld/api/payments/callback #make sure this endpoint is reachable! -JWT_SECRET=changeme #generate a random alphanumeric 64-char string here at least. please. - -GIN_TRUSTED_PROXIES=localhost #change this to your proxy ip - -DB_HOST=localhost -DB_USER=drinks -DB_PASSWORD=xxx -DB_DATABASE=drinks -DB_PORT=5432 -DB_TIMEZONE=Europe/Vienna \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..72ba521 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,142 @@ +.env + +### GoLand ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/dataSources.xml +.idea/**/data_source_mapping.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand Patch ### +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud \ No newline at end of file diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml index 6c0b863..efccd08 100644 --- a/backend/.idea/vcs.xml +++ b/backend/.idea/vcs.xml @@ -1,5 +1,11 @@ + + + + + + diff --git a/backend/Dockerfile b/backend/Dockerfile index af520ee..5e9cdcf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,10 +1,11 @@ FROM golang:alpine AS builder WORKDIR /app -COPY .. . +COPY . . RUN go build -o main . FROM alpine +RUN apk add --no-cache tzdata COPY --from=builder /app/main ./main CMD ["./main"] \ No newline at end of file diff --git a/backend/controllers/api/admin/v1/settings.go b/backend/controllers/api/admin/v1/settings.go new file mode 100644 index 0000000..70465f1 --- /dev/null +++ b/backend/controllers/api/admin/v1/settings.go @@ -0,0 +1,75 @@ +package v1 + +import ( + "encoding/json" + "fmt" + sse "metalab/metadrinks/controllers/api/payment/v1" + "metalab/metadrinks/models" + "net/http" + + "github.com/gin-gonic/gin" +) + +func FindAdminSettings(c *gin.Context) { + var settings models.Settings + + if err := models.DB.Where("id = ?", 1).First(&settings).Error; err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + c.JSON(http.StatusOK, gin.H{"data": settings}) +} + +func FindSettings(c *gin.Context) { + var settings models.Settings + if err := models.DB.Where("id = ?", 1).First(&settings).Error; err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + settings.MerchantInfo = nil // remove merchant info from normal response + c.JSON(http.StatusOK, gin.H{"data": settings}) +} + +type UpdateSettingsInput struct { + MaintenanceMode *bool `json:"maintenance,omitempty"` + DefaultReaderId *string `json:"default_reader_id,omitempty"` +} + +func UpdateSettings(c *gin.Context) { + var input UpdateSettingsInput + if err := c.ShouldBindJSON(&input); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var settings models.Settings + if err := models.DB.Where("id = ?", 1).First(&settings).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"}) + return + } + + updatedSettings := models.Settings{MaintenanceMode: input.MaintenanceMode, DefaultReaderId: input.DefaultReaderId} + + models.DB.Model(&settings).Updates(&updatedSettings) + + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "settings", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) + c.JSON(http.StatusOK, gin.H{"data": settings}) +} diff --git a/backend/controllers/api/admin/v1/user.go b/backend/controllers/api/admin/v1/user.go new file mode 100644 index 0000000..51a7157 --- /dev/null +++ b/backend/controllers/api/admin/v1/user.go @@ -0,0 +1,174 @@ +package v1 + +import ( + "encoding/json" + "fmt" + sse "metalab/metadrinks/controllers/api/payment/v1" + "metalab/metadrinks/libs/crypto" + "metalab/metadrinks/models" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type CreateUserAdminInput struct { + Name string `json:"name" binding:"required"` + Password string `json:"password,omitempty"` + IsTrusted *bool `json:"is_trusted,omitempty"` + IsAdmin *bool `json:"is_admin,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + IsRestricted *bool `json:"is_restricted,omitempty"` +} + +// CreateUser godoc +// +// @Summary Create user +// @Description creates a new user +// @Tags admin +// @Accept json +// @Produce json +// @Success 200 {object} models.User +// @Failure 400 +// @Failure 500 +// +// @Param user body CreateUserAdminInput true "Create user" +// +// @Router /users [post] +func CreateUser(c *gin.Context) { + var input CreateUserAdminInput + if err := c.ShouldBindJSON(&input); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(input.Name) > 24 { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "name must not be longer than 24 characters"}) + return + } + + userId := uuid.New() + + hashedPassword, err := crypto.HashPasswordSecure(input.Password) //bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user := models.User{UserID: userId, Name: input.Name, Password: hashedPassword, IsTrusted: input.IsTrusted, IsAdmin: input.IsAdmin, IsActive: input.IsActive, IsRestricted: input.IsRestricted, UsedAt: time.Now().Local()} + models.DB.Create(&user) + + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "users", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +// FindUsers godoc +// +// @Summary Find users +// @Description Lists all users +// @Tags admin +// @Accept json +// @Produce json +// @Success 200 {object} []models.User +// @Failure 500 +// +// +// @Router /users [get] +func FindUsers(c *gin.Context) { + var users []models.User + + models.DB.Order("used_at DESC").Find(&users) + + for i := range users { // do not return the user password + users[i].Password = "" + } + + c.Header("Content-Type", "application/json") + c.JSON(http.StatusOK, gin.H{"data": users}) +} + +type UpdateUserInput struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` + LoginBarcode string `json:"login_barcode,omitempty"` + Image string `json:"image,omitempty"` + Balance int `json:"balance,omitempty"` + IsTrusted *bool `json:"is_trusted,omitempty"` + IsAdmin *bool `json:"is_admin,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + IsRestricted *bool `json:"is_restricted,omitempty"` +} + +func UpdateUser(c *gin.Context) { + var input UpdateUserInput + if err := c.ShouldBindJSON(&input); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := models.DB.Where("user_id = ?", c.Param("id")).First(&user).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"}) + return + } + + if len(input.Name) > 24 { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "name must not be longer than 24 characters"}) + return + } + + if input.Password != "" { + hashedPassword, err := crypto.HashPasswordSecure(input.Password) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + input.Password = hashedPassword + } + + if input.Balance < 0 { + // add logic for removing balance as administrative action in log + } else if input.Balance > 0 { + // add logic for adding balance as administrative action in log + } + + updatedUser := models.User{Name: input.Name, Password: input.Password, LoginBarcode: input.LoginBarcode, Image: input.Image, Balance: input.Balance, IsTrusted: input.IsTrusted, IsAdmin: input.IsAdmin, IsActive: input.IsActive, IsRestricted: input.IsRestricted} + + models.DB.Model(&user).Updates(&updatedUser) + + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "users", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) + c.JSON(http.StatusOK, gin.H{"data": user}) +} diff --git a/backend/controllers/api/admin/v1/zz_router.go b/backend/controllers/api/admin/v1/zz_router.go new file mode 100644 index 0000000..c4d561c --- /dev/null +++ b/backend/controllers/api/admin/v1/zz_router.go @@ -0,0 +1,18 @@ +package v1 + +import ( + "metalab/metadrinks/controllers/auth" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutesV1(r *gin.RouterGroup) { + u := r.Group("users") + u.POST("", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), CreateUser) + u.GET("", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), FindUsers) + u.PUT("/:id", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), UpdateUser) + + s := r.Group("settings") + s.POST("", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), UpdateSettings) + s.GET("", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), FindAdminSettings) +} diff --git a/backend/controllers/api/admin/zz_router.go b/backend/controllers/api/admin/zz_router.go new file mode 100644 index 0000000..8d03e47 --- /dev/null +++ b/backend/controllers/api/admin/zz_router.go @@ -0,0 +1,12 @@ +package admin + +import ( + "metalab/metadrinks/controllers/api/admin/v1" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutesAdmin(r *gin.RouterGroup) { + groupV1 := r.Group("/v1") + v1.RegisterRoutesV1(groupV1) +} diff --git a/backend/controllers/payment/v1/sse.go b/backend/controllers/api/payment/v1/sse.go similarity index 50% rename from backend/controllers/payment/v1/sse.go rename to backend/controllers/api/payment/v1/sse.go index d5ff10d..abfc304 100644 --- a/backend/controllers/payment/v1/sse.go +++ b/backend/controllers/api/payment/v1/sse.go @@ -3,6 +3,7 @@ package v1 import ( "io" "log" + "time" sumupmodels "metalab/metadrinks/models/sumup" @@ -10,10 +11,11 @@ import ( ) type Event struct { - Message chan string - NewClients chan chan string - ClosedClients chan chan string - TotalClients map[chan string]bool + Message chan string + NewClients chan chan string + ClosedClients chan chan string + TotalClients map[chan string]bool + HeartbeatInterval time.Duration } type ClientChan chan string @@ -33,6 +35,9 @@ func NewServer() *Event { } func (Stream *Event) listen() { + heartbeat := time.NewTicker(30 * time.Second) + defer heartbeat.Stop() + for { select { case client := <-Stream.NewClients: @@ -40,17 +45,47 @@ func (Stream *Event) listen() { log.Printf("Client added. %d registered clients", len(Stream.TotalClients)) case client := <-Stream.ClosedClients: - delete(Stream.TotalClients, client) - close(client) - log.Printf("Removed client. %d registered clients", len(Stream.TotalClients)) + if _, ok := Stream.TotalClients[client]; ok { + delete(Stream.TotalClients, client) + close(client) + log.Printf("Removed client. %d registered clients", len(Stream.TotalClients)) + } case eventMsg := <-Stream.Message: for clientMessageChan := range Stream.TotalClients { select { case clientMessageChan <- eventMsg: default: + log.Printf("Client channel blocked, removing") + delete(Stream.TotalClients, clientMessageChan) + close(clientMessageChan) + } + } + + case <-heartbeat.C: + if len(Stream.TotalClients) == 0 { + continue + } + + var disconnectedClients []chan string + + for clientMessageChan := range Stream.TotalClients { + select { + case clientMessageChan <- `{"type":"heartbeat","data":{"timestamp":"` + time.Now().Format(time.RFC3339) + `"}}`: + default: + log.Printf("Client not responding to heartbeat, marking for removal") + disconnectedClients = append(disconnectedClients, clientMessageChan) } } + + for _, client := range disconnectedClients { + delete(Stream.TotalClients, client) + close(client) + } + + if len(disconnectedClients) > 0 { + log.Printf("Removed %d disconnected clients. %d clients remaining", len(disconnectedClients), len(Stream.TotalClients)) + } } } } @@ -72,13 +107,18 @@ const ( SSENotificationTransactionUpdate string = "transaction_update" ) +type SSENotificationContentUpdatePayload struct { + Type string `json:"type"` //users or items, for now +} + type SSENotificationTransactionUpdatePayload struct { ClientTransactionId string `json:"client_transaction_id"` TransactionStatus sumupmodels.TransactionFullStatus `json:"transaction_status"` } type SSENotificationPayload struct { - TransactionPayload *SSENotificationTransactionUpdatePayload + TransactionPayload *SSENotificationTransactionUpdatePayload `json:"transaction_payload"` + ContentPayload *SSENotificationContentUpdatePayload `json:"content_payload"` } func (Stream *Event) SendMessage(message string) { @@ -99,22 +139,44 @@ func SSEHeadersMiddleware() gin.HandlerFunc { func (Stream *Event) ServeHTTP() gin.HandlerFunc { return func(c *gin.Context) { - clientChan := make(ClientChan) + // buffered channel to prevent blocking + clientChan := make(ClientChan, 10) Stream.NewClients <- clientChan + c.SSEvent("onopen", gin.H{ + "type": "connection_established", + "data": gin.H{ + "status": "connected", + "timestamp": time.Now().Unix(), + }, + }) + c.Writer.Flush() + + defer func() { + Stream.ClosedClients <- clientChan + }() + + // Monitor for client disconnect + notify := c.Writer.CloseNotify() go func() { - <-c.Writer.CloseNotify() - for range clientChan { - } + <-notify + log.Printf("Client disconnected (CloseNotify)") Stream.ClosedClients <- clientChan }() c.Stream(func(w io.Writer) bool { - if msg, ok := <-clientChan; ok { + select { + case msg, ok := <-clientChan: + if !ok { + log.Printf("Client channel closed") + return false + } c.SSEvent("message", msg) return true + case <-notify: + log.Printf("Client disconnected during stream") + return false } - return false }) } } diff --git a/backend/controllers/payment/v1/sumup.go b/backend/controllers/api/payment/v1/sumup.go similarity index 73% rename from backend/controllers/payment/v1/sumup.go rename to backend/controllers/api/payment/v1/sumup.go index 25c36e9..df942e9 100644 --- a/backend/controllers/payment/v1/sumup.go +++ b/backend/controllers/api/payment/v1/sumup.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + jwt "metalab/metadrinks/libs/auth" "net/http" "metalab/metadrinks/libs" "metalab/metadrinks/models" sumupmodels "metalab/metadrinks/models/sumup" + "github.com/google/uuid" "github.com/sumup/sumup-go/readers" "github.com/gin-gonic/gin" @@ -151,11 +153,6 @@ func DeleteReaderByName(name string) error { return nil } -type TerminateReaderInput struct { - ReaderId string `json:"id"` - ReaderName string `json:"name"` -} - // TerminateReaderCheckout godoc // // @Summary Terminate reader checkout @@ -166,47 +163,27 @@ type TerminateReaderInput struct { // @Success 200 // @Failure 500 // -// @Param reader body TerminateReaderInput true "Terminate reader input" +// @Param id path string true "Reader ID" // -// @Router /readers/terminate [delete] +// @Router /readers/terminate/{id} [delete] func TerminateReaderCheckout(c *gin.Context) { - var input TerminateReaderInput - if err := c.ShouldBindJSON(&input); err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } + var reader sumupmodels.Reader + userClaims := jwt.ExtractClaims(c) + jwtSubject := userClaims["sub"].(string) + userId := uuid.MustParse(userClaims["userId"].(string)) - if input.ReaderId == "" && input.ReaderName == "" { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "reader id/name missing"}) + if err := models.DB.Where("reader_id = ?", c.Param("id")).First(&reader).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) return - } else if input.ReaderId == "" && input.ReaderName != "" { // name defined, id undefined - var dbReader *sumupmodels.Reader - var findErr error - dbReader, findErr = FindReaderByName(input.ReaderName) - if findErr != nil { - fmt.Printf("error finding reader by name: %s\n", findErr.Error()) - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": findErr.Error()}) - return - } + } - terminateErr := libs.SumupClient.Readers.TerminateCheckout(context.Background(), *libs.SumupAccount.MerchantProfile.MerchantCode, string(dbReader.ReaderId)) // uses reader id from db, retrieved from name - if terminateErr != nil { - fmt.Printf("error while terminating checkout by name: %s\n", terminateErr.Error()) - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": terminateErr.Error()}) - return - } - } else if input.ReaderId != "" && input.ReaderName == "" { // name undefined, id defined - terminateErr := libs.SumupClient.Readers.TerminateCheckout(context.Background(), *libs.SumupAccount.MerchantProfile.MerchantCode, input.ReaderId) // uses reader id from input - if terminateErr != nil { - fmt.Printf("error while terminating checkout by id: %s\n", terminateErr.Error()) - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": terminateErr.Error()}) - return - } - } else { - fmt.Printf("unknown error while terminating checkout\n") - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "unknown error while terminating checkout"}) + terminateErr := libs.SumupClient.Readers.TerminateCheckout(context.Background(), *libs.SumupAccount.MerchantProfile.MerchantCode, string(reader.ReaderId)) + if terminateErr != nil { + fmt.Printf("error while terminating checkout by name: %s\n", terminateErr.Error()) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": terminateErr.Error()}) return } + fmt.Printf("checkout on reader %s terminated by %s(%s)\n", reader.ReaderId, jwtSubject, userId) c.JSON(http.StatusOK, gin.H{"data": "success"}) } @@ -282,6 +259,53 @@ func UnlinkReader(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": "success"}) } +func DeleteReader(c *gin.Context) { + var reader sumupmodels.Reader + if err := models.DB.Where("reader_id = ?", c.Param("id")).First(&reader).Error; err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + unlinkErr := libs.SumupClient.Readers.DeleteReader(context.Background(), *libs.SumupAccount.MerchantProfile.MerchantCode, readers.ReaderId(reader.ReaderId)) + if unlinkErr != nil { + fmt.Printf("error while unlinking reader by id: %s\n", unlinkErr.Error()) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": unlinkErr.Error()}) + return + } + + var settings models.Settings + if err := models.DB.Where("id = ?", 1).First(&settings).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"}) + return + } + + if settings.DefaultReaderId != nil && *settings.DefaultReaderId == string(reader.ReaderId) { + updatedSettings := models.Settings{MaintenanceMode: settings.MaintenanceMode, DefaultReaderId: nil} + models.DB.Model(&settings).Updates(&updatedSettings) + + notification := SSENotification{ + NotificationType: SSENotificationType(SSENotificationContentUpdate), + NotificationData: SSENotificationPayload{ + ContentPayload: &SSENotificationContentUpdatePayload{ + Type: "settings", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + Stream.SendMessage(string(notificationJSON)) + } + models.DB.Where("reader_id = ?", reader.ReaderId).Delete(&reader) + + c.JSON(http.StatusOK, gin.H{"data": "success"}) +} + // GetIncomingWebhook godoc // // @Summary Get incoming webhook @@ -298,21 +322,31 @@ func UnlinkReader(c *gin.Context) { func GetIncomingWebhook(c *gin.Context) { // After receiving a webhook call, your application must always verify if the event really took place, by calling a relevant SumUp's API. var input sumupmodels.ReaderCheckoutStatusChange + var purchase models.Purchase if err := c.ShouldBindJSON(&input); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if err := models.DB.Where("client_transaction_id = ?", input.Payload.ClientTransactionId).First(&purchase).Error; err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + insertData := models.Purchase{TransactionStatus: input.Payload.Status} fmt.Printf("incoming sumup webhook: %v", input.Payload) - models.DB.Where("client_transaction_id = ?", input.Payload.ClientTransactionId).Updates(insertData) + if purchase.RefundAmount != 0 && input.Payload.Status == "successful" { + libs.UpdateUserBalance(purchase.CreatedBy, int(purchase.RefundAmount)) + } + + models.DB.Model(&purchase).Updates(insertData) notification := SSENotification{ NotificationType: SSENotificationType(SSENotificationTransactionUpdate), NotificationData: SSENotificationPayload{ TransactionPayload: &SSENotificationTransactionUpdatePayload{ - ClientTransactionId: input.Payload.TransactionId, + ClientTransactionId: input.Payload.ClientTransactionId, TransactionStatus: input.Payload.Status, }, }, diff --git a/backend/controllers/api/payment/v1/zz_router.go b/backend/controllers/api/payment/v1/zz_router.go new file mode 100644 index 0000000..ff90b60 --- /dev/null +++ b/backend/controllers/api/payment/v1/zz_router.go @@ -0,0 +1,21 @@ +package v1 + +import ( + "metalab/metadrinks/controllers/auth" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutesV1(r *gin.RouterGroup) { + r.POST("/callback", GetIncomingWebhook) + r.GET("/events", SSEHeadersMiddleware(), Stream.ServeHTTP()) + + re := r.Group("readers") + re.GET("", FindReaders) + re.GET("/:id", FindReader) + //re.GET("/api", auth.JWTAuthMiddleware.MiddlewareFunc(), FindApiReaders) + re.POST("/link", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), CreateReader) + re.DELETE("/terminate/:id", auth.JWTAuthMiddleware.MiddlewareFunc(), TerminateReaderCheckout) + re.DELETE("/unlink", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), UnlinkReader) + re.DELETE("/:id", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), DeleteReader) +} diff --git a/backend/controllers/payment/zz_router.go b/backend/controllers/api/payment/zz_router.go similarity index 76% rename from backend/controllers/payment/zz_router.go rename to backend/controllers/api/payment/zz_router.go index d79fb36..38b0ea8 100644 --- a/backend/controllers/payment/zz_router.go +++ b/backend/controllers/api/payment/zz_router.go @@ -1,7 +1,7 @@ package payment import ( - v1 "metalab/metadrinks/controllers/payment/v1" + v1 "metalab/metadrinks/controllers/api/payment/v1" "github.com/gin-gonic/gin" ) diff --git a/backend/controllers/api/v1/item.go b/backend/controllers/api/v1/item.go index 616bcf9..88775c5 100644 --- a/backend/controllers/api/v1/item.go +++ b/backend/controllers/api/v1/item.go @@ -1,19 +1,27 @@ package v1 import ( + "encoding/json" + "fmt" + sse "metalab/metadrinks/controllers/api/payment/v1" "net/http" "metalab/metadrinks/models" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/lib/pq" ) type CreateItemInput struct { - Name string `json:"name" binding:"required"` - Image string `json:"image"` - Price uint `json:"price" binding:"required"` - Barcode string `json:"barcode"` + ProductName string `json:"name" binding:"required"` + ProductVariant string `json:"variant"` + Image string `json:"image"` + Volume uint `json:"volume" binding:"required"` + Price uint `json:"price" binding:"required"` + Barcodes pq.StringArray `json:"barcodes"` + NutritionInfo []models.NutritionInfo `json:"nutrition_info"` + IsActive *bool `json:"is_active" default:"true"` } // @BasePath /api/v1 @@ -41,12 +49,29 @@ func CreateItem(c *gin.Context) { return } - item := models.Item{Name: input.Name, Image: input.Image, Price: input.Price, Barcode: input.Barcode} + item := models.Item{ProductName: input.ProductName, ProductVariant: input.ProductVariant, Image: input.Image, Volume: input.Volume, Price: input.Price, Barcodes: input.Barcodes, NutritionInfo: input.NutritionInfo, IsActive: input.IsActive} if err := models.DB.Create(&item).Error; err != nil { c.AbortWithStatus(http.StatusBadRequest /*, gin.H{"error": err.Error()}*/) return } + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "items", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -62,7 +87,7 @@ func CreateItem(c *gin.Context) { // @Router /items [get] func FindItems(c *gin.Context) { var items []models.Item - models.DB.Find(&items) + models.DB.Order("created_at ASC").Where("deleted_at IS NULL").Find(&items) c.Header("Content-Type", "application/json") c.JSON(http.StatusOK, gin.H{"data": items}) @@ -98,17 +123,21 @@ func FindItemById(id uuid.UUID) models.Item { var item models.Item if err := models.DB.Where("item_id = ?", id).First(&item).Error; err != nil { - return models.Item{Name: "No item found", Price: 0} + return models.Item{ProductName: "No item found", Price: 0} } return item } type UpdateItemInput struct { - Name string `json:"name,omitempty"` - Image string `json:"image,omitempty"` - Price uint `json:"price,omitempty"` - Barcode string `json:"barcode,omitempty"` + ProductName string `json:"name,omitempty"` + ProductVariant string `json:"variant,omitempty"` + Image string `json:"image,omitempty"` + Volume uint `json:"volume,omitempty"` + Price uint `json:"price,omitempty"` + Barcodes pq.StringArray `json:"barcodes,omitempty"` + NutritionInfo []models.NutritionInfo `json:"nutrition_info,omitempty"` + IsActive *bool `json:"is_active,omitempty"` } // UpdateItem godoc @@ -142,9 +171,27 @@ func UpdateItem(c *gin.Context) { return } - updatedItem := models.Item{Name: input.Name, Image: input.Image, Price: input.Price, Barcode: input.Barcode} + updatedItem := models.Item{ProductName: input.ProductName, ProductVariant: input.ProductVariant, Image: input.Image, Volume: input.Volume, Price: input.Price, Barcodes: input.Barcodes, NutritionInfo: input.NutritionInfo, IsActive: input.IsActive} models.DB.Model(&item).Updates(&updatedItem) + + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "items", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -173,5 +220,23 @@ func DeleteItem(c *gin.Context) { } models.DB.Delete(&item) + + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "items", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) c.JSON(http.StatusOK, gin.H{"data": "success"}) } diff --git a/backend/controllers/api/v1/purchase.go b/backend/controllers/api/v1/purchase.go index c297dc6..0377f61 100644 --- a/backend/controllers/api/v1/purchase.go +++ b/backend/controllers/api/v1/purchase.go @@ -11,7 +11,8 @@ import ( "metalab/metadrinks/models" sumupmodels "metalab/metadrinks/models/sumup" - jwt "github.com/appleboy/gin-jwt/v2" + jwt "metalab/metadrinks/libs/auth" + "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -57,77 +58,90 @@ func CreatePurchase(c *gin.Context) { clientTransactionId := "" var transactionDescription []string var transactionStatus sumupmodels.TransactionFullStatus - var returnedItemsArray []models.Item + var returnedItemsArray []models.PurchaseItem userClaims := jwt.ExtractClaims(c) userId := uuid.MustParse(userClaims["userId"].(string)) userTrust := userClaims["trusted"].(bool) if err := c.ShouldBindJSON(&input); err != nil { - c.AbortWithError(http.StatusBadRequest, err) + c.AbortWithStatus(http.StatusBadRequest) return } if input.Amount != 0 && len(input.Items) != 0 { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("only one of 'items' and 'amount' can be specified")) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "Only one of 'items' and 'amount' can be specified"}) + return + } + + if input.Amount != 0 && input.PaymentType == models.PaymentTypeBalance { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "Balance payment type cannot be used with amount"}) return } if input.Amount != 0 && userClaims["restricted"].(bool) { - c.AbortWithError(http.StatusForbidden, fmt.Errorf("user is restricted")) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"message": "User is restricted"}) return } for _, v := range input.Items { item := FindItemById(v.ItemId) - finalCost += item.Price - returnedItemsArray = append(returnedItemsArray, models.Item{ItemId: v.ItemId, Name: item.Name, Price: item.Price, Amount: v.Amount}) + if item.IsActive != nil && *item.IsActive == false { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "Attempted to purchase inactive item"}) + return + } + finalCost += item.Price * v.Amount + returnedItemsArray = append(returnedItemsArray, models.PurchaseItem{ItemId: v.ItemId, ProductName: item.ProductName, ProductVariant: item.ProductVariant, Price: item.Price, Amount: v.Amount}) if v.Amount > 1 { - transactionDescription = append(transactionDescription, fmt.Sprintf("%s x%d ", item.Name, v.Amount)) + transactionDescription = append(transactionDescription, fmt.Sprintf("%s x%d ", item.ProductName, v.Amount)) } else { - transactionDescription = append(transactionDescription, fmt.Sprintf("%s ", item.Name)) + transactionDescription = append(transactionDescription, fmt.Sprintf("%s ", item.ProductName)) } } finalTransactionDescription := strings.Join(transactionDescription[:], ", ") switch input.PaymentType { case models.PaymentTypeCard: + if finalCost == 0 && input.Amount != 0 { + finalCost = input.Amount + finalTransactionDescription = fmt.Sprintf("Balance top-up of €%d", input.Amount) + } var err error transactionStatus = sumupmodels.TransactionFullStatusPending clientTransactionId, err = libs.StartReaderCheckout(input.ReaderId, finalCost, &finalTransactionDescription) if err != nil { fmt.Printf("error while creating reader checkout: %s\n", err.Error()) - c.AbortWithError(http.StatusInternalServerError, err) + c.AbortWithStatus(http.StatusInternalServerError) return } case models.PaymentTypeCash: + if input.Amount != 0 { + libs.UpdateUserBalance(userId, int(input.Amount)) + } transactionStatus = sumupmodels.TransactionFullStatusSuccessful case models.PaymentTypeBalance: - if balance, err := GetUserBalance(userId); err == nil { + if balance, err := libs.GetUserBalance(userId); err == nil { if finalCost >= math.MaxInt32 { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("final cost exceeds maximum allowed value")) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "Final cost exceeds maximum allowed value"}) return } if (*balance-int(finalCost) < 0) && !userTrust { - c.AbortWithError(http.StatusForbidden, fmt.Errorf("not enough balance")) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"message": "Not enough balance"}) return } else { transactionStatus = sumupmodels.TransactionFullStatusSuccessful - UpdateUserBalance(userId, -int(finalCost)) + libs.UpdateUserBalance(userId, -int(finalCost)) } } else if err.Error() == "user is restricted" { - c.AbortWithError(http.StatusForbidden, err) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"message": "User is restricted"}) return } else { - c.AbortWithError(http.StatusInternalServerError, err) + c.AbortWithStatus(http.StatusInternalServerError) return } } purchase := models.Purchase{Items: returnedItemsArray, PaymentType: input.PaymentType, ClientTransactionId: clientTransactionId, TransactionStatus: transactionStatus, FinalCost: finalCost, RefundAmount: input.Amount, CreatedBy: userId} models.DB.Create(&purchase) - if input.Amount != 0 { - UpdateUserBalance(userId, int(input.Amount)) - } c.JSON(http.StatusOK, gin.H{"data": purchase}) } @@ -150,6 +164,7 @@ func FindPurchases(c *gin.Context) { var purchases []models.Purchase userClaims := jwt.ExtractClaims(c) userId := uuid.MustParse(userClaims["userId"].(string)) + isAdmin := userClaims["admin"].(bool) limit := c.DefaultQuery("limit", "-1") limitInt, err := strconv.Atoi(limit) @@ -157,8 +172,11 @@ func FindPurchases(c *gin.Context) { c.AbortWithError(http.StatusBadRequest, err) return } - - models.DB.Where("created_by = ?", userId).Find(&purchases).Limit(limitInt) + if !isAdmin { + models.DB.Where("created_by = ?", userId).Order("created_at DESC").Find(&purchases).Limit(limitInt) + } else { + models.DB.Order("created_at DESC").Find(&purchases).Limit(limitInt) + } c.Header("Content-Type", "application/json") c.JSON(http.StatusOK, gin.H{"data": purchases}) @@ -185,10 +203,18 @@ func FindPurchase(c *gin.Context) { var purchase models.Purchase userClaims := jwt.ExtractClaims(c) userId := uuid.MustParse(userClaims["userId"].(string)) + isAdmin := userClaims["admin"].(bool) - if err := models.DB.Where("created_by = ?", userId).Where("purchase_id = ?", c.Param("id")).First(&purchase).Error; err != nil { - c.AbortWithStatus(http.StatusNotFound) - return + if !isAdmin { + if err := models.DB.Where("created_by = ?", userId).Where("purchase_id = ?", c.Param("id")).First(&purchase).Error; err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + } else { + if err := models.DB.Where("purchase_id = ?", c.Param("id")).First(&purchase).Error; err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } } c.Header("Content-Type", "application/json") @@ -218,11 +244,11 @@ func UpdatePurchase(c *gin.Context) { for _, v := range input.Items { item := FindItemById(v.ItemId) - if item.Name == "No item found" { + if item.ProductName == "No item found" { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "itemid " + strconv.FormatUint(uint64(v.ItemId), 10) + " not found"}) } finalCost += (item.Price * v.Quantity) - returnArray = append(returnArray, models.Item{ItemId: v.ItemId, Name: item.Name, Quantity: v.Quantity, Price: item.Price}) + returnArray = append(returnArray, models.Item{ItemId: v.ItemId, ProductName: item.ProductName, Quantity: v.Quantity, Price: item.Price}) } finalCost += input.Tip diff --git a/backend/controllers/api/v1/user.go b/backend/controllers/api/v1/user.go index bff8c78..b1fdb5d 100644 --- a/backend/controllers/api/v1/user.go +++ b/backend/controllers/api/v1/user.go @@ -1,7 +1,11 @@ package v1 import ( + "encoding/json" "fmt" + sse "metalab/metadrinks/controllers/api/payment/v1" + "metalab/metadrinks/libs" + jwt "metalab/metadrinks/libs/auth" "metalab/metadrinks/libs/crypto" "net/http" "time" @@ -38,6 +42,11 @@ func CreateUser(c *gin.Context) { return } + if len(input.Name) > 24 { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "name must not be longer than 24 characters"}) + return + } + userId := uuid.New() hashedPassword, err := crypto.HashPasswordSecure(input.Password) //bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) @@ -45,16 +54,37 @@ func CreateUser(c *gin.Context) { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + user := models.User{UserID: userId, Name: input.Name, Password: hashedPassword, UsedAt: time.Now().Local()} models.DB.Create(&user) + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "users", + }, + }, + } + + user.Password = "" + user.LoginBarcode = "" + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + sse.Stream.SendMessage(string(notificationJSON)) c.JSON(http.StatusOK, gin.H{"data": user}) } // FindUsers godoc // // @Summary Find users -// @Description Lists all users +// @Description Lists all users except admins // @Tags users // @Accept json // @Produce json @@ -65,10 +95,12 @@ func CreateUser(c *gin.Context) { // @Router /users [get] func FindUsers(c *gin.Context) { var users []models.User - models.DB.Order("used_at DESC").Find(&users) + + models.DB.Where("is_admin = false").Order("used_at DESC").Find(&users) for i := range users { // do not return the user password users[i].Password = "" + users[i].LoginBarcode = "" } c.Header("Content-Type", "application/json") @@ -97,34 +129,104 @@ func FindUser(c *gin.Context) { } user.Password = "" + user.LoginBarcode = "" c.Header("Content-Type", "application/json") c.JSON(http.StatusOK, gin.H{"data": user}) } type UpdateUserInput struct { - Name string `json:"name" binding:"required"` + OldPassword string `json:"old_password,omitempty"` + Password string `json:"password,omitempty"` + GenerateLoginBarcode *bool `json:"generate_login_barcode,omitempty"` } -/*func UpdateUser(c *gin.Context) { - var user models.Item - if err := models.DB.Where("user_id = ?", c.Param("id")).First(&user).Error; err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"}) +func UpdateUser(c *gin.Context) { + var input UpdateUserInput + var loginBarcode = "" + userClaims := jwt.ExtractClaims(c) + userId := uuid.MustParse(userClaims["userId"].(string)) + userRestricted := userClaims["restricted"].(bool) + if err := c.ShouldBindJSON(&input); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var input UpdateUserInput - if err := c.ShouldBindJSON(&input); err != nil { + if userRestricted { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "user is restricted"}) + return + } + + if uId, err := uuid.Parse(c.Param("id")); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return + } else if userId != uId { + c.AbortWithStatus(http.StatusForbidden) + return } - updatedUser := models.User{Name: input.Name} + var user models.User + if err := models.DB.Where("user_id = ?", c.Param("id")).First(&user).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"}) + return + } + + if input.Password != "" { + if input.OldPassword == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "old_password cannot be empty"}) + return + } + + if err := crypto.AuthenticateUser(user.Password, input.OldPassword); err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "old_password does not match"}) + return + } + + hashedPassword, err := crypto.HashPasswordSecure(input.Password) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + input.Password = hashedPassword + } + + if input.GenerateLoginBarcode != nil && *input.GenerateLoginBarcode == true { + generatedBarcode, err := libs.GenerateSecureEAN13() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to generate barcode: %s", err.Error())}) + return + } + loginBarcode = generatedBarcode + } + + updatedUser := models.User{Password: input.Password, LoginBarcode: loginBarcode} models.DB.Model(&user).Updates(&updatedUser) + + notification := sse.SSENotification{ + NotificationType: sse.SSENotificationType(sse.SSENotificationContentUpdate), + NotificationData: sse.SSENotificationPayload{ + ContentPayload: &sse.SSENotificationContentUpdatePayload{ + Type: "users", + }, + }, + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + fmt.Printf("error marshalling notification: %s\n", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to process notification"}) + return + } + + user.Password = "" + if loginBarcode == "" { + user.LoginBarcode = "" + } + sse.Stream.SendMessage(string(notificationJSON)) c.JSON(http.StatusOK, gin.H{"data": user}) } -func DeleteUser(c *gin.Context) { +/*func DeleteUser(c *gin.Context) { var user models.User if err := models.DB.Where("user_id = ?", c.Param("id")).First(&user).Error; err != nil { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"}) @@ -167,28 +269,3 @@ func DeleteUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": user}) } */ - -func GetUserBalance(userId uuid.UUID) (*int, error) { - var user models.User - - if err := models.DB.Where("user_id = ?", userId).First(&user).Error; err != nil { - return nil, err - } - - if user.IsRestricted { - return nil, fmt.Errorf("user is restricted") - } - - return &user.Balance, nil -} - -func UpdateUserBalance(userId uuid.UUID, change int) { - var user models.User - - if err := models.DB.Where("user_id = ?", userId).First(&user).Error; err != nil { - return - } - - user.Balance = user.Balance + change - models.DB.Save(&user) -} diff --git a/backend/controllers/api/v1/zz_router.go b/backend/controllers/api/v1/zz_router.go index 852cdb4..e67f316 100644 --- a/backend/controllers/api/v1/zz_router.go +++ b/backend/controllers/api/v1/zz_router.go @@ -1,6 +1,7 @@ package v1 import ( + adminv1 "metalab/metadrinks/controllers/api/admin/v1" "metalab/metadrinks/controllers/auth" "github.com/gin-gonic/gin" @@ -18,7 +19,7 @@ func RegisterRoutesV1(r *gin.RouterGroup) { u.POST("", CreateUser) u.GET("", FindUsers) u.GET("/:id", FindUser) - //u.PUT("/:id", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), UpdateUser) + u.PUT("/:id", auth.JWTAuthMiddleware.MiddlewareFunc(), UpdateUser) //u.DELETE("//:id", auth.JWTAuthMiddleware.MiddlewareFunc(), auth.IsUserAdmin(), DeleteUser) p := r.Group("purchases") @@ -27,4 +28,7 @@ func RegisterRoutesV1(r *gin.RouterGroup) { p.GET("/:id", auth.JWTAuthMiddleware.MiddlewareFunc(), FindPurchase) //p.PATCH("/:id", UpdatePurchase) //p.DELETE("/:id", DeletePurchase) + + s := r.Group("settings") + s.GET("", adminv1.FindSettings) } diff --git a/backend/controllers/api/zz_router.go b/backend/controllers/api/zz_router.go index 3e180cf..92d9395 100644 --- a/backend/controllers/api/zz_router.go +++ b/backend/controllers/api/zz_router.go @@ -1,6 +1,8 @@ package api import ( + "metalab/metadrinks/controllers/api/admin" + "metalab/metadrinks/controllers/api/payment" "metalab/metadrinks/controllers/api/v1" "github.com/gin-gonic/gin" @@ -8,5 +10,9 @@ import ( func RegisterRoutesAPI(r *gin.RouterGroup) { groupV1 := r.Group("/v1") + groupAdmin := r.Group("/admin") + groupPayment := r.Group("/payment") v1.RegisterRoutesV1(groupV1) + admin.RegisterRoutesAdmin(groupAdmin) + payment.RegisterRoutesPayment(groupPayment) } diff --git a/backend/controllers/auth/auth.go b/backend/controllers/auth/auth.go index 60a2bed..d40d840 100644 --- a/backend/controllers/auth/auth.go +++ b/backend/controllers/auth/auth.go @@ -2,24 +2,25 @@ package auth import ( "log" + "metalab/metadrinks/libs/auth" "metalab/metadrinks/libs/crypto" "metalab/metadrinks/models" "net/http" "os" "time" - jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" ) -var JWTAuthMiddleware *jwt.GinJWTMiddleware +var JWTAuthMiddleware *auth.GinJWTMiddleware type LoginForm struct { - Username string `form:"username" json:"username" binding:"required"` + Username string `form:"username" json:"username"` Password string `form:"password" json:"password"` + Barcode string `form:"barcode" json:"barcode"` } -func HandlerMiddleware(authMiddleware *jwt.GinJWTMiddleware) gin.HandlerFunc { +func HandlerMiddleware(authMiddleware *auth.GinJWTMiddleware) gin.HandlerFunc { return func(context *gin.Context) { errInit := authMiddleware.MiddlewareInit() if errInit != nil { @@ -28,19 +29,20 @@ func HandlerMiddleware(authMiddleware *jwt.GinJWTMiddleware) gin.HandlerFunc { } } -func InitParams() *jwt.GinJWTMiddleware { - return &jwt.GinJWTMiddleware{ +func InitParams() *auth.GinJWTMiddleware { + return &auth.GinJWTMiddleware{ Realm: "drinks-pos", Key: []byte(os.Getenv("JWT_SECRET")), SigningAlgorithm: "HS512", Timeout: time.Minute * 5, MaxRefresh: time.Minute * 5, - // IdentityKey: identityKey, + // IdentityKey: identityKey, PayloadFunc: payloadFunc(), IdentityHandler: identityHandler(), Authenticator: authenticator(), Unauthorized: unauthorized(), + LoginResponse: loginResponse(), SendCookie: true, CookieName: "drinks_pos_session", CookieSameSite: http.SameSiteStrictMode, @@ -50,10 +52,10 @@ func InitParams() *jwt.GinJWTMiddleware { } } -func payloadFunc() func(data any) jwt.MapClaims { - return func(data any) jwt.MapClaims { +func payloadFunc() func(data any) auth.MapClaims { + return func(data any) auth.MapClaims { if v, ok := data.(*models.User); ok { - return jwt.MapClaims{ + return auth.MapClaims{ "userId": v.UserID.String(), "sub": v.Name, "restricted": v.IsRestricted, @@ -61,13 +63,13 @@ func payloadFunc() func(data any) jwt.MapClaims { "admin": v.IsAdmin, } } - return jwt.MapClaims{} + return auth.MapClaims{} } } func identityHandler() func(c *gin.Context) any { return func(c *gin.Context) any { - claims := jwt.ExtractClaims(c) + claims := auth.ExtractClaims(c) return &models.User{ Name: claims["sub"].(string), } @@ -78,17 +80,28 @@ func authenticator() func(c *gin.Context) (any, error) { return func(c *gin.Context) (any, error) { var loginVals LoginForm if err := c.ShouldBind(&loginVals); err != nil { - return "", jwt.ErrMissingLoginValues + return "", auth.ErrMissingLoginValues } username := loginVals.Username password := loginVals.Password + barcode := loginVals.Barcode - user, err := TryAuthenticate(username, password) - if err != nil { - log.Printf("Failed authentication for user %s: %v\n", username, err) - return nil, jwt.ErrFailedAuthentication + if username != "" { + user, err := TryAuthenticate(username, password) + if err != nil { + log.Printf("Failed authentication for user %s: %v\n", username, err) + return nil, auth.ErrFailedAuthentication + } + return user, nil + } else if barcode != "" { + user, err := TryAuthenticateByBarcode(barcode) + if err != nil { + log.Printf("Failed authentication for barcode %s: %v\n", username, err) + return nil, auth.ErrFailedAuthentication + } + return user, nil } - return user, nil + return nil, auth.ErrFailedAuthentication } } @@ -101,6 +114,40 @@ func unauthorized() func(c *gin.Context, code int, message string) { } } +func loginResponse() func(c *gin.Context, code int, token string, expire time.Time) { + return func(c *gin.Context, code int, token string, expire time.Time) { + // Extract user data from context + userData, exists := c.Get("user") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "message": "Failed to get user data", + }) + return + } + + user, ok := userData.(*models.User) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "message": "Invalid user data", + }) + return + } + + // Clear sensitive fields before returning + user.Password = "" + user.LoginBarcode = "" + + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "token": token, + "expire": expire.UTC().Format(http.TimeFormat), + "user": user, + }) + } +} + func TryAuthenticate(username, password string) (*models.User, error) { var user models.User @@ -117,9 +164,19 @@ func TryAuthenticate(username, password string) (*models.User, error) { return &user, nil } +func TryAuthenticateByBarcode(barcode string) (*models.User, error) { + var user models.User + if err := models.DB.Where("login_barcode = ?", barcode).First(&user).Error; err != nil { + return nil, err + } + user.UsedAt = time.Now().Local() + models.DB.Save(&user) + return &user, nil +} + func IsUserAdmin() gin.HandlerFunc { return func(c *gin.Context) { - if !jwt.ExtractClaims(c)["admin"].(bool) { + if !auth.ExtractClaims(c)["admin"].(bool) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } diff --git a/backend/controllers/payment/v1/zz_router.go b/backend/controllers/payment/v1/zz_router.go deleted file mode 100644 index 97c93f6..0000000 --- a/backend/controllers/payment/v1/zz_router.go +++ /dev/null @@ -1,20 +0,0 @@ -package v1 - -import ( - "metalab/metadrinks/controllers/auth" - - "github.com/gin-gonic/gin" -) - -func RegisterRoutesV1(r *gin.RouterGroup) { - r.POST("/callback", GetIncomingWebhook) - r.GET("/events", SSEHeadersMiddleware(), Stream.ServeHTTP()) - - re := r.Group("readers") - re.GET("/", FindReaders) - re.GET("/:id", FindReader) - re.GET("/api", auth.JWTAuthMiddleware.MiddlewareFunc(), FindApiReaders) - re.POST("/link", auth.JWTAuthMiddleware.MiddlewareFunc(), CreateReader) - re.DELETE("/terminate", TerminateReaderCheckout) - re.DELETE("/unlink", auth.JWTAuthMiddleware.MiddlewareFunc(), UnlinkReader) -} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml deleted file mode 100644 index db83def..0000000 --- a/backend/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - database: - image: "postgres:latest" - ports: - - 5432:5432 - - env_file: - - .env - environment: - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_DATABASE} - volumes: - - db-data:/var/lib/postgresql/data/ - -volumes: - db-data: diff --git a/backend/go.mod b/backend/go.mod index 2a0fb24..a024e4f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,73 +3,79 @@ module metalab/metadrinks go 1.24.4 require ( - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 gorm.io/driver/postgres v1.6.0 - gorm.io/gorm v1.30.1 + gorm.io/gorm v1.31.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.2.1 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect - github.com/go-openapi/jsonpointer v0.21.2 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/sv-tools/openapi v0.2.1 // indirect - github.com/swaggo/files v1.0.1 // indirect - github.com/swaggo/gin-swagger v1.6.0 // indirect - github.com/swaggo/swag v1.16.6 // indirect - github.com/swaggo/swag/v2 v2.0.0-rc4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/tools v0.37.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/lib/pq v1.10.9 + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + github.com/stretchr/testify v1.11.1 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 + go.uber.org/mock v0.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( - github.com/appleboy/gin-jwt/v2 v2.10.3 - github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic v1.14.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sumup/sumup-go v0.1.0 + github.com/sumup/sumup-go v0.2.0 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - golang.org/x/arch v0.20.0 // indirect + golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.43.0 - golang.org/x/net v0.45.0 // indirect + golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 68d1300..b111a2f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,59 +1,61 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= -github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc= -github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8= -github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= -github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= -github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= -github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= -github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= -github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -65,10 +67,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -77,25 +77,18 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -105,10 +98,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -119,28 +114,16 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/sumup/sumup-go v0.0.1 h1:w6/JtBDUt6lJqu/s5AkZUYag3RprqeSiCoSm4SGFTns= -github.com/sumup/sumup-go v0.0.1/go.mod h1:+zmFNOpPtaHBXWfeVgxdR72FxE1r+fH5gw61oOFnb9w= -github.com/sumup/sumup-go v0.1.0 h1:NAblyn720ed8pTDLHB9f9U7BIGGBEGODGikx3mfs7sI= -github.com/sumup/sumup-go v0.1.0/go.mod h1:+zmFNOpPtaHBXWfeVgxdR72FxE1r+fH5gw61oOFnb9w= -github.com/sv-tools/openapi v0.2.1 h1:ES1tMQMJFGibWndMagvdoo34T1Vllxr1Nlm5wz6b1aA= -github.com/sv-tools/openapi v0.2.1/go.mod h1:k5VuZamTw1HuiS9p2Wl5YIDWzYnHG6/FgPOSFXLAhGg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/sumup/sumup-go v0.2.0 h1:CfIPQGd/bs6YD2HzwmT/wmXPFffPRwihHmKhsWUExus= +github.com/sumup/sumup-go v0.2.0/go.mod h1:UgT9aaxvUBrAiRpunqyb1zi5v/+1z+6Lr6p6bgL8EYI= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= -github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= -github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/swaggo/swag/v2 v2.0.0-rc4 h1:SZ8cK68gcV6cslwrJMIOqPkJELRwq4gmjvk77MrvHvY= -github.com/swaggo/swag/v2 v2.0.0-rc4/go.mod h1:Ow7Y8gF16BTCDn8YxZbyKn8FkMLRUHekv1kROJZpbvE= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -148,45 +131,29 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= -golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= -golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -196,10 +163,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -209,36 +172,23 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= -gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/libs/auth/auth_jwt.go b/backend/libs/auth/auth_jwt.go new file mode 100644 index 0000000..cff53ca --- /dev/null +++ b/backend/libs/auth/auth_jwt.go @@ -0,0 +1,857 @@ +package auth + +import ( + "crypto/rsa" + "encoding/json" + "errors" + "maps" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/youmark/pkcs8" +) + +type MapClaims jwt.MapClaims + +// GinJWTMiddleware provides a Json-Web-Token authentication implementation. On failure, a 401 HTTP response +// is returned. On success, the wrapped middleware is called, and the userID is made available as +// c.Get("userID").(string). +// Users can get a token by posting a json request to LoginHandler. The token then needs to be passed in +// the Authentication header. Example: Authorization:Bearer XXX_TOKEN_XXX +type GinJWTMiddleware struct { + // Realm name to display to the user. Required. + Realm string + + // signing algorithm - possible values are HS256, HS384, HS512, RS256, RS384 or RS512 + // Optional, default is HS256. + SigningAlgorithm string + + // Secret key used for signing. Required. + Key []byte + + // Callback to retrieve key used for signing. Setting KeyFunc will bypass + // all other key settings + KeyFunc func(token *jwt.Token) (any, error) + + // Duration that a jwt token is valid. Optional, defaults to one hour. + Timeout time.Duration + // Callback function that will override the default timeout duration. + TimeoutFunc func(data any) time.Duration + + // This field allows clients to refresh their token until MaxRefresh has passed. + // Note that clients can refresh their token in the last moment of MaxRefresh. + // This means that the maximum validity timespan for a token is TokenTime + MaxRefresh. + // Optional, defaults to 0 meaning not refreshable. + MaxRefresh time.Duration + + // Callback function that should perform the authentication of the user based on login info. + // Must return user data as user identifier, it will be stored in Claim Array. Required. + // Check error (e) to determine the appropriate error message. + Authenticator func(c *gin.Context) (any, error) + + // Callback function that should perform the authorization of the authenticated user. Called + // only after an authentication success. Must return true on success, false on failure. + // Optional, default to success. + Authorizer func(data any, c *gin.Context) bool + + // Callback function that will be called during login. + // Using this function it is possible to add additional payload data to the webtoken. + // The data is then made available during requests via c.Get("JWT_PAYLOAD"). + // Note that the payload is not encrypted. + // The attributes mentioned on jwt.io can't be used as keys for the map. + // Optional, by default no additional data will be set. + PayloadFunc func(data any) MapClaims + + // User can define own Unauthorized func. + Unauthorized func(c *gin.Context, code int, message string) + + // User can define own LoginResponse func. + LoginResponse func(c *gin.Context, code int, message string, time time.Time) + + // User can define own LogoutResponse func. + LogoutResponse func(c *gin.Context, code int) + + // User can define own RefreshResponse func. + RefreshResponse func(c *gin.Context, code int, message string, time time.Time) + + // Set the identity handler function + IdentityHandler func(*gin.Context) any + + // Set the identity key + IdentityKey string + + // TokenLookup is a string in the form of ":" that is used + // to extract token from the request. + // Optional. Default value "header:Authorization". + // Possible values: + // - "header:" + // - "query:" + // - "cookie:" + TokenLookup string + + // TokenHeadName is a string in the header. Default value is "Bearer" + TokenHeadName string + + // TimeFunc provides the current time. You can override it to use another time value. This is useful for testing or if your server uses a different time zone than your tokens. + TimeFunc func() time.Time + + // HTTP Status messages for when something in the JWT middleware fails. + // Check error (e) to determine the appropriate error message. + HTTPStatusMessageFunc func(e error, c *gin.Context) string + + // Private key file for asymmetric algorithms + PrivKeyFile string + + // Private Key bytes for asymmetric algorithms + // + // Note: PrivKeyFile takes precedence over PrivKeyBytes if both are set + PrivKeyBytes []byte + + // Public key file for asymmetric algorithms + PubKeyFile string + + // Private key passphrase + PrivateKeyPassphrase string + + // Public key bytes for asymmetric algorithms. + // + // Note: PubKeyFile takes precedence over PubKeyBytes if both are set + PubKeyBytes []byte + + // Private key + privKey *rsa.PrivateKey + + // Public key + pubKey *rsa.PublicKey + + // Optionally return the token as a cookie + SendCookie bool + + // Duration that a cookie is valid. Optional, by default equals to Timeout value. + CookieMaxAge time.Duration + + // Allow insecure cookies for development over http + SecureCookie bool + + // Allow cookies to be accessed client side for development + CookieHTTPOnly bool + + // Allow cookie domain change for development + CookieDomain string + + // SendAuthorization allow return authorization header for every request + SendAuthorization bool + + // Disable abort() of context. + DisabledAbort bool + + // CookieName allow cookie name change for development + CookieName string + + // CookieSameSite allow use http.SameSite cookie param + CookieSameSite http.SameSite + + // ParseOptions allow to modify jwt's parser methods + ParseOptions []jwt.ParserOption + + // Default vaule is "exp" + ExpField string +} + +var ( + // ErrMissingSecretKey indicates Secret key is required + ErrMissingSecretKey = errors.New("Secret key is required") + + // ErrForbidden when HTTP status 403 is given + ErrForbidden = errors.New("You don't have permission to access this resource") + + // ErrMissingAuthenticatorFunc indicates Authenticator is required + ErrMissingAuthenticatorFunc = errors.New("ginJWTMiddleware.Authenticator func is undefined") + + // ErrMissingLoginValues indicates a user tried to authenticate without username or password + ErrMissingLoginValues = errors.New("Missing username or password") + + // ErrFailedAuthentication indicates authentication failed, could be faulty username or password + ErrFailedAuthentication = errors.New("Incorrect username or password") + + // ErrFailedTokenCreation indicates JWT Token failed to create, reason unknown + ErrFailedTokenCreation = errors.New("Failed to create JWT token") + + // ErrExpiredToken indicates JWT token has expired. Can't refresh. + ErrExpiredToken = errors.New("Token is expired") // in practice, this is generated from the jwt library not by us + + // ErrEmptyAuthHeader can be thrown if authing with a HTTP header, the Auth header needs to be set + ErrEmptyAuthHeader = errors.New("Auth header is empty") + + // ErrMissingExpField missing exp field in token + ErrMissingExpField = errors.New("Missing exp field") + + // ErrWrongFormatOfExp field must be float64 format + ErrWrongFormatOfExp = errors.New("Exp must be float64 format") + + // ErrInvalidAuthHeader indicates auth header is invalid, could for example have the wrong Realm name + ErrInvalidAuthHeader = errors.New("Auth header is invalid") + + // ErrEmptyQueryToken can be thrown if authing with URL Query, the query token variable is empty + ErrEmptyQueryToken = errors.New("Query token is empty") + + // ErrEmptyCookieToken can be thrown if authing with a cookie, the token cookie is empty + ErrEmptyCookieToken = errors.New("Cookie token is empty") + + // ErrEmptyParamToken can be thrown if authing with parameter in path, the parameter in path is empty + ErrEmptyParamToken = errors.New("Parameter token is empty") + + // ErrInvalidSigningAlgorithm indicates signing algorithm is invalid, needs to be HS256, HS384, HS512, RS256, RS384 or RS512 + ErrInvalidSigningAlgorithm = errors.New("Invalid signing algorithm") + + // ErrNoPrivKeyFile indicates that the given private key is unreadable + ErrNoPrivKeyFile = errors.New("Private key file unreadable") + + // ErrNoPubKeyFile indicates that the given public key is unreadable + ErrNoPubKeyFile = errors.New("Public key file unreadable") + + // ErrInvalidPrivKey indicates that the given private key is invalid + ErrInvalidPrivKey = errors.New("Private key invalid") + + // ErrInvalidPubKey indicates that the given public key is invalid + ErrInvalidPubKey = errors.New("Public key invalid") + + // IdentityKey default identity key + IdentityKey = "identity" +) + +// New for check error with GinJWTMiddleware +func New(m *GinJWTMiddleware) (*GinJWTMiddleware, error) { + if err := m.MiddlewareInit(); err != nil { + return nil, err + } + + return m, nil +} + +func (mw *GinJWTMiddleware) readKeys() error { + err := mw.privateKey() + if err != nil { + return err + } + + err = mw.publicKey() + if err != nil { + return err + } + return nil +} + +func (mw *GinJWTMiddleware) privateKey() error { + var keyData []byte + var err error + if mw.PrivKeyFile == "" { + keyData = mw.PrivKeyBytes + } else { + var filecontent []byte + filecontent, err = os.ReadFile(mw.PrivKeyFile) + if err != nil { + return ErrNoPrivKeyFile + } + keyData = filecontent + } + + if mw.PrivateKeyPassphrase != "" { + var key any + key, err = pkcs8.ParsePKCS8PrivateKey(keyData, []byte(mw.PrivateKeyPassphrase)) + if err != nil { + return ErrInvalidPrivKey + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return ErrInvalidPrivKey + } + mw.privKey = rsaKey + return nil + } + + var key *rsa.PrivateKey + key, err = jwt.ParseRSAPrivateKeyFromPEM(keyData) + if err != nil { + return ErrInvalidPrivKey + } + mw.privKey = key + return nil +} + +func (mw *GinJWTMiddleware) publicKey() error { + var keyData []byte + if mw.PubKeyFile == "" { + keyData = mw.PubKeyBytes + } else { + filecontent, err := os.ReadFile(mw.PubKeyFile) + if err != nil { + return ErrNoPubKeyFile + } + keyData = filecontent + } + + key, err := jwt.ParseRSAPublicKeyFromPEM(keyData) + if err != nil { + return ErrInvalidPubKey + } + mw.pubKey = key + return nil +} + +func (mw *GinJWTMiddleware) usingPublicKeyAlgo() bool { + switch mw.SigningAlgorithm { + case "RS256", "RS512", "RS384": + return true + } + return false +} + +// MiddlewareInit initialize jwt configs. +func (mw *GinJWTMiddleware) MiddlewareInit() error { + if mw.TokenLookup == "" { + mw.TokenLookup = "header:Authorization" + } + + if mw.SigningAlgorithm == "" { + mw.SigningAlgorithm = "HS256" + } + + if mw.Timeout == 0 { + mw.Timeout = time.Hour + } + + if mw.TimeoutFunc == nil { + mw.TimeoutFunc = func(data any) time.Duration { + return mw.Timeout + } + } + + if mw.TimeFunc == nil { + mw.TimeFunc = time.Now + } + + mw.TokenHeadName = strings.TrimSpace(mw.TokenHeadName) + if len(mw.TokenHeadName) == 0 { + mw.TokenHeadName = "Bearer" + } + + if mw.Authorizer == nil { + mw.Authorizer = func(data any, c *gin.Context) bool { + return true + } + } + + if mw.Unauthorized == nil { + mw.Unauthorized = func(c *gin.Context, code int, message string) { + c.JSON(code, gin.H{ + "code": code, + "message": message, + }) + } + } + + if mw.LoginResponse == nil { + mw.LoginResponse = func(c *gin.Context, code int, token string, expire time.Time) { + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "token": token, + "expire": expire.UTC().Format(http.TimeFormat), + "max-age": int(mw.CookieMaxAge.Seconds()), + }) + } + } + + if mw.LogoutResponse == nil { + mw.LogoutResponse = func(c *gin.Context, code int) { + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + }) + } + } + + if mw.RefreshResponse == nil { + mw.RefreshResponse = func(c *gin.Context, code int, token string, expire time.Time) { + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "token": token, + "expire": expire.UTC().Format(http.TimeFormat), + "max-age": int(mw.CookieMaxAge.Seconds()), + }) + } + } + + if mw.IdentityKey == "" { + mw.IdentityKey = IdentityKey + } + + if mw.IdentityHandler == nil { + mw.IdentityHandler = func(c *gin.Context) any { + claims := ExtractClaims(c) + return claims[mw.IdentityKey] + } + } + + if mw.HTTPStatusMessageFunc == nil { + mw.HTTPStatusMessageFunc = func(e error, c *gin.Context) string { + return e.Error() + } + } + + if mw.Realm == "" { + mw.Realm = "gin jwt" + } + + if mw.CookieMaxAge == 0 { + mw.CookieMaxAge = mw.Timeout + } + + if mw.CookieName == "" { + mw.CookieName = "jwt" + } + + if mw.ExpField == "" { + mw.ExpField = "exp" + } + + // bypass other key settings if KeyFunc is set + if mw.KeyFunc != nil { + return nil + } + + if mw.usingPublicKeyAlgo() { + return mw.readKeys() + } + + if mw.Key == nil { + return ErrMissingSecretKey + } + + return nil +} + +// MiddlewareFunc makes GinJWTMiddleware implement the Middleware interface. +func (mw *GinJWTMiddleware) MiddlewareFunc() gin.HandlerFunc { + return func(c *gin.Context) { + mw.middlewareImpl(c) + } +} + +func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) { + claims, err := mw.GetClaimsFromJWT(c) + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) + return + } + + switch v := claims[mw.ExpField].(type) { + case nil: + mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c)) + return + case float64: + if int64(v) < mw.TimeFunc().Unix() { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c)) + return + } + case json.Number: + n, err := v.Int64() + if err != nil { + mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c)) + return + } + if n < mw.TimeFunc().Unix() { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c)) + return + } + default: + mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c)) + return + } + + c.Set("JWT_PAYLOAD", claims) + identity := mw.IdentityHandler(c) + + if identity != nil { + c.Set(mw.IdentityKey, identity) + } + + if !mw.Authorizer(identity, c) { + mw.unauthorized(c, http.StatusForbidden, mw.HTTPStatusMessageFunc(ErrForbidden, c)) + return + } + + c.Next() +} + +// GetClaimsFromJWT get claims from JWT token +func (mw *GinJWTMiddleware) GetClaimsFromJWT(c *gin.Context) (MapClaims, error) { + token, err := mw.ParseToken(c) + if err != nil { + return nil, err + } + + if mw.SendAuthorization { + if v, ok := c.Get("JWT_TOKEN"); ok { + c.Header("Authorization", mw.TokenHeadName+" "+v.(string)) + } + } + + claims := MapClaims{} + for key, value := range token.Claims.(jwt.MapClaims) { + claims[key] = value + } + + return claims, nil +} + +// LoginHandler can be used by clients to get a jwt token. +// Payload needs to be json in the form of {"username": "USERNAME", "password": "PASSWORD"}. +// Reply will be of the form {"token": "TOKEN"}. +func (mw *GinJWTMiddleware) LoginHandler(c *gin.Context) { + if mw.Authenticator == nil { + mw.unauthorized(c, http.StatusInternalServerError, mw.HTTPStatusMessageFunc(ErrMissingAuthenticatorFunc, c)) + return + } + + data, err := mw.Authenticator(c) + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) + return + } + + // Create the token + token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm)) + claims := token.Claims.(jwt.MapClaims) + + if mw.PayloadFunc != nil { + maps.Copy(claims, mw.PayloadFunc(data)) + } + + expire := mw.TimeFunc().Add(mw.TimeoutFunc(claims)) + claims[mw.ExpField] = expire.Unix() + claims["orig_iat"] = mw.TimeFunc().Unix() + tokenString, err := mw.signedString(token) + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrFailedTokenCreation, c)) + return + } + + mw.SetCookie(c, tokenString) + + // Store user data in context for LoginResponse to access + c.Set("user", data) + + mw.LoginResponse(c, http.StatusOK, tokenString, expire) +} + +// LogoutHandler can be used by clients to remove the jwt cookie (if set) +func (mw *GinJWTMiddleware) LogoutHandler(c *gin.Context) { + // delete auth cookie + if mw.SendCookie { + if mw.CookieSameSite != 0 { + c.SetSameSite(mw.CookieSameSite) + } + + c.SetCookie( + mw.CookieName, + "", + -1, + "/", + mw.CookieDomain, + mw.SecureCookie, + mw.CookieHTTPOnly, + ) + } + + mw.LogoutResponse(c, http.StatusOK) +} + +func (mw *GinJWTMiddleware) signedString(token *jwt.Token) (string, error) { + var tokenString string + var err error + if mw.usingPublicKeyAlgo() { + tokenString, err = token.SignedString(mw.privKey) + } else { + tokenString, err = token.SignedString(mw.Key) + } + return tokenString, err +} + +// RefreshHandler can be used to refresh a token. The token still needs to be valid on refresh. +// Shall be put under an endpoint that is using the GinJWTMiddleware. +// Reply will be of the form {"token": "TOKEN"}. +func (mw *GinJWTMiddleware) RefreshHandler(c *gin.Context) { + tokenString, expire, err := mw.RefreshToken(c) + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) + return + } + + mw.RefreshResponse(c, http.StatusOK, tokenString, expire) +} + +// RefreshToken refresh token and check if token is expired +func (mw *GinJWTMiddleware) RefreshToken(c *gin.Context) (string, time.Time, error) { + claims, err := mw.CheckIfTokenExpire(c) + if err != nil { + return "", time.Now(), err + } + + // Create the token + newToken := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm)) + newClaims := newToken.Claims.(jwt.MapClaims) + + for key := range claims { + newClaims[key] = claims[key] + } + + expire := mw.TimeFunc().Add(mw.TimeoutFunc(claims)) + newClaims[mw.ExpField] = expire.Unix() + newClaims["orig_iat"] = mw.TimeFunc().Unix() + tokenString, err := mw.signedString(newToken) + if err != nil { + return "", time.Now(), err + } + + mw.SetCookie(c, tokenString) + + return tokenString, expire, nil +} + +// CheckIfTokenExpire check if token expire +func (mw *GinJWTMiddleware) CheckIfTokenExpire(c *gin.Context) (jwt.MapClaims, error) { + token, err := mw.ParseToken(c) + if err != nil { + // If we receive an error, and the error is anything other than a single + // ValidationErrorExpired, we want to return the error. + // If the error is just ValidationErrorExpired, we want to continue, as we can still + // refresh the token if it's within the MaxRefresh time. + // (see https://github.com/appleboy/gin-jwt/issues/176) + var validationErr *jwt.ValidationError + ok := errors.As(err, &validationErr) + if !ok || validationErr.Errors != jwt.ValidationErrorExpired { + return nil, err + } + } + + claims := token.Claims.(jwt.MapClaims) + + origIat := int64(claims["orig_iat"].(float64)) + + if origIat < mw.TimeFunc().Add(-mw.MaxRefresh).Unix() { + return nil, ErrExpiredToken + } + + return claims, nil +} + +// TokenGenerator method that clients can use to get a jwt token. +func (mw *GinJWTMiddleware) TokenGenerator(data any) (string, time.Time, error) { + token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm)) + claims := token.Claims.(jwt.MapClaims) + + if mw.PayloadFunc != nil { + maps.Copy(claims, mw.PayloadFunc(data)) + } + + expire := mw.TimeFunc().Add(mw.TimeoutFunc(claims)) + claims[mw.ExpField] = expire.Unix() + claims["orig_iat"] = mw.TimeFunc().Unix() + tokenString, err := mw.signedString(token) + if err != nil { + return "", time.Time{}, err + } + + return tokenString, expire, nil +} + +func (mw *GinJWTMiddleware) jwtFromHeader(c *gin.Context, key string) (string, error) { + authHeader := c.Request.Header.Get(key) + + if authHeader == "" { + return "", ErrEmptyAuthHeader + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == mw.TokenHeadName) { + return "", ErrInvalidAuthHeader + } + + return parts[1], nil +} + +func (mw *GinJWTMiddleware) jwtFromQuery(c *gin.Context, key string) (string, error) { + token := c.Query(key) + + if token == "" { + return "", ErrEmptyQueryToken + } + + return token, nil +} + +func (mw *GinJWTMiddleware) jwtFromCookie(c *gin.Context, key string) (string, error) { + cookie, _ := c.Cookie(key) + + if cookie == "" { + return "", ErrEmptyCookieToken + } + + return cookie, nil +} + +func (mw *GinJWTMiddleware) jwtFromParam(c *gin.Context, key string) (string, error) { + token := c.Param(key) + + if token == "" { + return "", ErrEmptyParamToken + } + + return token, nil +} + +func (mw *GinJWTMiddleware) jwtFromForm(c *gin.Context, key string) (string, error) { + token := c.PostForm(key) + + if token == "" { + return "", ErrEmptyParamToken + } + + return token, nil +} + +// ParseToken parse jwt token from gin context +func (mw *GinJWTMiddleware) ParseToken(c *gin.Context) (*jwt.Token, error) { + var token string + var err error + + methods := strings.SplitSeq(mw.TokenLookup, ",") + for method := range methods { + if len(token) > 0 { + break + } + parts := strings.Split(strings.TrimSpace(method), ":") + k := strings.TrimSpace(parts[0]) + v := strings.TrimSpace(parts[1]) + switch k { + case "header": + token, err = mw.jwtFromHeader(c, v) + case "query": + token, err = mw.jwtFromQuery(c, v) + case "cookie": + token, err = mw.jwtFromCookie(c, v) + case "param": + token, err = mw.jwtFromParam(c, v) + case "form": + token, err = mw.jwtFromForm(c, v) + } + } + + if err != nil { + return nil, err + } + + if mw.KeyFunc != nil { + return jwt.Parse(token, mw.KeyFunc, mw.ParseOptions...) + } + + return jwt.Parse(token, func(t *jwt.Token) (any, error) { + if jwt.GetSigningMethod(mw.SigningAlgorithm) != t.Method { + return nil, ErrInvalidSigningAlgorithm + } + if mw.usingPublicKeyAlgo() { + return mw.pubKey, nil + } + + // save token string if valid + c.Set("JWT_TOKEN", token) + + return mw.Key, nil + }, mw.ParseOptions...) +} + +// ParseTokenString parse jwt token string +func (mw *GinJWTMiddleware) ParseTokenString(token string) (*jwt.Token, error) { + if mw.KeyFunc != nil { + return jwt.Parse(token, mw.KeyFunc, mw.ParseOptions...) + } + + return jwt.Parse(token, func(t *jwt.Token) (any, error) { + if jwt.GetSigningMethod(mw.SigningAlgorithm) != t.Method { + return nil, ErrInvalidSigningAlgorithm + } + if mw.usingPublicKeyAlgo() { + return mw.pubKey, nil + } + + return mw.Key, nil + }, mw.ParseOptions...) +} + +func (mw *GinJWTMiddleware) unauthorized(c *gin.Context, code int, message string) { + c.Header("WWW-Authenticate", "JWT realm="+mw.Realm) + if !mw.DisabledAbort { + c.Abort() + } + + mw.Unauthorized(c, code, message) +} + +// ExtractClaims help to extract the JWT claims +func ExtractClaims(c *gin.Context) MapClaims { + claims, exists := c.Get("JWT_PAYLOAD") + if !exists { + return make(MapClaims) + } + + return claims.(MapClaims) +} + +// ExtractClaimsFromToken help to extract the JWT claims from token +func ExtractClaimsFromToken(token *jwt.Token) MapClaims { + if token == nil { + return make(MapClaims) + } + + claims := MapClaims{} + maps.Copy(claims, token.Claims.(jwt.MapClaims)) + + return claims +} + +// GetToken help to get the JWT token string +func GetToken(c *gin.Context) string { + token, exists := c.Get("JWT_TOKEN") + if !exists { + return "" + } + + return token.(string) +} + +// SetCookie help to set the token in the cookie +func (mw *GinJWTMiddleware) SetCookie(c *gin.Context, token string) { + // set cookie + if mw.SendCookie { + expireCookie := mw.TimeFunc().Add(mw.CookieMaxAge) + maxage := int(expireCookie.Unix() - mw.TimeFunc().Unix()) + + if mw.CookieSameSite != 0 { + c.SetSameSite(mw.CookieSameSite) + } + + c.SetCookie( + mw.CookieName, + token, + maxage, + "/", + mw.CookieDomain, + mw.SecureCookie, + mw.CookieHTTPOnly, + ) + } +} diff --git a/backend/libs/helper.go b/backend/libs/helper.go new file mode 100644 index 0000000..11ec8d5 --- /dev/null +++ b/backend/libs/helper.go @@ -0,0 +1,100 @@ +package libs + +import ( + "crypto/rand" + "fmt" + "math/big" + "metalab/metadrinks/models" + "strconv" + + "github.com/google/uuid" +) + +func GetUserBalance(userId uuid.UUID) (*int, error) { + var user models.User + + if err := models.DB.Where("user_id = ?", userId).First(&user).Error; err != nil { + return nil, err + } + + if *user.IsRestricted { + return nil, fmt.Errorf("user is restricted") + } + + return &user.Balance, nil +} + +func UpdateUserBalance(userId uuid.UUID, change int) { + var user models.User + + if err := models.DB.Where("user_id = ?", userId).First(&user).Error; err != nil { + return + } + + if *user.IsRestricted { + return + } + + user.Balance = user.Balance + change + models.DB.Save(&user) +} + +// calculateEAN13Checksum calculates the check digit for an EAN-13 barcode +func calculateEAN13Checksum(barcode string) string { + if len(barcode) != 12 { + return "" + } + + sum := 0 + for i, digit := range barcode { + num := int(digit - '0') + if i%2 == 0 { + sum += num + } else { + sum += num * 3 + } + } + + checkDigit := (10 - (sum % 10)) % 10 + return strconv.Itoa(checkDigit) +} + +// isBarcodeUsed checks if a barcode has already been assigned to a user +func isBarcodeUsed(barcode string) bool { + var count int64 + models.DB.Model(&models.User{}).Where("login_barcode = ?", barcode).Count(&count) + return count > 0 +} + +// GenerateSecureEAN13 generates a secure random and unique EAN-13 barcode with prefix 040-049 +func GenerateSecureEAN13() (string, error) { + const maxAttempts = 100 + + for attempt := 0; attempt < maxAttempts; attempt++ { + prefixNum, err := rand.Int(rand.Reader, big.NewInt(10)) // 0-9 + if err != nil { + return "", fmt.Errorf("failed to generate random prefix: %w", err) + } + prefix := fmt.Sprintf("04%d", prefixNum.Int64()) + + randomDigits := make([]byte, 9) + for i := 0; i < 9; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return "", fmt.Errorf("failed to generate random digit: %w", err) + } + randomDigits[i] = byte('0' + num.Int64()) + } + + barcode12 := prefix + string(randomDigits) + + checkDigit := calculateEAN13Checksum(barcode12) + ean13 := barcode12 + checkDigit + + if !isBarcodeUsed(ean13) { + return ean13, nil + } + } + + return "", fmt.Errorf("failed to generate unique barcode after %d attempts", maxAttempts) +} diff --git a/backend/libs/sumup.go b/backend/libs/sumup.go index 0aff519..2e94762 100644 --- a/backend/libs/sumup.go +++ b/backend/libs/sumup.go @@ -13,7 +13,6 @@ import ( "github.com/sumup/sumup-go/client" "github.com/sumup/sumup-go/merchant" "github.com/sumup/sumup-go/readers" - "gorm.io/gorm" ) var ( @@ -23,14 +22,24 @@ var ( func Login(apiKey string) { SumupClient = sumup.NewClient(client.WithAPIKey(apiKey)) + var settings models.Settings + + if err := models.DB.Where("id = ?", 1).First(&settings).Error; err != nil { + panic(err.Error()) + } account, err := SumupClient.Merchant.Get(context.Background(), merchant.GetAccountParams{}) if err != nil { fmt.Printf("[ERROR] SumUp API: Error getting merchant account: %s\n", err.Error()) + updatedSettings := models.Settings{MaintenanceMode: settings.MaintenanceMode, DefaultReaderId: settings.DefaultReaderId, MerchantInfo: nil} + models.DB.Model(&settings).Updates(&updatedSettings) return } - fmt.Printf("[INFO] SumUp API: Authorized for merchant %q (%s)\n\n", *account.MerchantProfile.MerchantCode, *account.MerchantProfile.CompanyName) + formattedDbString := fmt.Sprintf("%s (%s)", *account.MerchantProfile.CompanyName, *account.MerchantProfile.MerchantCode) + fmt.Printf("[INFO] SumUp API: Authorized for merchant %q (%s)\n", *account.MerchantProfile.MerchantCode, *account.MerchantProfile.CompanyName) + updatedSettings := models.Settings{MaintenanceMode: settings.MaintenanceMode, DefaultReaderId: settings.DefaultReaderId, MerchantInfo: &formattedDbString} + models.DB.Model(&settings).Updates(&updatedSettings) SumupAccount = account } @@ -41,24 +50,39 @@ func InitAPIReaders() { return } - var r []sumupmodels.Reader - models.DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&r) + var rIds []string + for _, v := range response.Items { + rIds = append(rIds, string(v.Id)) + } + + // do not delete and only update existing readers that are still in api response, + // delete the readers not in api response. add the new ones. + var deletedReadersCount int64 + if len(rIds) > 0 { + deletedReadersCount = models.DB.Where("reader_id NOT IN (?)", rIds).Delete(&sumupmodels.Reader{}).RowsAffected + } else { + // if no readers in api response, delete all readers + deletedReadersCount = models.DB.Delete(&sumupmodels.Reader{}, "1=1").RowsAffected + } + if deletedReadersCount > 0 { + fmt.Printf("[INFO] SumUp API: Deleted %d reader(s).\n", deletedReadersCount) + } - // lookup if readers are in db by reader id, create only non-added ones. readersCount := 0 for _, v := range response.Items { apiReader := sumupmodels.Reader{ReaderId: sumupmodels.ReaderId(v.Id), Name: sumupmodels.ReaderName(v.Name), Status: sumupmodels.ReaderStatus(v.Status), Device: sumupmodels.ReaderDevice{Identifier: v.Device.Identifier, Model: sumupmodels.ReaderDeviceModel(v.Device.Model)}, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt} - models.DB.Create(&apiReader) + models.DB.Where("reader_id = ?", v.Id).Save(&apiReader) readersCount++ } - fmt.Printf("[INFO] SumUp API: Initialized %d reader(s).\n", readersCount) + + fmt.Printf("[INFO] SumUp API: Initialized %d linked reader(s).\n", readersCount) } func StartReaderCheckout(ReaderId string, TotalAmount uint, Description *string) (ClientTransactionId string, Error error) { returnUrl := os.Getenv("SUMUP_RETURN_URL") response, checkoutErr := SumupClient.Readers.CreateCheckout(context.Background(), *SumupAccount.MerchantProfile.MerchantCode, ReaderId, readers.CreateReaderCheckoutBody{Description: Description, ReturnUrl: &returnUrl, TotalAmount: readers.CreateReaderCheckoutAmount{Currency: "EUR", MinorUnit: 2, Value: int(TotalAmount)}}) if checkoutErr != nil { - return "error", fmt.Errorf("error while creating reader checkout: %s", checkoutErr.Error()) + return "error", fmt.Errorf("%s", checkoutErr.Error()) } return *response.Data.ClientTransactionId, nil } diff --git a/backend/main.go b/backend/main.go index 6b88992..4a42d3a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,23 +1,23 @@ package main import ( + "fmt" "log" "os" "strings" - jwt "github.com/appleboy/gin-jwt/v2" + authLib "metalab/metadrinks/libs/auth" + swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "metalab/metadrinks/controllers/api" "metalab/metadrinks/controllers/auth" - "metalab/metadrinks/controllers/payment" "metalab/metadrinks/libs" "metalab/metadrinks/models" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - "github.com/joho/godotenv" _ "metalab/metadrinks/docs" ) @@ -32,28 +32,9 @@ import ( // @name drinks_pos_session func main() { - err := godotenv.Load() - if err != nil { - log.Fatal("Error loading .env file") - } - - enforcedVars := []string{ - "SUMUP_API_KEY", - "SUMUP_RETURN_URL", - "JWT_SECRET", - "GIN_TRUSTED_PROXIES", - "CORS_ALLOWED_ORIGINS", - "DB_HOST", - "DB_USER", - "DB_PASSWORD", - "DB_DATABASE", - "DB_PORT", - "DB_TIMEZONE", - } - for _, v := range enforcedVars { - if os.Getenv(v) == "" { - panic("Environment variable " + v + " is not set. Please set it before running the application.") - } + if err := models.LoadEnvironmentVariables(); err != nil { + fmt.Printf("[ERROR] %s\n", err.Error()) + os.Exit(1) } r := gin.Default() @@ -77,7 +58,7 @@ func main() { libs.Login(os.Getenv("SUMUP_API_KEY")) libs.InitAPIReaders() - authMiddleware, err := jwt.New(auth.InitParams()) + authMiddleware, err := authLib.New(auth.InitParams()) if err != nil { log.Fatal("JWT Error:" + err.Error()) } @@ -86,7 +67,6 @@ func main() { api.RegisterRoutesAPI(r.Group("/api")) auth.RegisterRoutesAuth(r.Group("/auth")) - payment.RegisterRoutesPayment(r.Group("/payment")) swaggerGroup := r.Group("/docs") swaggerGroup.StaticFile("/swagger.json", "docs/swagger.json") diff --git a/backend/models/item.go b/backend/models/item.go index 94c2cc0..55429f5 100644 --- a/backend/models/item.go +++ b/backend/models/item.go @@ -1,12 +1,29 @@ package models -import "github.com/google/uuid" +import ( + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" +) type Item struct { - ItemId uuid.UUID `json:"id" gorm:"primaryKey;unique;type:uuid;default:gen_random_uuid()" example:"00000000-0000-0000-0000-000000000000"` - Name string `json:"name" gorm:"unique"` - Image string `json:"image,omitempty"` - Price uint `json:"price"` - Amount uint `json:"amount,omitempty" gorm:"default:1"` - Barcode string `json:"barcode,omitempty" gorm:"size:13"` //barcodes are 13 numbers long + ItemId uuid.UUID `json:"id" gorm:"primaryKey;unique;type:uuid;default:gen_random_uuid()" example:"00000000-0000-0000-0000-000000000000"` + ProductName string `json:"name" gorm:"uniqueIndex:name_variant_volume_idx"` + ProductVariant string `json:"variant,omitempty" gorm:"uniqueIndex:name_variant_volume_idx"` + Image string `json:"image,omitempty"` + Volume uint `json:"volume" gorm:"uniqueIndex:name_variant_volume_idx"` //in ml + Price uint `json:"price"` + Amount uint `json:"amount,omitempty" gorm:"-"` //do not write this to db - it is only used when creating a purchase + Barcodes pq.StringArray `json:"barcodes,omitempty" gorm:"type:bytes;serializer:gob"` + NutritionInfo []NutritionInfo `json:"nutrition_info,omitempty" gorm:"type:bytes;serializer:gob"` + IsActive *bool `json:"is_active" gorm:"default:true"` + CreatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty"` +} + +type NutritionInfo struct { + Name string `json:"name" gorm:"unique" binding:"required"` + Value string `json:"value" binding:"required"` } diff --git a/backend/models/purchase.go b/backend/models/purchase.go index 055ca7d..d4e97ee 100644 --- a/backend/models/purchase.go +++ b/backend/models/purchase.go @@ -10,7 +10,7 @@ import ( type Purchase struct { PurchaseId uuid.UUID `json:"id" gorm:"primaryKey;unique;type:uuid;default:gen_random_uuid()"` - Items []Item `json:"items,omitempty" gorm:"foreignKey:ItemID;type:bytes;serializer:gob"` + Items []PurchaseItem `json:"items,omitempty" gorm:"foreignKey:ItemID;type:bytes;serializer:gob"` PaymentType PaymentType `json:"payment_type"` TransactionStatus sumupmodels.TransactionFullStatus `json:"status"` ClientTransactionId string `json:"client_transaction_id,omitempty"` @@ -20,6 +20,15 @@ type Purchase struct { CreatedBy uuid.UUID `json:"created_by"` // uuid of user, otherwise null uuid (for guests) } +type PurchaseItem struct { + ItemId uuid.UUID `json:"id"` + ProductName string `json:"name"` + ProductVariant string `json:"variant"` + Volume uint `json:"volume"` + Price uint `json:"price"` + Amount uint `json:"amount"` +} + // PaymentType The type of the payment object gives information about the type of payment. // // Possible values: diff --git a/backend/models/settings.go b/backend/models/settings.go new file mode 100644 index 0000000..5ecce15 --- /dev/null +++ b/backend/models/settings.go @@ -0,0 +1,8 @@ +package models + +type Settings struct { + ID uint `gorm:"primaryKey;unique" json:"-"` + MaintenanceMode *bool `json:"maintenance"` + DefaultReaderId *string `json:"default_reader_id"` + MerchantInfo *string `json:"merchant_info,omitempty"` +} diff --git a/backend/models/setup.go b/backend/models/setup.go index 9873bcd..9aacfd5 100644 --- a/backend/models/setup.go +++ b/backend/models/setup.go @@ -1,6 +1,7 @@ package models import ( + "crypto/rand" "fmt" "metalab/metadrinks/libs/crypto" "os" @@ -9,6 +10,7 @@ import ( models "metalab/metadrinks/models/sumup" "github.com/google/uuid" + "github.com/joho/godotenv" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -16,7 +18,7 @@ import ( var DB *gorm.DB func ConnectDatabase() { - dsn := "host=" + os.Getenv("DB_HOST") + " user=" + os.Getenv("DB_USER") + " password=" + os.Getenv("DB_PASSWORD") + " dbname=" + os.Getenv("DB_DATABASE") + " port=" + os.Getenv("DB_PORT") + " sslmode=disable timezone=" + os.Getenv("DB_TIMEZONE") + dsn := "host=" + os.Getenv("DB_HOST") + " user=" + os.Getenv("POSTGRES_USER") + " password=" + os.Getenv("POSTGRES_PASSWORD") + " dbname=" + os.Getenv("POSTGRES_DB") + " port=" + os.Getenv("DB_PORT") + " sslmode=disable timezone=" + os.Getenv("TZ") database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) // change the database provider if necessary if err != nil { panic("Failed to connect to database!" + err.Error()) @@ -25,17 +27,85 @@ func ConnectDatabase() { database.AutoMigrate(&User{}) database.AutoMigrate(&Item{}) database.AutoMigrate(&Purchase{}) + database.AutoMigrate(&Settings{}) database.AutoMigrate(&models.Reader{}) - hashedPassword, err := crypto.HashPasswordSecure("") //bcrypt.GenerateFromPassword([]byte(""), bcrypt.DefaultCost) - if err != nil { - fmt.Println("Error generating password hash: ", err) - return + if database.Limit(1).Find(&User{Name: "guest"}).RowsAffected == 0 { + hashedPassword, err := crypto.HashPasswordSecure("") + if err != nil { + fmt.Println("Error generating guest password hash: ", err) + return + } + database.Create(&User{UserID: uuid.Nil, Name: "Guest", Password: hashedPassword, IsAdmin: BoolPointer(false), IsTrusted: BoolPointer(false), IsRestricted: BoolPointer(true), UsedAt: time.Now().Local()}) + fmt.Println("[INFO] Created guest user") } - if database.Limit(1).Find(&User{Name: "guest"}).RowsAffected == 0 { - database.Create(&User{UserID: uuid.Nil, Name: "Guest", Password: string(hashedPassword), IsTrusted: false, IsRestricted: true, UsedAt: time.Now().Local()}) + if database.Where("is_admin = true").Find(&User{}).RowsAffected == 0 { + key := rand.Text() + hashedPassword, err := crypto.HashPasswordSecure(key) + if err != nil { + fmt.Println("Error generating admin password hash: ", err) + return + } + database.Create(&User{UserID: uuid.Nil, Name: "a-admin", Password: hashedPassword, IsAdmin: BoolPointer(true), IsTrusted: BoolPointer(false), IsRestricted: BoolPointer(false), UsedAt: time.Now().Local()}) + fmt.Printf("\n[INFO] Created default admin user with password %s\n", key) + } + + if database.Where("id = ?", 1).Find(&Settings{}).RowsAffected == 0 { + database.Create(&Settings{ID: 1, MaintenanceMode: BoolPointer(false), MerchantInfo: nil}) + fmt.Println("[INFO] Created default settings") } DB = database } + +func BoolPointer(b bool) *bool { + return &b +} + +func LoadEnvironmentVariables() error { + enforcedVars := []string{ + "SUMUP_API_KEY", + "SUMUP_RETURN_URL", + "JWT_SECRET", + "GIN_TRUSTED_PROXIES", + "CORS_ALLOWED_ORIGINS", + "DB_HOST", + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "POSTGRES_DB", + "DB_PORT", + "TZ", + } + + // Check if any required vars are missing + var missingVars []string + for _, envVar := range enforcedVars { + if os.Getenv(envVar) == "" { + missingVars = append(missingVars, envVar) + } + } + + // If some vars are missing, try loading from .env + if len(missingVars) > 0 { + fmt.Println("[INFO] Some environment variables not set, attempting to load from .env file...") + if err := godotenv.Load(); err != nil { + return fmt.Errorf("error loading .env file: %w", err) + } + } + + // Verify all required vars are now set + var stillMissing []string + for _, envVar := range enforcedVars { + if os.Getenv(envVar) == "" { + stillMissing = append(stillMissing, envVar) + } + } + + if len(stillMissing) > 0 { + return fmt.Errorf("missing required environment variables: %v", stillMissing) + } + + fmt.Println("[INFO] All required environment variables are set") + return nil +} diff --git a/backend/models/sumup/reader.go b/backend/models/sumup/reader.go index 5fbf363..e150778 100644 --- a/backend/models/sumup/reader.go +++ b/backend/models/sumup/reader.go @@ -20,7 +20,7 @@ type Reader struct { // a physical device. // Min length: 30 // Max length: 30 - ReaderId ReaderId `json:"id"` + ReaderId ReaderId `json:"id" gorm:"primaryKey;unique"` // Set of user-defined key-value pairs attached to the object. // Max properties: 50 Meta *Meta `json:"meta,omitempty" gorm:"type:bytes;serializer:json"` diff --git a/backend/models/user.go b/backend/models/user.go index ebf9f01..bfae818 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -9,14 +9,15 @@ import ( type User struct { UserID uuid.UUID `json:"id" gorm:"primaryKey;unique;type:uuid;default:gen_random_uuid()"` - Name string `json:"name" gorm:"index,unique"` - Image string `json:"image" default:"assets/empty.webp"` + Name string `json:"name" gorm:"index,unique,size:24"` + Image string `json:"image,omitempty"` Password string `json:"password,omitempty"` + LoginBarcode string `json:"login_barcode,omitempty"` Balance int `json:"balance" gorm:"default:0"` - IsTrusted bool `json:"is_trusted" gorm:"default:false"` - IsAdmin bool `json:"is_admin" gorm:"default:false"` - IsActive bool `json:"is_active" gorm:"default:true"` - IsRestricted bool `json:"is_restricted" gorm:"default:false"` // this entirely disables the balance element for the affected user + IsTrusted *bool `json:"is_trusted" gorm:"default:false"` + IsAdmin *bool `json:"is_admin" gorm:"default:false"` + IsActive *bool `json:"is_active" gorm:"default:true"` + IsRestricted *bool `json:"is_restricted" gorm:"default:false"` // this entirely disables the balance element for the affected user CreatedAt time.Time `json:"created_at"` UsedAt time.Time `json:"used_at"` DeletedAt gorm.DeletedAt `json:"deleted_at"` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f469c29 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + database: + image: "postgres:18-alpine" + ports: + - 5432:5432 + env_file: + - .env.backend + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_DATABASE} + volumes: + - db-data:/var/lib/postgresql/data/ + backend: + image: "ghcr.io/metalab/metadrinks:backend-latest" #can also be used with backend- or backend-pr- + ports: + - 8080:8080 + env_file: + - .env.backend + depends_on: + - database + + frontend: + image: "ghcr.io/metalab/metadrinks:frontend-latest" #can also be used with frontend- or frontend-pr- + ports: + - 3000:3000 + env_file: + - .env.frontend + depends_on: + - backend + +volumes: + db-data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2e39e0a..2ddc857 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,2 +1,66 @@ -FROM nginx:alpine -COPY html /usr/share/nginx/html \ No newline at end of file +# syntax=docker.io/docker/dockerfile:1 + +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..f06471a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/admin/items/page.tsx b/frontend/app/admin/items/page.tsx new file mode 100644 index 0000000..3a68852 --- /dev/null +++ b/frontend/app/admin/items/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useAuth } from "@/components/auth-context"; +import { useUser } from "@/components/user-context"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { config } from "@/lib/config"; +import { Button } from "@/components/ui/button"; +import ItemDialog from "@/components/item-admin-dialog"; +import ItemCards from "@/components/item-cards"; +import { useContentUpdates } from "@/hooks/use-sse-events"; +import { PlusIcon } from "lucide-react"; +import { Item } from "@/types/item"; + +export default function AdminItemsPage() { + const { loggedIn } = useAuth(); + const { user } = useUser(); + const router = useRouter(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + + useEffect(() => { + if (!loggedIn) { + router.push("/admin"); + return; + } + + if (user && !user.is_admin) { + router.push("/admin"); + } + }, [loggedIn, user, router]); + + useEffect(() => { + fetchItems(); + }, []); + + useContentUpdates((data) => { + if (data.content_payload.type === "items") { + fetchItems(); + } + }, []); + + const fetchItems = async () => { + try { + const res = await fetch(`${config.apiBaseUrl}/api/v1/items`); + const data = await res.json(); + const itemsData: Item[] = Array.isArray(data) ? data : data.data || []; + setItems(itemsData); + } catch (error) { + console.error("Failed to fetch items:", error); + setItems([]); + } finally { + setLoading(false); + } + }; + + const handleItemClick = (item: Item) => { + setSelectedItem(item); + setDialogOpen(true); + }; + + const handleCreateNew = () => { + setSelectedItem(null); + setDialogOpen(true); + }; + + const handleDialogSuccess = () => { + fetchItems(); + }; + + if (!loggedIn) { + return null; + } + + if (!user) { + return ( +
+

Loading...

+
+ ); + } + + if (!user.is_admin) { + return null; + } + + if (loading) { + return ( +
+

Loading items...

+
+ ); + } + + return ( +
+
+
+

Manage Items

+ +
+ + +
+ + +
+ ); +} diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 0000000..87cb9a9 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useAuth } from "@/components/auth-context"; +import { useEffect } from "react"; + +export default function AdminLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { enableAutoRefresh } = useAuth(); + + useEffect(() => { + enableAutoRefresh(true); + + return () => { + enableAutoRefresh(false); + }; + }, [enableAutoRefresh]); + + return <>{children}; +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..cbdcb40 --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,70 @@ +"use client"; +import { useAuth } from "@/components/auth-context"; +import { useUser, User } from "@/components/user-context"; +import { useState, useEffect } from "react"; +import PasswordDialog from "@/components/password-dialog"; + +export default function AdminPage() { + const { loggedIn, logout } = useAuth(); + const { user } = useUser(); + const [dialogOpen, setDialogOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState( + "Please log in with an admin account to access this page." + ); + + useEffect(() => { + if (loggedIn && user) { + if (!user.is_admin) { + setDialogOpen(true); + } else { + setDialogOpen(false); + } + } else if (!loggedIn) { + setDialogOpen(true); + } + }, [loggedIn, user]); + + const handleValidation = async (userData: User | null) => { + // user data is passed from login response + if (!userData) { + return { + isValid: false, + error: "User data not loaded", + }; + } + + if (!userData.is_admin) { + logout(false); + setErrorMessage( + "Access denied. You must be an admin to access this page." + ); + setDialogOpen(true); + return { + isValid: false, + error: "Access denied. You must be an admin to access this page.", + }; + } + + setDialogOpen(false); + return { isValid: true }; + }; + + return ( +
+ + {loggedIn && user?.is_admin && ( +
+

Admin Page

+
+ )} +
+ ); +} diff --git a/frontend/app/admin/purchases/columns.tsx b/frontend/app/admin/purchases/columns.tsx new file mode 100644 index 0000000..5508821 --- /dev/null +++ b/frontend/app/admin/purchases/columns.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ArrowUpDown, UserSearchIcon } from "lucide-react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Purchase } from "@/types/purchase"; +import { Item } from "@/types/item"; +import { useState, useEffect } from "react"; +import { config } from "@/lib/config"; + +// cache for resolved usernames +const userCache: Map = new Map(); +const userCacheListeners: Map void>> = new Map(); + +const subscribeToUser = (userId: string, callback: (name: string) => void) => { + if (!userCacheListeners.has(userId)) { + userCacheListeners.set(userId, new Set()); + } + userCacheListeners.get(userId)!.add(callback); + + return () => { + const listeners = userCacheListeners.get(userId); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + userCacheListeners.delete(userId); + } + } + }; +}; + +const notifyUserResolved = (userId: string, userName: string) => { + userCache.set(userId, userName); + const listeners = userCacheListeners.get(userId); + if (listeners) { + listeners.forEach((callback) => callback(userName)); + } +}; + +const UserIdCell = ({ userId }: { userId: string }) => { + const [userName, setUserName] = useState( + userCache.get(userId) || null + ); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const unsubscribe = subscribeToUser(userId, setUserName); + return unsubscribe; + }, [userId]); + + const resolveUser = async () => { + if (userCache.has(userId)) { + setUserName(userCache.get(userId)!); + return; + } + + setLoading(true); + try { + const res = await fetch(`${config.apiBaseUrl}/api/v1/users/${userId}`, { + credentials: "include", + }); + const data = await res.json(); + const user = data.data || data; + notifyUserResolved(userId, user.name); + } catch (error) { + console.error("Failed to fetch user:", error); + notifyUserResolved(userId, "Unknown"); + } finally { + setLoading(false); + } + }; + + if (userName) { + return
{userName}
; + } + + return ( +
+
{userId.slice(0, 8)}...
+ +
+ ); +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "created_by", + header: "User ID", + cell: ({ row }) => { + const userId = row.getValue("created_by") as string; + return ; + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const date = new Date(row.getValue("created_at")); + return
{date.toLocaleString()}
; + }, + }, + { + accessorKey: "payment_type", + header: "Payment Type", + cell: ({ row }) => { + const paymentType = row.getValue("payment_type") as string; + const badges: Record = { + cash: "bg-green-100 text-green-800", + card: "bg-blue-100 text-blue-800", + balance: "bg-purple-100 text-purple-800", + }; + return ( +
+ {paymentType} +
+ ); + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.getValue("status") as string; + const badges: Record = { + SUCCESSFUL: "bg-green-100 text-green-800", + PENDING: "bg-yellow-100 text-yellow-800", + FAILED: "bg-red-100 text-red-800", + }; + return ( +
+ {status} +
+ ); + }, + }, + { + accessorKey: "final_cost", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const amount = parseFloat(row.getValue("final_cost")) / 100; + const formatted = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "EUR", + }).format(amount); + + return
{formatted}
; + }, + }, + { + accessorKey: "items", + header: "Items", + cell: ({ row }) => { + const items = row.getValue("items") as Item[]; + if (!items || items.length === 0) { + return
No items
; + } + return ( +
+ {items.map((item, idx) => ( +
+ {item.amount}x {item.name} {item.variant} +
+ ))} +
+ ); + }, + }, +]; diff --git a/frontend/app/admin/purchases/data-table.tsx b/frontend/app/admin/purchases/data-table.tsx new file mode 100644 index 0000000..cef18a7 --- /dev/null +++ b/frontend/app/admin/purchases/data-table.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import React from "react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + state: { + sorting, + }, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as + | { className?: string } + | undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as + | { className?: string } + | undefined; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + +
+
+ ); +} diff --git a/frontend/app/admin/purchases/page.tsx b/frontend/app/admin/purchases/page.tsx new file mode 100644 index 0000000..403bdcf --- /dev/null +++ b/frontend/app/admin/purchases/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useAuth } from "@/components/auth-context"; +import { useUser } from "@/components/user-context"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { config } from "@/lib/config"; +import { useContentUpdates } from "@/hooks/use-sse-events"; +import { columns } from "./columns"; +import { DataTable } from "./data-table"; +import { Purchase } from "@/types/purchase"; + +export default function AdminPurchasesPage() { + const { loggedIn } = useAuth(); + const { user } = useUser(); + const router = useRouter(); + const [purchases, setPurchases] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!loggedIn) { + router.push("/admin"); + return; + } + + if (user && !user.is_admin) { + router.push("/admin"); + } + }, [loggedIn, user, router]); + + useEffect(() => { + fetchPurchases(); + }, []); + + useContentUpdates((data) => { + if (data.content_payload.type === "purchases") { + fetchPurchases(); + } + }, []); + + const fetchPurchases = async () => { + try { + const res = await fetch(`${config.apiBaseUrl}/api/v1/purchases`, { + credentials: "include", + }); + const data = await res.json(); + const purchasesData: Purchase[] = Array.isArray(data) + ? data + : data.data || []; + setPurchases(purchasesData); + } catch (error) { + console.error("Failed to fetch purchases:", error); + setPurchases([]); + } finally { + setLoading(false); + } + }; + + if (!loggedIn) { + return null; + } + + if (!user) { + return ( +
+

Loading...

+
+ ); + } + + if (!user.is_admin) { + return null; + } + + if (loading) { + return ( +
+

Loading purchases...

+
+ ); + } + + return ( +
+
+
+

Purchases

+
+ + +
+
+ ); +} diff --git a/frontend/app/admin/readers/columns.tsx b/frontend/app/admin/readers/columns.tsx new file mode 100644 index 0000000..8a76a0e --- /dev/null +++ b/frontend/app/admin/readers/columns.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ArrowUpDown, Star, Trash2 } from "lucide-react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Reader, ReaderStatus } from "@/types/reader"; +import { Badge } from "@/components/ui/badge"; + +const getStatusBadgeColor = (status: ReaderStatus) => { + const badges: Record = { + paired: + "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400", + processing: + "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400", + expired: "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400", + unknown: "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400", + }; + return badges[status] || badges.unknown; +}; + +const getModelName = (model: string) => { + const modelNames: Record = { + solo: "SumUp Solo", + "virtual-solo": "Virtual Solo", + }; + return modelNames[model] || model; +}; + +export const createColumns = ( + onDeleteClick: (reader: Reader) => void, + onSetDefault: (id: string) => Promise, + defaultReaderId?: string +): ColumnDef[] => [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const name = row.getValue("name") as string; + const reader = row.original; + const isDefault = defaultReaderId === reader.id; + + return ( +
+
+ {name}{" "} + {isDefault && ( + + Default + + )} +
+
+ {reader.id} +
+
+ ); + }, + }, + { + accessorKey: "device", + header: "Device", + cell: ({ row }) => { + const reader = row.original; + return ( +
+
{getModelName(reader.device.model)}
+
+ {reader.device.identifier} +
+
+ ); + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.getValue("status") as ReaderStatus; + return ( +
+ {status.charAt(0).toUpperCase() + status.slice(1)} +
+ ); + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const date = new Date(row.getValue("created_at")); + return ( +
+ {date.toLocaleDateString()}{" "} + + {date.toLocaleTimeString()} + +
+ ); + }, + }, + { + accessorKey: "updated_at", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const date = new Date(row.getValue("updated_at")); + return ( +
+ {date.toLocaleDateString()}{" "} + + {date.toLocaleTimeString()} + +
+ ); + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const reader = row.original; + const isDefault = defaultReaderId === reader.id; + + return ( +
+ {!isDefault && ( + + )} + +
+ ); + }, + }, +]; diff --git a/frontend/app/admin/readers/data-table.tsx b/frontend/app/admin/readers/data-table.tsx new file mode 100644 index 0000000..cef18a7 --- /dev/null +++ b/frontend/app/admin/readers/data-table.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import React from "react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + state: { + sorting, + }, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as + | { className?: string } + | undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as + | { className?: string } + | undefined; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + +
+
+ ); +} diff --git a/frontend/app/admin/readers/page.tsx b/frontend/app/admin/readers/page.tsx new file mode 100644 index 0000000..0637b16 --- /dev/null +++ b/frontend/app/admin/readers/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useAuth } from "@/components/auth-context"; +import { useUser } from "@/components/user-context"; +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { config } from "@/lib/config"; +import { useContentUpdates } from "@/hooks/use-sse-events"; +import { createColumns } from "./columns"; +import { DataTable } from "./data-table"; +import { Reader } from "@/types/reader"; +import { Button } from "@/components/ui/button"; +import { Link2Icon } from "lucide-react"; +import LinkReaderDialog from "@/components/link-reader-dialog"; +import { toast } from "sonner"; +import { useSettings } from "@/components/settings-context"; +import { Spinner } from "@/components/ui/spinner"; +import { DeleteReaderDialog } from "@/components/delete-reader-dialog"; + +export default function AdminReadersPage() { + const { loggedIn } = useAuth(); + const { user } = useUser(); + const { settings, refreshSettings } = useSettings(); + const router = useRouter(); + const [readers, setReaders] = useState([]); + const [loading, setLoading] = useState(true); + const [linkDialogOpen, setLinkDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [readerToDelete, setReaderToDelete] = useState(null); + const hasFetchedRef = useRef(false); + + useEffect(() => { + if (!loggedIn) { + router.push("/admin"); + return; + } + + if (user && !user.is_admin) { + router.push("/admin"); + } + }, [loggedIn, user, router]); + + useEffect(() => { + if (!hasFetchedRef.current) { + hasFetchedRef.current = true; + fetchReaders(); + } + }, []); + + useContentUpdates((data) => { + if (data.content_payload.type === "readers") { + fetchReaders(); + } + }, []); + + const fetchReaders = async () => { + try { + const res = await fetch(`${config.apiBaseUrl}/api/payment/v1/readers`, { + credentials: "include", + }); + const data = await res.json(); + const readersData: Reader[] = Array.isArray(data) + ? data + : data.data || []; + setReaders(readersData); + } catch (error) { + console.error("Failed to fetch readers:", error); + setReaders([]); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + try { + const isDefault = settings?.default_reader_id === id; + + const res = await fetch( + `${config.apiBaseUrl}/api/payment/v1/readers/${id}`, + { + method: "DELETE", + credentials: "include", + } + ); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to delete reader"); + } + + if (isDefault) { + await handleSetDefault(""); + } + + toast.success("Reader deleted successfully"); + setDeleteDialogOpen(false); + setReaderToDelete(null); + await fetchReaders(); + } catch (error) { + console.error("Failed to delete reader:", error); + toast.error( + error instanceof Error ? error.message : "Failed to delete reader" + ); + } + }; + + const handleSetDefault = async (readerId: string) => { + try { + const res = await fetch(`${config.apiBaseUrl}/api/admin/v1/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + default_reader_id: readerId, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to update default reader"); + } + + await refreshSettings(); + + if (readerId) { + toast.success("Default reader updated successfully"); + } + } catch (error) { + console.error("Failed to set default reader:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to update default reader" + ); + } + }; + + const handleDeleteClick = (reader: Reader) => { + setReaderToDelete(reader); + setDeleteDialogOpen(true); + }; + + const columns = createColumns( + handleDeleteClick, + handleSetDefault, + settings?.default_reader_id + ); + + if (!loggedIn) { + return null; + } + + if (!user) { + return ( +
+ +
+ ); + } + + if (!user.is_admin) { + return null; + } + + if (loading) { + return ( +
+
+ +

Loading readers...

+
+
+ ); + } + + return ( +
+
+
+
+

Card Readers

+

+ Manage linked card readers for payment processing. +
+ SumUp Merchant: {settings?.merchant_info || "none"} +

+
+ +
+ + +
+ + + + {readerToDelete && ( + + )} +
+ ); +} diff --git a/frontend/app/admin/users/columns.tsx b/frontend/app/admin/users/columns.tsx new file mode 100644 index 0000000..65c917b --- /dev/null +++ b/frontend/app/admin/users/columns.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ArrowUpDown, UserPen, ShieldIcon, UserIcon } from "lucide-react"; +import { User } from "@/components/user-context"; +import { ColumnDef } from "@tanstack/react-table"; +import { IconClipboard } from "@tabler/icons-react"; + +export const columns = ( + onEditUser: (user: User) => void, + currentUserId?: string +): ColumnDef[] => [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => { + const user = row.original; + const isCurrentUser = currentUserId && user.id === currentUserId; + const isAdmin = user.is_admin; + + return ( +
+ {isAdmin && ( +
+ +
+ )} + {isCurrentUser && ( +
+ +
+ )} + {user.name} +
+ ); + }, + }, + { + accessorKey: "balance", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const amount = parseFloat(row.getValue("balance")) / 100; + const formatted = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "EUR", + }).format(amount); + + return
{formatted}
; + }, + }, + { + accessorKey: "is_active", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const isActive = row.getValue("is_active"); + return
{isActive ? "✅" : "❌"}
; + }, + }, + { + accessorKey: "is_trusted", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const isTrusted = row.getValue("is_trusted"); + return
{isTrusted ? "✅" : "❌"}
; + }, + }, + { + accessorKey: "is_restricted", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const isRestricted = row.getValue("is_restricted"); + return
{isRestricted ? "✅" : "❌"}
; + }, + }, + { + accessorKey: "is_admin", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const isAdmin = row.getValue("is_admin"); + return
{isAdmin ? "✅" : "❌"}
; + }, + }, + { + id: "actions", + cell: ({ row }) => { + const user = row.original; + + return ( +
+ + +
+ ); + }, + meta: { + className: "w-[200px]", + }, + }, +]; diff --git a/frontend/app/admin/users/data-table.tsx b/frontend/app/admin/users/data-table.tsx new file mode 100644 index 0000000..0e2ed6e --- /dev/null +++ b/frontend/app/admin/users/data-table.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import React from "react"; +import { Input } from "@/components/ui/input"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + }, + }); + + return ( +
+
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as + | { className?: string } + | undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as + | { className?: string } + | undefined; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + +
+
+ ); +} diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx new file mode 100644 index 0000000..1bf5739 --- /dev/null +++ b/frontend/app/admin/users/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useAuth } from "@/components/auth-context"; +import { User, useUser } from "@/components/user-context"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { config } from "@/lib/config"; +import { Button } from "@/components/ui/button"; +import UserAdminDialog from "@/components/user-admin-dialog"; +import { useContentUpdates } from "@/hooks/use-sse-events"; +import { PlusIcon } from "lucide-react"; +import { columns } from "./columns"; +import { DataTable } from "./data-table"; + +export default function AdminUsersPage() { + const { loggedIn } = useAuth(); + const { user } = useUser(); + const router = useRouter(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + + useEffect(() => { + if (!loggedIn) { + router.push("/admin"); + return; + } + + if (user && !user.is_admin) { + router.push("/admin"); + } + }, [loggedIn, user, router]); + + useEffect(() => { + fetchUsers(); + }, []); + + useContentUpdates((data) => { + if (data.content_payload.type === "users") { + fetchUsers(); + } + }, []); + + const fetchUsers = async () => { + try { + const res = await fetch(`${config.apiBaseUrl}/api/admin/v1/users`, { + credentials: "include", + }); + const data = await res.json(); + const usersData: User[] = Array.isArray(data) ? data : data.data || []; + setUsers(usersData); + } catch (error) { + console.error("Failed to fetch users:", error); + setUsers([]); + } finally { + setLoading(false); + } + }; + + const handleCreateNew = () => { + setSelectedUser(null); + setDialogOpen(true); + }; + + const handleEditUser = (user: User) => { + setSelectedUser(user); + setDialogOpen(true); + }; + + const handleDialogSuccess = () => { + fetchUsers(); + }; + + if (!loggedIn) { + return null; + } + + if (!user) { + return ( +
+

Loading...

+
+ ); + } + + if (!user.is_admin) { + return null; + } + + if (loading) { + return ( +
+

Loading users...

+
+ ); + } + + return ( +
+
+
+

Manage Users

+ +
+ + +
+ + +
+ ); +} diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..dc98be7 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/app/items/client-layout.tsx b/frontend/app/items/client-layout.tsx new file mode 100644 index 0000000..ffe1499 --- /dev/null +++ b/frontend/app/items/client-layout.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { SelectedItemsProvider } from "@/components/selected-items-context"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { ItemsSidebar } from "@/components/items-sidebar"; +import { useAuth } from "@/components/auth-context"; +import { BarcodeItemListener } from "@/components/barcode-item-listener"; +import React from "react"; + +export default function ItemsClientLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { loggedIn } = useAuth(); + + return ( + + + +
+
{children}
+ {loggedIn && } +
+
+
+ ); +} diff --git a/frontend/app/items/layout.tsx b/frontend/app/items/layout.tsx new file mode 100644 index 0000000..b6c7ea9 --- /dev/null +++ b/frontend/app/items/layout.tsx @@ -0,0 +1,13 @@ +import ItemsClientLayout from "./client-layout"; + +export default function ItemsLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/app/items/page.tsx b/frontend/app/items/page.tsx new file mode 100644 index 0000000..131f807 --- /dev/null +++ b/frontend/app/items/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import ItemCards from "@/components/item-cards"; +import { BarcodeSearchInput } from "@/components/search-barcode-input"; +import { useSelectedItems } from "@/components/selected-items-context"; +import { useContentUpdates } from "@/hooks/use-sse-events"; +import { config } from "@/lib/config"; +import { Item } from "@/types/item"; + +export default function ItemsPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const { addItem } = useSelectedItems(); + const hasFetchedRef = useRef(false); + + const fetchItems = useCallback(() => { + fetch(`${config.apiBaseUrl}/api/v1/items`) + .then((res) => res.json()) + .then((data) => { + const items: Item[] = Array.isArray(data) ? data : data.data || []; + setItems(items); + setLoading(false); + }) + .catch(() => { + setItems([]); + setLoading(false); + }); + }, []); + + useEffect(() => { + if (!hasFetchedRef.current) { + hasFetchedRef.current = true; + fetchItems(); + } + }, [fetchItems]); + + useContentUpdates( + (data) => { + if (data.content_payload.type === "items") { + fetchItems(); + } + }, + [fetchItems] + ); + + if (loading) { + return
Loading...
; + } + + // Filter to show only active items + const activeItems = items.filter((item) => item.is_active === true); + + return ( +
+ + +
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..4a7a2bb --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import RootLayoutClient from "@/components/root-layout-client"; +import { PublicEnvScript } from "next-runtime-env"; + +export const metadata: Metadata = { + title: "Metadrinks", + //description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + + + {children} + + + ); +} diff --git a/frontend/app/maintenance/page.tsx b/frontend/app/maintenance/page.tsx new file mode 100644 index 0000000..70c2aa8 --- /dev/null +++ b/frontend/app/maintenance/page.tsx @@ -0,0 +1,34 @@ +import { Construction } from "lucide-react"; + +export default function MaintenancePage() { + return ( +
+
+
+
+ +
+
+ +
+

+ Under Maintenance +

+

+ We're currently performing scheduled maintenance to improve + your experience. +

+
+ +
+
+
+ + Service temporarily unavailable + +
+
+
+
+ ); +} diff --git a/frontend/app/manifest.json b/frontend/app/manifest.json new file mode 100644 index 0000000..e26497c --- /dev/null +++ b/frontend/app/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Metadrinks", + "short_name": "Metadrinks", + "icons": [ + { + "src": "/assets/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/assets/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone", + "orientation": "landscape" +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..838cd16 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import UserCards from "@/components/user-cards"; +import { useAuth } from "@/components/auth-context"; +import { useContentUpdates } from "@/hooks/use-sse-events"; +import { PlusIcon, SearchIcon } from "lucide-react"; +import { UserDialog } from "@/components/user-dialog"; +import { BarcodeSearchInput } from "@/components/search-barcode-input"; +import { Spinner } from "@/components/ui/spinner"; + +export default function Home() { + const [search, setSearch] = useState(""); + const [guestLoading, setGuestLoading] = useState(false); + const [userDialogOpen, setUserDialogOpen] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const { login } = useAuth(); + + const handleUserCreated = () => { + setRefreshTrigger((prev) => prev + 1); + }; + + useContentUpdates((data) => { + if (data.content_payload.type === "users") { + setRefreshTrigger((prev) => prev + 1); + } + }, []); + + const handleGuestLogin = async () => { + setGuestLoading(true); + await login("Guest"); + setGuestLoading(false); + }; + + return ( +
+ +
+ + +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+
+ +
+
+
+ +
+
+ +
+ ); +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/components/add-balance-dialog.tsx b/frontend/components/add-balance-dialog.tsx new file mode 100644 index 0000000..f3c56de --- /dev/null +++ b/frontend/components/add-balance-dialog.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PaymentDialog } from "./payment-dialog"; +import { toast } from "sonner"; + +interface AddBalanceDialogProps { + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onComplete?: () => void; +} + +export function AddBalanceDialog({ + trigger, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + onComplete, +}: AddBalanceDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); + const [amount, setAmount] = useState(""); + const [showPayment, setShowPayment] = useState(false); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled + ? controlledOnOpenChange || (() => {}) + : setInternalOpen; + + const handleAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // Only allow numbers and decimal point + if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) { + setAmount(value); + } + }; + + const handleCheckout = () => { + const amountNum = parseFloat(amount); + if (!amount || isNaN(amountNum) || amountNum <= 0) { + toast.error("Please enter a valid amount"); + return; + } + setShowPayment(true); + }; + + const handlePaymentComplete = () => { + setShowPayment(false); + setAmount(""); + setOpen(false); + onComplete?.(); + toast.success("Balance added successfully"); + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + setAmount(""); + setShowPayment(false); + } + }; + + const amountInCents = Math.round((parseFloat(amount) || 0) * 100); + const isValidAmount = amount && parseFloat(amount) > 0; + + return ( + + {trigger && {trigger}} + + + Add Balance + + Enter the amount you want to add to your balance. + + + + {!showPayment ? ( +
+
+ + + {amount && isValidAmount && ( +

+ You will be charged €{parseFloat(amount).toFixed(2)} +

+ )} +
+ + +
+ ) : ( +
+
+

Amount to add:

+

+ €{parseFloat(amount).toFixed(2)} +

+
+ +
+

Select payment method:

+ + + Cash + + } + onComplete={handlePaymentComplete} + /> + + + Card + + } + onComplete={handlePaymentComplete} + /> + + +
+
+ )} +
+
+ ); +} + +export default AddBalanceDialog; diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx new file mode 100644 index 0000000..8ebc4ae --- /dev/null +++ b/frontend/components/app-sidebar.tsx @@ -0,0 +1,181 @@ +"use client" + +import * as React from "react" +import { + IconCamera, + IconChartBar, + IconDashboard, + IconDatabase, + IconFileAi, + IconFileDescription, + IconFileWord, + IconFolder, + IconHelp, + IconInnerShadowTop, + IconListDetails, + IconReport, + IconSearch, + IconSettings, + IconUsers, +} from "@tabler/icons-react" + +import { NavDocuments } from "@/components/nav-documents" +import { NavMain } from "@/components/nav-main" +import { NavSecondary } from "@/components/nav-secondary" +import { NavUser } from "@/components/nav-user" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Dashboard", + url: "#", + icon: IconDashboard, + }, + { + title: "Lifecycle", + url: "#", + icon: IconListDetails, + }, + { + title: "Analytics", + url: "#", + icon: IconChartBar, + }, + { + title: "Projects", + url: "#", + icon: IconFolder, + }, + { + title: "Team", + url: "#", + icon: IconUsers, + }, + ], + navClouds: [ + { + title: "Capture", + icon: IconCamera, + isActive: true, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Proposal", + icon: IconFileDescription, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Prompts", + icon: IconFileAi, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Settings", + url: "#", + icon: IconSettings, + }, + { + title: "Get Help", + url: "#", + icon: IconHelp, + }, + { + title: "Search", + url: "#", + icon: IconSearch, + }, + ], + documents: [ + { + name: "Data Library", + url: "#", + icon: IconDatabase, + }, + { + name: "Reports", + url: "#", + icon: IconReport, + }, + { + name: "Word Assistant", + url: "#", + icon: IconFileWord, + }, + ], +} + +export function AppSidebar({ ...props }: React.ComponentProps) { + return ( + + + + + + + + Acme Inc. + + + + + + + + + + + + + + + ) +} diff --git a/frontend/components/auth-context.tsx b/frontend/components/auth-context.tsx new file mode 100644 index 0000000..5ffbe7c --- /dev/null +++ b/frontend/components/auth-context.tsx @@ -0,0 +1,205 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { config } from "@/lib/config"; +import { useUser, User } from "@/components/user-context"; + +interface AuthContextType { + loggedIn: boolean; + expiresIn: number | null; + login: ( + username: string, + password?: string, + redirect?: boolean, + login_barcode?: string + ) => Promise; + logout: (redirect?: boolean) => void; + enableAutoRefresh: (enabled: boolean) => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [loggedIn, setLoggedIn] = useState(false); + const [expiresIn, setExpiresIn] = useState(null); + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(false); + const router = useRouter(); + const { setUser } = useUser(); + + // countdown logic + useEffect(() => { + let refreshTimeout: NodeJS.Timeout | null = null; + + async function refresh() { + try { + const res = await fetch(`${config.apiBaseUrl}/auth/refresh`, { + method: "GET", + credentials: "include", + }); + if (!res.ok) { + throw new Error("Failed to refresh session"); + } + const data = await res.json(); + if (data.expire) { + const expTime = new Date(data.expire).getTime().toString(); + localStorage.setItem("session_exp", expTime); + setLoggedIn(true); + } else { + throw new Error("No expiry time in refresh response"); + } + } catch (error) { + console.error("Session refresh failed:", error); + setLoggedIn(false); + setUser(null); + localStorage.removeItem("session_exp"); + if (refreshTimeout) { + clearTimeout(refreshTimeout); + refreshTimeout = null; + } + router.push("/"); + } + } + + function updateCountdown() { + const exp = localStorage.getItem("session_exp"); + if (exp) { + const expTime = parseInt(exp, 10); + const now = Date.now(); + const diff = Math.max(0, Math.floor((expTime - now) / 1000)); + setExpiresIn(diff); + + if (diff <= 0) { + setLoggedIn(false); + setUser(null); + localStorage.removeItem("session_exp"); + if (refreshTimeout) { + clearTimeout(refreshTimeout); + refreshTimeout = null; + } + router.push("/"); + } else { + setLoggedIn(document.cookie.includes("drinks_pos_session=")); + + if (autoRefreshEnabled && diff > 60 && refreshTimeout === null) { + refreshTimeout = setTimeout(() => { + refresh(); + refreshTimeout = null; + }, (diff - 60) * 1000); + } + } + } else { + setExpiresIn(null); + setLoggedIn(false); + if (refreshTimeout) { + clearTimeout(refreshTimeout); + refreshTimeout = null; + } + } + } + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + return () => { + clearInterval(interval); + if (refreshTimeout) clearTimeout(refreshTimeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoRefreshEnabled]); + + const login = async ( + username: string, + password?: string, + redirect: boolean = true, + login_barcode?: string + ): Promise => { + try { + const body: { + username?: string; + password?: string; + barcode?: string; + } = {}; + + if (login_barcode) { + body.barcode = login_barcode; + } else { + body.username = username; + if (password) { + body.password = password; + } + } + + const res = await fetch(`${config.apiBaseUrl}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + credentials: "include", + }); + + const data = await res.json(); + if (!res.ok) { + const error = new Error(data.message || "Login failed"); + error.name = res.status === 401 ? "Unauthorized" : "LoginError"; + throw error; + } + + if (data.expire) { + const expTime = new Date(data.expire).getTime().toString(); + localStorage.setItem("session_exp", expTime); + setLoggedIn(true); + + if (data.user) { + setUser(data.user); + } + + if (redirect) { + router.push("/items"); + } + + return data.user || null; + } else { + throw new Error("No expiry time received from server"); + } + } catch (error) { + setLoggedIn(false); + setUser(null); + localStorage.removeItem("session_exp"); + throw error; + } + }; + + const logout = async (redirect: boolean = true) => { + await fetch(`${config.apiBaseUrl}/auth/logout`, { + method: "POST", + credentials: "include", + }); + document.cookie = + "drinks_pos_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + localStorage.removeItem("session_exp"); + setLoggedIn(false); + setUser(null); + + if (redirect) { + router.push("/"); + } + }; + + const enableAutoRefresh = (enabled: boolean) => { + setAutoRefreshEnabled(enabled); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/frontend/components/barcode-item-listener.tsx b/frontend/components/barcode-item-listener.tsx new file mode 100644 index 0000000..d1aaa60 --- /dev/null +++ b/frontend/components/barcode-item-listener.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import { useSelectedItems } from "./selected-items-context"; +import { Item } from "@/types/item"; + +export function BarcodeItemListener() { + const { addItem } = useSelectedItems(); + + useEffect(() => { + const handleBarcodeItemScanned = (event: Event) => { + const customEvent = event as CustomEvent<{ item: Item }>; + const { item } = customEvent.detail; + if (item) { + addItem(item); + } + }; + + window.addEventListener("barcode-item-scanned", handleBarcodeItemScanned); + + return () => { + window.removeEventListener( + "barcode-item-scanned", + handleBarcodeItemScanned + ); + }; + }, [addItem]); + + return null; +} diff --git a/frontend/components/chart-area-interactive.tsx b/frontend/components/chart-area-interactive.tsx new file mode 100644 index 0000000..5b475ea --- /dev/null +++ b/frontend/components/chart-area-interactive.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" + +import { useIsMobile } from "@/hooks/use-mobile" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group" + +export const description = "An interactive area chart" + +const chartData = [ + { date: "2024-04-01", desktop: 222, mobile: 150 }, + { date: "2024-04-02", desktop: 97, mobile: 180 }, + { date: "2024-04-03", desktop: 167, mobile: 120 }, + { date: "2024-04-04", desktop: 242, mobile: 260 }, + { date: "2024-04-05", desktop: 373, mobile: 290 }, + { date: "2024-04-06", desktop: 301, mobile: 340 }, + { date: "2024-04-07", desktop: 245, mobile: 180 }, + { date: "2024-04-08", desktop: 409, mobile: 320 }, + { date: "2024-04-09", desktop: 59, mobile: 110 }, + { date: "2024-04-10", desktop: 261, mobile: 190 }, + { date: "2024-04-11", desktop: 327, mobile: 350 }, + { date: "2024-04-12", desktop: 292, mobile: 210 }, + { date: "2024-04-13", desktop: 342, mobile: 380 }, + { date: "2024-04-14", desktop: 137, mobile: 220 }, + { date: "2024-04-15", desktop: 120, mobile: 170 }, + { date: "2024-04-16", desktop: 138, mobile: 190 }, + { date: "2024-04-17", desktop: 446, mobile: 360 }, + { date: "2024-04-18", desktop: 364, mobile: 410 }, + { date: "2024-04-19", desktop: 243, mobile: 180 }, + { date: "2024-04-20", desktop: 89, mobile: 150 }, + { date: "2024-04-21", desktop: 137, mobile: 200 }, + { date: "2024-04-22", desktop: 224, mobile: 170 }, + { date: "2024-04-23", desktop: 138, mobile: 230 }, + { date: "2024-04-24", desktop: 387, mobile: 290 }, + { date: "2024-04-25", desktop: 215, mobile: 250 }, + { date: "2024-04-26", desktop: 75, mobile: 130 }, + { date: "2024-04-27", desktop: 383, mobile: 420 }, + { date: "2024-04-28", desktop: 122, mobile: 180 }, + { date: "2024-04-29", desktop: 315, mobile: 240 }, + { date: "2024-04-30", desktop: 454, mobile: 380 }, + { date: "2024-05-01", desktop: 165, mobile: 220 }, + { date: "2024-05-02", desktop: 293, mobile: 310 }, + { date: "2024-05-03", desktop: 247, mobile: 190 }, + { date: "2024-05-04", desktop: 385, mobile: 420 }, + { date: "2024-05-05", desktop: 481, mobile: 390 }, + { date: "2024-05-06", desktop: 498, mobile: 520 }, + { date: "2024-05-07", desktop: 388, mobile: 300 }, + { date: "2024-05-08", desktop: 149, mobile: 210 }, + { date: "2024-05-09", desktop: 227, mobile: 180 }, + { date: "2024-05-10", desktop: 293, mobile: 330 }, + { date: "2024-05-11", desktop: 335, mobile: 270 }, + { date: "2024-05-12", desktop: 197, mobile: 240 }, + { date: "2024-05-13", desktop: 197, mobile: 160 }, + { date: "2024-05-14", desktop: 448, mobile: 490 }, + { date: "2024-05-15", desktop: 473, mobile: 380 }, + { date: "2024-05-16", desktop: 338, mobile: 400 }, + { date: "2024-05-17", desktop: 499, mobile: 420 }, + { date: "2024-05-18", desktop: 315, mobile: 350 }, + { date: "2024-05-19", desktop: 235, mobile: 180 }, + { date: "2024-05-20", desktop: 177, mobile: 230 }, + { date: "2024-05-21", desktop: 82, mobile: 140 }, + { date: "2024-05-22", desktop: 81, mobile: 120 }, + { date: "2024-05-23", desktop: 252, mobile: 290 }, + { date: "2024-05-24", desktop: 294, mobile: 220 }, + { date: "2024-05-25", desktop: 201, mobile: 250 }, + { date: "2024-05-26", desktop: 213, mobile: 170 }, + { date: "2024-05-27", desktop: 420, mobile: 460 }, + { date: "2024-05-28", desktop: 233, mobile: 190 }, + { date: "2024-05-29", desktop: 78, mobile: 130 }, + { date: "2024-05-30", desktop: 340, mobile: 280 }, + { date: "2024-05-31", desktop: 178, mobile: 230 }, + { date: "2024-06-01", desktop: 178, mobile: 200 }, + { date: "2024-06-02", desktop: 470, mobile: 410 }, + { date: "2024-06-03", desktop: 103, mobile: 160 }, + { date: "2024-06-04", desktop: 439, mobile: 380 }, + { date: "2024-06-05", desktop: 88, mobile: 140 }, + { date: "2024-06-06", desktop: 294, mobile: 250 }, + { date: "2024-06-07", desktop: 323, mobile: 370 }, + { date: "2024-06-08", desktop: 385, mobile: 320 }, + { date: "2024-06-09", desktop: 438, mobile: 480 }, + { date: "2024-06-10", desktop: 155, mobile: 200 }, + { date: "2024-06-11", desktop: 92, mobile: 150 }, + { date: "2024-06-12", desktop: 492, mobile: 420 }, + { date: "2024-06-13", desktop: 81, mobile: 130 }, + { date: "2024-06-14", desktop: 426, mobile: 380 }, + { date: "2024-06-15", desktop: 307, mobile: 350 }, + { date: "2024-06-16", desktop: 371, mobile: 310 }, + { date: "2024-06-17", desktop: 475, mobile: 520 }, + { date: "2024-06-18", desktop: 107, mobile: 170 }, + { date: "2024-06-19", desktop: 341, mobile: 290 }, + { date: "2024-06-20", desktop: 408, mobile: 450 }, + { date: "2024-06-21", desktop: 169, mobile: 210 }, + { date: "2024-06-22", desktop: 317, mobile: 270 }, + { date: "2024-06-23", desktop: 480, mobile: 530 }, + { date: "2024-06-24", desktop: 132, mobile: 180 }, + { date: "2024-06-25", desktop: 141, mobile: 190 }, + { date: "2024-06-26", desktop: 434, mobile: 380 }, + { date: "2024-06-27", desktop: 448, mobile: 490 }, + { date: "2024-06-28", desktop: 149, mobile: 200 }, + { date: "2024-06-29", desktop: 103, mobile: 160 }, + { date: "2024-06-30", desktop: 446, mobile: 400 }, +] + +const chartConfig = { + visitors: { + label: "Visitors", + }, + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +export function ChartAreaInteractive() { + const isMobile = useIsMobile() + const [timeRange, setTimeRange] = React.useState("90d") + + React.useEffect(() => { + if (isMobile) { + setTimeRange("7d") + } + }, [isMobile]) + + const filteredData = chartData.filter((item) => { + const date = new Date(item.date) + const referenceDate = new Date("2024-06-30") + let daysToSubtract = 90 + if (timeRange === "30d") { + daysToSubtract = 30 + } else if (timeRange === "7d") { + daysToSubtract = 7 + } + const startDate = new Date(referenceDate) + startDate.setDate(startDate.getDate() - daysToSubtract) + return date >= startDate + }) + + return ( + + + Total Visitors + + + Total for the last 3 months + + Last 3 months + + + + Last 3 months + Last 30 days + Last 7 days + + + + + + + + + + + + + + + + + + + { + const date = new Date(value) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + indicator="dot" + /> + } + /> + + + + + + + ) +} diff --git a/frontend/components/checkout-button.tsx b/frontend/components/checkout-button.tsx new file mode 100644 index 0000000..6c8f2f6 --- /dev/null +++ b/frontend/components/checkout-button.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { PaymentDialog } from "./payment-dialog"; +import { useSelectedItems } from "./selected-items-context"; +import { useUser } from "./user-context"; +import { useSettings } from "./settings-context"; +import React from "react"; + +export default function CheckoutButton() { + const { clearItems, totalInCents } = useSelectedItems(); + const { user } = useUser(); + const { settings } = useSettings(); + + const isCardAvailable = !!settings?.default_reader_id; + const hasEnoughBalance = (user?.balance ?? 0) >= totalInCents; + const isBalanceAvailable = + !user?.is_restricted && (user?.is_trusted || hasEnoughBalance); + + async function handleComplete(result: { method: string; data?: unknown }) { + clearItems(); + console.log(`${result.method} payment completed successfully`); + } + return ( +
+ + + + + +
+
+

Checkout

+

+ How would you like to pay? +

+
+
+ + Cash + + } + onComplete={handleComplete} + /> + + Card + + } + onComplete={handleComplete} + /> + + Balance + + } + onComplete={handleComplete} + /> +
+
+
+
+
+ ); +} diff --git a/frontend/components/data-table.tsx b/frontend/components/data-table.tsx new file mode 100644 index 0000000..53c9022 --- /dev/null +++ b/frontend/components/data-table.tsx @@ -0,0 +1,801 @@ +"use client"; + +import * as React from "react"; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, + IconCircleCheckFilled, + IconDotsVertical, + IconGripVertical, + IconLayoutColumns, + IconLoader, + IconPlus, + IconTrendingUp, +} from "@tabler/icons-react"; +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + Row, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table"; +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export const schema = z.object({ + id: z.number(), + header: z.string(), + type: z.string(), + status: z.string(), + target: z.string(), + limit: z.string(), + reviewer: z.string(), +}); + +function DragHandle({ id }: { id: number }) { + const { attributes, listeners } = useSortable({ + id, + }); + + return ( + + ); +} + +const columns: ColumnDef>[] = [ + { + id: "drag", + header: () => null, + cell: ({ row }) => , + }, + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "header", + header: "Header", + cell: ({ row }) => { + return ; + }, + enableHiding: false, + }, + { + accessorKey: "type", + header: "Section Type", + cell: ({ row }) => ( +
+ + {row.original.type} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "Done" ? ( + + ) : ( + + )} + {row.original.status} + + ), + }, + { + accessorKey: "target", + header: () =>
Target
, + cell: ({ row }) => ( +
{ + e.preventDefault(); + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }); + }} + > + + +
+ ), + }, + { + accessorKey: "limit", + header: () =>
Limit
, + cell: ({ row }) => ( +
{ + e.preventDefault(); + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }); + }} + > + + +
+ ), + }, + { + accessorKey: "reviewer", + header: "Reviewer", + cell: ({ row }) => { + const isAssigned = row.original.reviewer !== "Assign reviewer"; + + if (isAssigned) { + return row.original.reviewer; + } + + return ( + <> + + + + ); + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +]; + +function DraggableRow({ row }: { row: Row> }) { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }); + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); +} + +export function DataTable({ + data: initialData, +}: { + data: z.infer[]; +}) { + const [data, setData] = React.useState(() => initialData); + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + const sortableId = React.useId(); + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + const dataIds = React.useMemo( + () => data?.map(({ id }) => id) || [], + [data] + ); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + getRowId: (row) => row.id.toString(), + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (active && over && active.id !== over.id) { + setData((data) => { + const oldIndex = dataIds.indexOf(active.id); + const newIndex = dataIds.indexOf(over.id); + return arrayMove(data, oldIndex, newIndex); + }); + } + } + + return ( + +
+ + + + Outline + + Past Performance 3 + + + Key Personnel 2 + + Focus Documents + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ ); +} + +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig; + +function TableCellViewer({ item }: { item: z.infer }) { + const isMobile = useIsMobile(); + + return ( + + + + + + + {item.header} + + Showing total visitors for the last 6 months + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Trending up by 5.2% this month{" "} + +
+
+ Showing total visitors for the last 6 months. This is just + some random text to test the layout. It spans multiple lines + and should wrap around. +
+
+ + + )} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
+
+ ); +} diff --git a/frontend/components/delete-reader-dialog.tsx b/frontend/components/delete-reader-dialog.tsx new file mode 100644 index 0000000..fefbdb3 --- /dev/null +++ b/frontend/components/delete-reader-dialog.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Reader } from "@/types/reader"; +import { Spinner } from "./ui/spinner"; + +interface DeleteReaderDialogProps { + reader: Reader; + isDefault: boolean; + open?: boolean; + setOpen?: (open: boolean) => void; + onDelete: (id: string) => Promise; +} + +export function DeleteReaderDialog({ + reader, + isDefault, + open, + setOpen, + onDelete, +}: DeleteReaderDialogProps) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + if (isDeleting) return; + + setIsDeleting(true); + try { + await onDelete(reader.id); + setOpen?.(false); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + Delete Reader + + Are you sure you want to delete {reader.name}? This + action cannot be undone and will unlink the card reader from the + system. + {isDefault && ( + + This is currently the default reader. Deleting it will clear the + default reader setting. + + )} + + + + Cancel + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting && } + {isDeleting ? "Deleting..." : "Delete"} + + + + + ); +} diff --git a/frontend/components/footer.tsx b/frontend/components/footer.tsx new file mode 100644 index 0000000..f79cd1a --- /dev/null +++ b/frontend/components/footer.tsx @@ -0,0 +1,87 @@ +"use client"; + +import * as React from "react"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +function ProgressSteps() { + const [currentStep, setCurrentStep] = React.useState(1); + const totalSteps = 4; + const progress = (currentStep / totalSteps) * 100; + + const steps = ["User selection", "Item selection", "Review", "Payment"]; + + const nextStep = () => { + if (currentStep < totalSteps) { + setCurrentStep(currentStep + 1); + } + }; + + const prevStep = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + return ( +
+
+
+
+ + Step {currentStep} of {totalSteps} + + + {steps[currentStep - 1]} + +
+ +
+ +
+ + +
+
+
+ ); +} + +export default function Footer() { + return ( +
+ +
+ ); +} diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx new file mode 100644 index 0000000..eab2946 --- /dev/null +++ b/frontend/components/header.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { ModeToggle } from "@/components/ui/theme-mode-toggle"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, +} from "@/components/ui/navigation-menu"; +import { useAuth } from "@/components/auth-context"; +import { useUser } from "@/components/user-context"; +import { useSettings } from "@/components/settings-context"; +import { Button } from "@/components/ui/button"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { MoreDropdownMenu } from "./more-dropdown"; +import { cn } from "@/lib/utils"; + +type HeaderProps = { + showSidebarTrigger?: boolean; +}; + +export default function Header({ showSidebarTrigger = false }: HeaderProps) { + const { loggedIn, expiresIn, logout } = useAuth(); + const { user } = useUser(); + const { maintenanceMode } = useSettings(); + const pathname = usePathname(); + + const isActive = (path: string) => { + if (path === "/" || path === "/admin") { + return pathname === path; + } + return pathname.startsWith(path); + }; + + return ( +
+
+ {maintenanceMode && !user?.is_admin ? ( + + ) : ( + <> + {showSidebarTrigger && } + + {loggedIn && user?.is_admin ? ( + + + + + Home + + + + + + + Items + + + + + + + Users + + + + + + + Purchases + + + + + + + Readers + + + + + ) : ( + + + + + Home + + + + + + + Items + + + + + )} + + {loggedIn && ( + <> + + + + )} + + + )} +
+ {loggedIn && expiresIn !== null && ( +
+ + Session expires in: {Math.floor(expiresIn / 60)}: + {(expiresIn % 60).toString().padStart(2, "0")} + +
+ )} +
+ ); +} diff --git a/frontend/components/item-admin-dialog.tsx b/frontend/components/item-admin-dialog.tsx new file mode 100644 index 0000000..5c518a2 --- /dev/null +++ b/frontend/components/item-admin-dialog.tsx @@ -0,0 +1,581 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + PackageIcon, + ImageIcon, + DollarSignIcon, + BarcodeIcon, + PlusIcon, + XIcon, + BeakerIcon, + ShieldIcon, + TrashIcon, +} from "lucide-react"; +import { config } from "@/lib/config"; +import { Item } from "@/types/item"; +import { isValid } from "gtin"; + +export default function ItemDialog({ + open, + setOpen, + item, + onSuccess, +}: { + open: boolean; + setOpen: (open: boolean) => void; + item?: Item | null; + onSuccess?: () => void; +}) { + const [name, setName] = React.useState(""); + const [variant, setVariant] = React.useState(""); + const [image, setImage] = React.useState(""); + const [volume, setVolume] = React.useState(""); + const [price, setPrice] = React.useState(""); + const [isActive, setIsActive] = React.useState(true); + const [barcodes, setBarcodes] = React.useState([""]); + const [barcodeErrors, setBarcodeErrors] = React.useState<(string | null)[]>([ + null, + ]); + const [nutritionInfo, setNutritionInfo] = React.useState< + Array<{ name: string; value: string }> + >([{ name: "", value: "" }]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false); + + const isEditMode = !!item?.id; + + const validateBarcodeChecksum = (barcode: string): string | null => { + const cleanBarcode = barcode.trim(); + + if (cleanBarcode === "") { + return null; // Empty is valid (optional field) + } + + if (!/^\d+$/.test(cleanBarcode)) { + return "Barcode must contain only digits"; + } + + if (cleanBarcode.length !== 8 && cleanBarcode.length !== 13) { + return "Barcode must be 8 or 13 digits (EAN-8 or EAN-13)"; + } + + if (!isValid(cleanBarcode)) { + return `Invalid checksum`; + } + + return null; // Valid + }; + + React.useEffect(() => { + if (item) { + setName(item.name || ""); + setVariant(item.variant || ""); + setImage(item.image || ""); + setVolume(item.volume?.toString() || ""); + setPrice(item.price?.toString() || ""); + setIsActive(item.is_active ?? true); + setBarcodes( + item.barcodes && item.barcodes.length > 0 ? item.barcodes : [""] + ); + setBarcodeErrors( + item.barcodes && item.barcodes.length > 0 + ? item.barcodes.map(() => null) + : [null] + ); + setNutritionInfo( + item.nutrition_info && item.nutrition_info.length > 0 + ? item.nutrition_info + : [{ name: "", value: "" }] + ); + } else { + setName(""); + setVariant(""); + setImage(""); + setVolume(""); + setPrice(""); + setIsActive(true); + setBarcodes([""]); + setBarcodeErrors([null]); + setNutritionInfo([{ name: "", value: "" }]); + } + setError(""); + }, [item, open]); + + const handleAddBarcode = () => { + setBarcodes([...barcodes, ""]); + setBarcodeErrors([...barcodeErrors, null]); + }; + + const handleRemoveBarcode = (index: number) => { + if (barcodes.length > 1) { + setBarcodes(barcodes.filter((_, i) => i !== index)); + setBarcodeErrors(barcodeErrors.filter((_, i) => i !== index)); + } + }; + + const handleBarcodeChange = (index: number, value: string) => { + const newBarcodes = [...barcodes]; + newBarcodes[index] = value; + setBarcodes(newBarcodes); + + const newErrors = [...barcodeErrors]; + newErrors[index] = validateBarcodeChecksum(value); + setBarcodeErrors(newErrors); + }; + + const handleAddNutrition = () => { + setNutritionInfo([...nutritionInfo, { name: "", value: "" }]); + }; + + const handleRemoveNutrition = (index: number) => { + if (nutritionInfo.length > 1) { + setNutritionInfo(nutritionInfo.filter((_, i) => i !== index)); + } + }; + + const handleNutritionChange = ( + index: number, + field: "name" | "value", + value: string + ) => { + const newNutritionInfo = [...nutritionInfo]; + newNutritionInfo[index][field] = value; + setNutritionInfo(newNutritionInfo); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + setError(""); + + try { + const filteredBarcodes = barcodes + .map((b) => b.trim()) + .filter((b) => b.length > 0); + + const invalidBarcodes = filteredBarcodes.filter( + (barcode) => validateBarcodeChecksum(barcode) !== null + ); + + if (invalidBarcodes.length > 0) { + throw new Error( + "Please fix invalid barcodes before submitting. Check the error messages below each barcode field." + ); + } + + // Filter out empty nutrition info entries + const filteredNutritionInfo = nutritionInfo + .filter((info) => info.name.trim() && info.value.trim()) + .map((info) => ({ + name: info.name.trim(), + value: info.value.trim(), + })); + + const itemData = { + name, + variant: variant || undefined, + image: image || undefined, + volume: parseInt(volume, 10), + price: parseInt(price, 10), + is_active: isActive, + barcodes: filteredBarcodes.length > 0 ? filteredBarcodes : undefined, + nutrition_info: + filteredNutritionInfo.length > 0 ? filteredNutritionInfo : undefined, + }; + + const url = isEditMode + ? `${config.apiBaseUrl}/api/v1/items/${item.id}` + : `${config.apiBaseUrl}/api/v1/items`; + + const method = isEditMode ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(itemData), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error( + data.error || `Failed to ${isEditMode ? "update" : "create"} item` + ); + } + + onSuccess?.(); + setOpen(false); + + setName(""); + setVariant(""); + setImage(""); + setVolume(""); + setPrice(""); + setIsActive(true); + setBarcodes([""]); + setBarcodeErrors([null]); + setNutritionInfo([{ name: "", value: "" }]); + } catch (err) { + console.error("Item operation error:", err); + setError( + err instanceof Error + ? err.message + : "An error occurred. Please try again." + ); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!item?.id) return; + + setLoading(true); + setError(""); + + try { + const res = await fetch(`${config.apiBaseUrl}/api/v1/items/${item.id}`, { + method: "DELETE", + credentials: "include", + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to delete item"); + } + + onSuccess?.(); + setOpen(false); + setShowDeleteDialog(false); + } catch (err) { + console.error("Item delete error:", err); + setError( + err instanceof Error + ? err.message + : "An error occurred. Please try again." + ); + } finally { + setLoading(false); + } + }; + + return ( + + +
+ + + {isEditMode ? "Edit Item" : "Create New Item"} + + + {isEditMode + ? "Update the item details below." + : "Fill in the details to create a new item."} + + +
+
+
+ setIsActive(checked === true)} + disabled={loading} + /> +
+ + +
+
+
+ +
+ +
+ + setName(e.target.value)} + disabled={loading} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setVariant(e.target.value)} + disabled={loading} + className="pl-10" + /> +
+
+ +
+ +
+ + setVolume(e.target.value)} + disabled={loading} + className="pl-10" + min="0" + required + /> +
+
+ +
+ +
+ + setPrice(e.target.value)} + disabled={loading} + className="pl-10" + min="0" + required + /> +
+
+ +
+ +
+ + setImage(e.target.value)} + disabled={loading} + className="pl-10" + /> +
+
+ +
+
+ + +
+ {barcodes.map((barcode, index) => ( +
+
+
+ + + handleBarcodeChange(index, e.target.value) + } + disabled={loading} + className={`pl-10 ${ + barcodeErrors[index] ? "border-red-500" : "" + }`} + /> +
+ {barcodes.length > 1 && ( + + )} +
+ {barcodeErrors[index] && ( +

+ {barcodeErrors[index]} +

+ )} +
+ ))} +

+ Add one or more barcodes for this item +

+
+ +
+
+ + +
+ {nutritionInfo.map((info, index) => ( +
+ + handleNutritionChange(index, "name", e.target.value) + } + disabled={loading} + className="flex-1" + /> + + handleNutritionChange(index, "value", e.target.value) + } + disabled={loading} + className="flex-1" + /> + {nutritionInfo.length > 1 && ( + + )} +
+ ))} +

+ Add nutrition information for this item +

+
+
+ {error &&
{error}
} + + {isEditMode && ( + + )} +
+ + + + +
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Item + + Are you sure you want to delete this item? This action cannot be + undone. + + + {error &&
{error}
} + + + + + + +
+
+
+ ); +} diff --git a/frontend/components/item-cards.tsx b/frontend/components/item-cards.tsx new file mode 100644 index 0000000..6752c24 --- /dev/null +++ b/frontend/components/item-cards.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import Image from "next/image"; +import { Item } from "@/types/item"; + +interface ItemCardsProps { + items: Item[]; + onItemClick?: (item: Item) => void; +} + +export default function ItemCards({ items, onItemClick }: ItemCardsProps) { + const handleClick = (item: Item) => { + onItemClick?.(item); + }; + + if (items.length === 0) { + return ( +
+ No results found. +
+ ); + } + + return ( +
+ {items.map((item) => { + const isInactive = + item.is_active === false || + item.is_active === null || + item.is_active === undefined; + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/frontend/components/items-sidebar.tsx b/frontend/components/items-sidebar.tsx new file mode 100644 index 0000000..4e301c0 --- /dev/null +++ b/frontend/components/items-sidebar.tsx @@ -0,0 +1,28 @@ +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, +} from "@/components/ui/sidebar"; +import Header from "@/components/header"; +import { SelectedItemsList } from "@/components/selected-items-list"; +import { SidebarFooterContent } from "@/components/sidebar-footer-content"; + +export function ItemsSidebar() { + return ( + + +
+ + + + + + + + + + + ); +} diff --git a/frontend/components/link-reader-dialog.tsx b/frontend/components/link-reader-dialog.tsx new file mode 100644 index 0000000..f9f7f19 --- /dev/null +++ b/frontend/components/link-reader-dialog.tsx @@ -0,0 +1,199 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { CreditCard, Loader2 } from "lucide-react"; +import { config } from "@/lib/config"; +import { toast } from "sonner"; + +interface LinkReaderDialogProps { + open: boolean; + setOpen: (open: boolean) => void; + onSuccess?: () => void; +} + +export default function LinkReaderDialog({ + open, + setOpen, + onSuccess, +}: LinkReaderDialogProps) { + const [name, setName] = React.useState(""); + const [pairingCode, setPairingCode] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); + + React.useEffect(() => { + if (open) { + setName(""); + setPairingCode(""); + setError(""); + } + }, [open]); + + const handlePairingCodeChange = (value: string) => { + // Filter to only alphanumeric characters and convert to uppercase + const filtered = value + .split("") + .filter((char) => /[0-9A-Za-z]/.test(char)) + .join("") + .toUpperCase(); + setPairingCode(filtered); + setError(""); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!name.trim()) { + setError("Reader name is required"); + return; + } + + if (pairingCode.length !== 8 && pairingCode.length !== 9) { + setError("Pairing code must be 8 or 9 characters"); + return; + } + + setLoading(true); + setError(""); + + try { + const res = await fetch( + `${config.apiBaseUrl}/api/payment/v1/readers/link`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + name: name.trim(), + pairing_code: pairingCode, + }), + } + ); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to link reader"); + } + + toast.success("Reader linked successfully!"); + onSuccess?.(); + setOpen(false); + setName(""); + setPairingCode(""); + } catch (err) { + console.error("Reader linking error:", err); + const errorMessage = + err instanceof Error + ? err.message + : "An error occurred. Please try again."; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + +
+ + + + Link Card Reader + + + Give your reader a name and enter the pairing code displayed on + the device. + + + +
+
+ + setName(e.target.value)} + disabled={loading} + autoFocus + /> +
+ +
+ + + + + + + + + + + + + + +

+ The pairing code is shown on the card reader screen +

+
+ + {error && ( +
{error}
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/components/maintenance-guard.tsx b/frontend/components/maintenance-guard.tsx new file mode 100644 index 0000000..c54848a --- /dev/null +++ b/frontend/components/maintenance-guard.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useSettings } from "@/components/settings-context"; +import { useUser } from "@/components/user-context"; + +export function MaintenanceGuard({ children }: { children: React.ReactNode }) { + const { maintenanceMode } = useSettings(); + const { user } = useUser(); + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + if (maintenanceMode) { + const isAdmin = user?.is_admin; + + if (pathname === "/maintenance" || pathname === "/admin") { + return; + } + + if (!isAdmin) { + router.push("/maintenance"); + } + } else { + if (pathname === "/maintenance") { + router.push("/"); + } + } + }, [maintenanceMode, user, pathname, router]); + + if (maintenanceMode && pathname === "/admin") { + return <>{children}; + } + + if (maintenanceMode && !user?.is_admin && pathname !== "/maintenance") { + return null; + } + + if (!maintenanceMode && pathname === "/maintenance") { + return null; + } + + return <>{children}; +} diff --git a/frontend/components/more-dropdown.tsx b/frontend/components/more-dropdown.tsx new file mode 100644 index 0000000..c359531 --- /dev/null +++ b/frontend/components/more-dropdown.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontalIcon, Wallet, Settings } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { AddBalanceDialog } from "./add-balance-dialog"; +import SettingsDialog from "./settings-dialog"; + +export function MoreDropdownMenu() { + const [showAddBalance, setShowAddBalance] = useState(false); + const [showSettings, setShowSettings] = useState(false); + + return ( + <> + + + + + + Menu + + + setShowAddBalance(true)}> + + Add Balance + + setShowSettings(true)}> + + Settings + + + + + + { + setShowAddBalance(false); + }} + /> + + + + ); +} diff --git a/frontend/components/nav-documents.tsx b/frontend/components/nav-documents.tsx new file mode 100644 index 0000000..b551e71 --- /dev/null +++ b/frontend/components/nav-documents.tsx @@ -0,0 +1,92 @@ +"use client" + +import { + IconDots, + IconFolder, + IconShare3, + IconTrash, + type Icon, +} from "@tabler/icons-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavDocuments({ + items, +}: { + items: { + name: string + url: string + icon: Icon + }[] +}) { + const { isMobile } = useSidebar() + + return ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ) +} diff --git a/frontend/components/nav-main.tsx b/frontend/components/nav-main.tsx new file mode 100644 index 0000000..3759bb8 --- /dev/null +++ b/frontend/components/nav-main.tsx @@ -0,0 +1,58 @@ +"use client" + +import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react" + +import { Button } from "@/components/ui/button" +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: Icon + }[] +}) { + return ( + + + + + + + Quick Create + + + + + + {items.map((item) => ( + + + {item.icon && } + {item.title} + + + ))} + + + + ) +} diff --git a/frontend/components/nav-secondary.tsx b/frontend/components/nav-secondary.tsx new file mode 100644 index 0000000..3f3636f --- /dev/null +++ b/frontend/components/nav-secondary.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { type Icon } from "@tabler/icons-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string + url: string + icon: Icon + }[] +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} diff --git a/frontend/components/nav-user.tsx b/frontend/components/nav-user.tsx new file mode 100644 index 0000000..7c49dc7 --- /dev/null +++ b/frontend/components/nav-user.tsx @@ -0,0 +1,110 @@ +"use client" + +import { + IconCreditCard, + IconDotsVertical, + IconLogout, + IconNotification, + IconUserCircle, +} from "@tabler/icons-react" + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + CN + +
+ {user.name} + + {user.email} + +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + + {user.email} + +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/frontend/components/password-dialog.tsx b/frontend/components/password-dialog.tsx new file mode 100644 index 0000000..0df1269 --- /dev/null +++ b/frontend/components/password-dialog.tsx @@ -0,0 +1,168 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { LockIcon, UserIcon } from "lucide-react"; +import { useAuth } from "./auth-context"; +import { User } from "./user-context"; + +export default function PasswordDialog({ + open, + setOpen, + username, + description, + disableClose = false, + redirect = true, + onSuccess, + onValidate, + passwordInputMode = "text", +}: { + open: boolean; + setOpen: (open: boolean) => void; + username: string; + description?: string; + disableClose?: boolean; + redirect?: boolean; + onSuccess?: () => void; + onValidate?: ( + user: User | null + ) => Promise<{ isValid: boolean; error?: string }>; + passwordInputMode?: + | "text" + | "numeric" + | "decimal" + | "tel" + | "search" + | "email" + | "url" + | "none"; +}) { + const [localUsername, setLocalUsername] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); + + React.useEffect(() => { + if (username) { + setLocalUsername(username); + } else { + setLocalUsername(""); + } + }, [username, open]); + + const { login } = useAuth(); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + // Prevent double submission + if (loading) { + return; + } + + setLoading(true); + setError(""); + try { + const userData = await login(localUsername, password, redirect); + + if (onValidate) { + const validation = await onValidate(userData); + if (!validation.isValid) { + setError(validation.error || "Validation failed"); + return; + } + } + + onSuccess?.(); + setOpen(false); + setPassword(""); + } catch (err) { + console.error("Login error:", err); + setError( + err instanceof Error ? err.message : "Network error. Please try again." + ); + } finally { + setLoading(false); + } + }; + + return ( + + disableClose && e.preventDefault()} + onPointerDownOutside={(e) => disableClose && e.preventDefault()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.stopPropagation(); + } + }} + showCloseButton={!disableClose} + > +
+ + Authentication required + + {description ?? "This page has asked you to log in."} + + +
+
+ +
+ + setLocalUsername(e.target.value)} + disabled={!!username} + className="pl-10" + /> +
+
+
+ +
+ + setPassword(e.target.value)} + disabled={loading} + inputMode={passwordInputMode} + /> +
+
+
+ {error &&
{error}
} + + + + + + +
+
+
+ ); +} diff --git a/frontend/components/payment-dialog.tsx b/frontend/components/payment-dialog.tsx new file mode 100644 index 0000000..569f65e --- /dev/null +++ b/frontend/components/payment-dialog.tsx @@ -0,0 +1,627 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useSelectedItems } from "@/components/selected-items-context"; +import { Spinner } from "./ui/spinner"; +import { useUser } from "./user-context"; +import { useSSE } from "./sse-context"; +import { useAuth } from "./auth-context"; +import { config } from "@/lib/config"; +import { useSettings } from "./settings-context"; + +type KnownMethod = "cash" | "card" | "balance"; + +interface PaymentDialogProps { + method?: string; // cash/card/balance + trigger?: React.ReactNode; + amountInCents?: number; // custom amount for balance top-up + onComplete?: (result: { method: string; data?: unknown }) => void; +} + +interface PaymentFormProps { + onComplete?: (d: unknown) => void; + amountInCents?: number; // custom amount for balance top-up +} + +function usePaymentCompletion( + initialCompleted = false, + onComplete?: () => void +) { + const [isCompleted, setIsCompleted] = useState(initialCompleted); + const [countdown, setCountdown] = useState(5); + const onCompleteRef = useRef(onComplete); + + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + + useEffect(() => { + if (isCompleted && countdown > 0) { + const timer = setTimeout(() => { + setCountdown(countdown - 1); + }, 1000); + return () => clearTimeout(timer); + } else if (isCompleted && countdown === 0) { + onCompleteRef.current?.(); + const closeButton = document.querySelector( + "[data-dialog-close]" + ) as HTMLButtonElement; + if (closeButton) { + closeButton.click(); + } + } + }, [isCompleted, countdown]); + + return { isCompleted, setIsCompleted, countdown }; +} + +function PaymentCompletedState({ + method, + countdown, + onClose, +}: { + method: string; + countdown: number; + onClose?: () => void; +}) { + const { logout } = useAuth(); + + const handleClose = () => { + onClose?.(); + logout(); + }; + + return ( +
+ + Payment completed + + Your {method} payment has been processed successfully. + + +
+
+ ✅ Payment Completed +
+
+ + + + + +
+ ); +} + +function createPurchasePayload( + selectedItems: { id: string; quantity: number }[], + paymentType: string, + additionalData?: Record +) { + return { + items: selectedItems.map((item) => ({ + id: item.id, + amount: item.quantity, + })), + payment_type: paymentType, + ...additionalData, + }; +} + +function CashForm({ onComplete, amountInCents }: PaymentFormProps) { + const { selectedItems } = useSelectedItems(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [paymentResult, setPaymentResult] = useState(null); + + const { isCompleted, setIsCompleted, countdown } = usePaymentCompletion( + false, + () => { + if (paymentResult) { + onComplete?.(paymentResult); + } + } + ); + + const isBalanceTopUp = amountInCents !== undefined; + const totalInCents = isBalanceTopUp + ? amountInCents + : selectedItems.reduce((sum, item) => sum + item.price * item.quantity, 0); + const totalInEuros = (totalInCents / 100).toFixed(2); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + let payload; + if (isBalanceTopUp) { + payload = { + amount: amountInCents, + payment_type: "cash", + }; + } else { + payload = createPurchasePayload(selectedItems, "cash"); + } + + const response = await fetch(`${config.apiBaseUrl}/api/v1/purchases`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to create purchase: ${response.statusText}`); + } + + const result = await response.json(); + setPaymentResult({ method: "cash", data: result }); + setIsCompleted(true); + } catch (error) { + console.error("Error creating cash purchase:", error); + } finally { + setIsSubmitting(false); + } + }; + + if (isCompleted) { + return ; + } + + return ( +
+ + Cash payment + + {isBalanceTopUp + ? `Please put €${totalInEuros} into the register.` + : `Please put the cash (€${totalInEuros}) into the register.`} + + + + + + + + +
+ ); +} + +function CardForm({ onComplete, amountInCents }: PaymentFormProps) { + const { selectedItems } = useSelectedItems(); + const { settings } = useSettings(); + const { isConnected, lastEvent } = useSSE(); + const [isProcessing, setIsProcessing] = useState(false); + const [clientTransactionId, setClientTransactionId] = useState( + null + ); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [paymentResult, setPaymentResult] = useState(null); + + const { isCompleted, setIsCompleted, countdown } = usePaymentCompletion( + false, + () => { + if (paymentResult) { + onComplete?.(paymentResult); + } + } + ); + + const defaultReaderId = settings?.default_reader_id; + + useEffect(() => { + if (!defaultReaderId) { + setError("No card reader configured. Please contact an administrator."); + } + }, [defaultReaderId]); + + // automatically start payment when dialog opens + useEffect(() => { + if ( + defaultReaderId && + isConnected && + status === "idle" && + !isProcessing && + !error + ) { + startPayment(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultReaderId, isConnected]); + + useEffect(() => { + if (lastEvent?.type === "transaction_update" && clientTransactionId) { + const data = lastEvent.data.transaction_payload; + if (!data) return; + const { client_transaction_id, transaction_status } = data; + + if (client_transaction_id === clientTransactionId) { + setStatus(transaction_status); + + if (transaction_status === "successful") { + setPaymentResult({ status: "successful", client_transaction_id }); + setIsCompleted(true); + } else if ( + transaction_status === "failed" || + transaction_status === "cancelled" + ) { + setIsProcessing(false); + setError(`Payment ${transaction_status}`); + } + } + } + }, [lastEvent, clientTransactionId, setIsCompleted]); + + const startPayment = async () => { + if (!defaultReaderId) { + setError("No card reader configured. Please contact an administrator."); + return; + } + + if (!isConnected) { + setError("Payment system not connected. Please try again."); + return; + } + + setIsProcessing(true); + setError(null); + setStatus("starting"); + + try { + let purchaseData; + const isBalanceTopUp = amountInCents !== undefined; + + if (isBalanceTopUp) { + purchaseData = { + amount: amountInCents, + payment_type: "card", + reader_id: defaultReaderId, + }; + } else { + purchaseData = createPurchasePayload(selectedItems, "card", { + reader_id: defaultReaderId, + }); + } + + const response = await fetch(`${config.apiBaseUrl}/api/v1/purchases`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(purchaseData), + }); + + if (!response.ok) { + throw new Error(`Failed to create purchase: ${response.statusText}`); + } + + const result = await response.json(); + const transactionId = result.data?.client_transaction_id; + + if (!transactionId) { + throw new Error("No client_transaction_id received from server"); + } + + setClientTransactionId(transactionId); + setStatus("pending"); + } catch (err) { + setIsProcessing(false); + setError(err instanceof Error ? err.message : "Payment failed"); + } + }; + + const terminatePayment = async () => { + try { + await fetch( + `${config.apiBaseUrl}/api/payment/v1/readers/terminate/${defaultReaderId}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + credentials: "include", + } + ); + } catch (err) { + console.error("Failed to terminate payment:", err); + } + }; + + const getStatusMessage = () => { + const messages = { + starting: "Initializing payment...", + pending: "Please complete payment on the card reader", + successful: "Payment successful!", + failed: "Payment failed", + cancelled: "Payment cancelled", + }; + return ( + messages[status as keyof typeof messages] || "Ready to start payment" + ); + }; + + if (isCompleted) { + return ; + } + + return ( +
{ + e.preventDefault(); + if (!isProcessing && status === "idle") { + startPayment(); + } + }} + > + + Card payment + + {!isConnected + ? "Payment system not connected. Please wait or try again later." + : "Complete your payment using the card reader."} + + + +
+ {(isProcessing || status === "pending") && } + +
+ {error ? ( + {error} + ) : ( + getStatusMessage() + )} +
+ + {!isConnected && ( +
+ ⚠️ Payment system disconnected +
+ )} +
+ + + + + + + {!isProcessing && status === "idle" && ( + + )} + + {error && defaultReaderId && ( + + )} + +
+ ); +} + +function BalanceForm({ onComplete }: PaymentFormProps) { + const { selectedItems, totalInEuros } = useSelectedItems(); + const { user } = useUser(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [paymentResult, setPaymentResult] = useState(null); + + const { isCompleted, setIsCompleted, countdown } = usePaymentCompletion( + false, + () => { + if (paymentResult) { + onComplete?.(paymentResult); + } + } + ); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + try { + const payload = createPurchasePayload(selectedItems, "balance"); + const response = await fetch(`${config.apiBaseUrl}/api/v1/purchases`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `Payment failed: ${response.statusText}`; + + try { + const errorData = JSON.parse(errorText); + if (errorData.message) { + errorMessage = errorData.message; + } else if (errorData.error) { + errorMessage = errorData.error; + } + } catch {} + + throw new Error(errorMessage); + } + + const result = await response.json(); + setPaymentResult({ method: "balance", data: result }); + setIsCompleted(true); + } catch (error) { + console.error("Error creating balance purchase:", error); + setError(error instanceof Error ? error.message : "Payment failed"); + } finally { + setIsSubmitting(false); + } + }; + + if (isCompleted) { + return ; + } + + return ( +
+ + Balance payment + + Do you want to use €{totalInEuros} from your balance? +
+ You currently have{" "} + {user ? `€${(user.balance / 100).toFixed(2)}` : "N/A"} in your + balance. +
+
+ + {error && ( +
+
{error}
+
+ )} + + + + + + + {error ? ( + + ) : ( + + )} + +
+ ); +} + +export function PaymentDialog({ + method, + trigger, + amountInCents, + onComplete, +}: PaymentDialogProps) { + const { settings } = useSettings(); + const [selected, setSelected] = useState( + (method as KnownMethod) ?? undefined + ); + + function normalizeMethod(m?: string): KnownMethod | undefined { + if (!m) return undefined; + const s = m.toLowerCase(); + if (s === "cash" || s === "card" || s === "balance") + return s as KnownMethod; + return undefined; + } + + React.useEffect(() => { + const m = normalizeMethod(method); + setSelected(m); + }, [method]); + + const isCardAvailable = !!settings?.default_reader_id; + const isBalanceTopUp = amountInCents !== undefined; + + return ( + + {trigger ? {trigger} : null} + + + {!selected ? ( +
+ + Select payment method + + Choose how to accept payment. + + +
+ + + {!isBalanceTopUp && ( + + )} +
+ {!isCardAvailable && ( +
+ Card payments are unavailable. Please configure a default card + reader in the admin settings. +
+ )} + + + + + +
+ ) : ( +
+ {selected === "cash" && ( + onComplete?.({ method: "cash", data })} + /> + )} + {selected === "card" && ( + onComplete?.({ method: "card", data })} + /> + )} + {selected === "balance" && ( + onComplete?.({ method: "balance", data })} + /> + )} +
+ )} +
+
+ ); +} diff --git a/frontend/components/root-layout-client.tsx b/frontend/components/root-layout-client.tsx new file mode 100644 index 0000000..8fc1d9d --- /dev/null +++ b/frontend/components/root-layout-client.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { ThemeProvider } from "@/components/theme-provider"; +import Header from "@/components/header"; +import { AuthProvider } from "@/components/auth-context"; +import { UserProvider } from "@/components/user-context"; +import { SSEProvider } from "@/components/sse-context"; +import SSEConnectionStatus from "@/components/sse-connection-status"; +import { Toaster } from "@/components/ui/sonner"; +import { ThemeColorMeta } from "@/components/theme-color-meta"; +import { SettingsProvider } from "@/components/settings-context"; +import { MaintenanceGuard } from "@/components/maintenance-guard"; + +export default function RootLayoutClient({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const pathname = usePathname(); + const isMaintenancePage = pathname === "/maintenance"; + + return ( + + + + + + + +
+
+
{children}
+
+
+ {!isMaintenancePage && } + +
+
+
+
+
+ ); +} diff --git a/frontend/components/search-barcode-input.tsx b/frontend/components/search-barcode-input.tsx new file mode 100644 index 0000000..2897cbf --- /dev/null +++ b/frontend/components/search-barcode-input.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { SearchIcon } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { useAuth } from "./auth-context"; +import { toast } from "sonner"; +import { useSelectedItems } from "./selected-items-context"; +import { config } from "@/lib/config"; + +interface Item { + id: string; + name: string; + price: number; + barcodes?: string[]; +} + +interface SearchInputProps { + items?: Item[]; + visible?: boolean; +} + +export function BarcodeSearchInput({ + items = [], + visible = true, +}: SearchInputProps) { + const [value, setValue] = useState(""); + const valueRef = useRef(""); + const timerRef = useRef | null>(null); + const processedBarcodeRef = useRef(false); + + const resetTimer = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + setValue(""); + valueRef.current = ""; + timerRef.current = null; + }, 5000); // 5 seconds + }; + + const { loggedIn, login, logout } = useAuth(); + const selectedItemsRef = useRef>([]); + const clearItemsRef = useRef<(() => void) | undefined>(undefined); + + // safely access selectedItems context - not available in all contexts + try { + const context = useSelectedItems(); + selectedItemsRef.current = context.selectedItems; + clearItemsRef.current = context.clearItems; + } catch { + selectedItemsRef.current = []; + clearItemsRef.current = undefined; + } + + const handleBarcodeScan = async (barcode: string) => { + const isLoginBarcode = barcode.length === 13 && /^04[0-9]/.test(barcode); + + if (!loggedIn && isLoginBarcode) { + try { + await login("", undefined, true, barcode); + } catch (error) { + toast.error("Login failed", { + description: `Failed to log in with barcode: ${error}`, + }); + } + setValue(""); + valueRef.current = ""; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + return; + } + + if (!loggedIn) { + sessionStorage.setItem("pendingBarcode", barcode); + try { + await login("Guest"); + } catch (error) { + toast.error("Login failed", { + description: `Failed to log in as guest: ${error}`, + }); + sessionStorage.removeItem("pendingBarcode"); + } + setValue(""); + valueRef.current = ""; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + return; + } + + if (loggedIn && isLoginBarcode) { + // keep current items, switch to new user, then create purchase with new user's balance + // also gets the latest state from the ref when scanning + const itemsToCheckout = [...selectedItemsRef.current]; + + try { + await login("", undefined, false, barcode); + + // create purchase with items from previous user + if (itemsToCheckout.length > 0) { + try { + const payload = { + items: itemsToCheckout.map((item) => ({ + id: item.id, + amount: item.quantity, + })), + payment_type: "balance", + }; + + const response = await fetch( + `${config.apiBaseUrl}/api/v1/purchases`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + } + ); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = "Payment failed"; + try { + const errorData = JSON.parse(errorText); + errorMessage = + errorData.message || errorData.error || errorMessage; + } catch {} + throw new Error(errorMessage); + } + + if (clearItemsRef.current) { + clearItemsRef.current(); + } + + logout(); + toast.success("Purchase completed", { + description: "Items charged to your balance", + }); + } catch (error) { + toast.error("Purchase failed", { + description: + error instanceof Error + ? error.message + : "Failed to complete purchase", + }); + } + } else { + toast.success("User switched", { + description: "Logged in with new user", + }); + } + } catch (error) { + toast.error("Login failed", { + description: `Failed to log in with barcode: ${error}`, + }); + } + setValue(""); + valueRef.current = ""; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + return; + } + + const foundItem = items.find((item: Item) => + item.barcodes?.includes(barcode) + ); + if (foundItem) { + const event = new CustomEvent("barcode-item-scanned", { + detail: { item: foundItem }, + }); + window.dispatchEvent(event); + + setValue(""); + valueRef.current = ""; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + } else { + toast.error("Item not found", { + description: `No item found with barcode ${barcode}`, + }); + + setValue(""); + valueRef.current = ""; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + } + }; + + useEffect(() => { + if (loggedIn && items.length > 0 && !processedBarcodeRef.current) { + const pendingBarcode = sessionStorage.getItem("pendingBarcode"); + if (pendingBarcode) { + const foundItem = items.find((item: Item) => + item.barcodes?.includes(pendingBarcode) + ); + if (foundItem) { + const event = new CustomEvent("barcode-item-scanned", { + detail: { item: foundItem }, + }); + window.dispatchEvent(event); + processedBarcodeRef.current = true; + } else { + toast.error("Item not found", { + description: `No item found with barcode ${pendingBarcode}`, + }); + processedBarcodeRef.current = true; + } + sessionStorage.removeItem("pendingBarcode"); + } + } + }, [loggedIn, items]); + + useEffect(() => { + if (document.activeElement?.tagName === "INPUT") { + return; + } + + const handleKeyPress = (event: KeyboardEvent) => { + const key = event.key; + if (/^[0-9]$/.test(key)) { + // append digit and keep ref in sync + setValue((prev) => { + const next = prev + key; + valueRef.current = next; + return next; + }); + resetTimer(); + } else if (key === "Enter" && valueRef.current) { + // read from ref to avoid stale closures + handleBarcodeScan(valueRef.current); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => { + window.removeEventListener("keydown", handleKeyPress); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!visible) { + return null; + } + + return ( +
+ {/*make this invisible here*/} +
+ + +
+
+ ); +} diff --git a/frontend/components/section-cards.tsx b/frontend/components/section-cards.tsx new file mode 100644 index 0000000..f714d25 --- /dev/null +++ b/frontend/components/section-cards.tsx @@ -0,0 +1,102 @@ +import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" + +import { Badge } from "@/components/ui/badge" +import { + Card, + CardAction, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export function SectionCards() { + return ( +
+ + + Total Revenue + + $1,250.00 + + + + + +12.5% + + + + +
+ Trending up this month +
+
+ Visitors for the last 6 months +
+
+
+ + + New Customers + + 1,234 + + + + + -20% + + + + +
+ Down 20% this period +
+
+ Acquisition needs attention +
+
+
+ + + Active Accounts + + 45,678 + + + + + +12.5% + + + + +
+ Strong user retention +
+
Engagement exceed targets
+
+
+ + + Growth Rate + + 4.5% + + + + + +4.5% + + + + +
+ Steady performance increase +
+
Meets growth projections
+
+
+
+ ) +} diff --git a/frontend/components/selected-items-context.tsx b/frontend/components/selected-items-context.tsx new file mode 100644 index 0000000..cad74fb --- /dev/null +++ b/frontend/components/selected-items-context.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { createContext, useContext, useState, ReactNode } from "react"; +import { Item } from "@/types/item"; + +type SelectedItemWithQuantity = Item & { + quantity: number; +}; + +type SelectedItemsContextType = { + selectedItems: SelectedItemWithQuantity[]; + addItem: (item: Item) => void; + removeItem: (itemId: string) => void; + removeOneItem: (itemId: string) => void; + clearItems: () => void; + totalInCents: number; + totalInEuros: string; +}; + +const SelectedItemsContext = createContext< + SelectedItemsContextType | undefined +>(undefined); + +export function SelectedItemsProvider({ children }: { children: ReactNode }) { + const [selectedItems, setSelectedItems] = useState< + SelectedItemWithQuantity[] + >([]); + + const addItem = (item: Item) => { + setSelectedItems((prev) => { + const existingItem = prev.find((i) => i.id === item.id); + if (existingItem) { + return prev.map((i) => + i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i + ); + } + return [...prev, { ...item, quantity: 1 }]; + }); + }; + + const removeItem = (itemId: string) => { + setSelectedItems((prev) => prev.filter((item) => item.id !== itemId)); + }; + + const removeOneItem = (itemId: string) => { + setSelectedItems((prev) => { + const existingItem = prev.find((i) => i.id === itemId); + if (existingItem && existingItem.quantity > 1) { + return prev.map((i) => + i.id === itemId ? { ...i, quantity: i.quantity - 1 } : i + ); + } + return prev.filter((item) => item.id !== itemId); + }); + }; + + const totalInCents = selectedItems.reduce( + (sum, item) => sum + item.price * item.quantity, + 0 + ); + const totalInEuros = (totalInCents / 100).toFixed(2); + + const clearItems = () => { + setSelectedItems([]); + }; + + return ( + + {children} + + ); +} + +export function useSelectedItems() { + const context = useContext(SelectedItemsContext); + if (context === undefined) { + throw new Error( + "useSelectedItems must be used within a SelectedItemsProvider" + ); + } + return context; +} diff --git a/frontend/components/selected-items-list.tsx b/frontend/components/selected-items-list.tsx new file mode 100644 index 0000000..9eb2205 --- /dev/null +++ b/frontend/components/selected-items-list.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useSelectedItems } from "@/components/selected-items-context"; + +export function SelectedItemsList() { + const { selectedItems, removeItem, removeOneItem } = useSelectedItems(); + + if (selectedItems.length === 0) { + return ( +
No items selected
+ ); + } + + return ( +
+
+ {selectedItems.map((item) => ( +
+
+
{item.name}
+
+ {((item.price * item.quantity) / 100).toFixed(2)}€ +
+
+
+
+ x{item.quantity} +
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/settings-context.tsx b/frontend/components/settings-context.tsx new file mode 100644 index 0000000..2e97fd1 --- /dev/null +++ b/frontend/components/settings-context.tsx @@ -0,0 +1,145 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useEffect, + useRef, + useCallback, +} from "react"; +import { env } from "next-runtime-env"; +import { config } from "@/lib/config"; +import { Settings } from "@/types/settings"; +import { useUser } from "@/components/user-context"; + +interface SettingsContextType { + settings: Settings | null; + maintenanceMode: boolean; + isEnvMaintenance: boolean; + refreshSettings: () => Promise; + loading: boolean; +} + +const SettingsContext = createContext( + undefined +); + +export function SettingsProvider({ children }: { children: React.ReactNode }) { + const isEnvMaintenance = env("NEXT_PUBLIC_MAINTENANCE") === "true"; + + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const { user } = useUser(); + const hasFetchedRef = useRef(false); + const lastAdminStatusRef = useRef(undefined); + + const fetchSettings = useCallback(async () => { + try { + const endpoint = user?.is_admin + ? `${config.apiBaseUrl}/api/admin/v1/settings` + : `${config.apiBaseUrl}/api/v1/settings`; + + console.log( + `Fetching settings from: ${endpoint} (is_admin: ${user?.is_admin})` + ); + + const res = await fetch(endpoint, { + credentials: "include", + }); + + if (res.ok) { + const data = await res.json(); + const fetchedSettings = data.data || data; + setSettings(fetchedSettings); + } else { + console.error("Failed to fetch settings:", res.status); + setSettings({ maintenance: false }); + } + } catch (error) { + console.error("Error fetching settings:", error); + setSettings({ maintenance: false }); + } finally { + setLoading(false); + } + }, [user?.is_admin]); + + useEffect(() => { + const isAdmin = user?.is_admin; + + // fetch if: + // - never fetched before, OR + // - admin status has changed + if (!hasFetchedRef.current || lastAdminStatusRef.current !== isAdmin) { + hasFetchedRef.current = true; + lastAdminStatusRef.current = isAdmin; + fetchSettings(); + } + }, [user?.is_admin, fetchSettings]); + + useEffect(() => { + const handleSettingsUpdate = async () => { + console.log("Settings updated via SSE, fetching latest settings..."); + try { + const endpoint = user?.is_admin + ? `${config.apiBaseUrl}/api/admin/v1/settings` + : `${config.apiBaseUrl}/api/v1/settings`; + + console.log( + `Fetching settings via SSE from: ${endpoint} (is_admin: ${user?.is_admin})` + ); + + const res = await fetch(endpoint, { + credentials: "include", + }); + + if (res.ok) { + const data = await res.json(); + const fetchedSettings = data.data || data; + setSettings(fetchedSettings); + } else { + console.error( + "Failed to fetch settings after SSE update:", + res.status + ); + } + } catch (error) { + console.error("Error fetching settings after SSE update:", error); + } + }; + + window.addEventListener("sse:settings", handleSettingsUpdate); + + return () => { + window.removeEventListener("sse:settings", handleSettingsUpdate); + }; + }, [user?.is_admin]); + + const maintenanceMode = isEnvMaintenance || settings?.maintenance || false; + + const refreshSettings = async () => { + await fetchSettings(); + }; + + return ( + + {children} + + ); +} + +export function useSettings() { + const context = useContext(SettingsContext); + if (context === undefined) { + throw new Error("useSettings must be used within a SettingsProvider"); + } + return context; +} diff --git a/frontend/components/settings-dialog.tsx b/frontend/components/settings-dialog.tsx new file mode 100644 index 0000000..e71638f --- /dev/null +++ b/frontend/components/settings-dialog.tsx @@ -0,0 +1,291 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Construction, + AlertTriangle, + Palette, + User, + Loader2, + RefreshCw, +} from "lucide-react"; +import { useUser } from "@/components/user-context"; +import { useSettings } from "@/components/settings-context"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useTheme } from "next-themes"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { config } from "@/lib/config"; +import { toast } from "sonner"; + +interface SettingsDialogProps { + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export default function SettingsDialog({ + trigger, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, +}: SettingsDialogProps = {}) { + const { user } = useUser(); + const { maintenanceMode, isEnvMaintenance, refreshSettings } = useSettings(); + const { theme, setTheme } = useTheme(); + const [internalOpen, setInternalOpen] = React.useState(false); + const [updating, setUpdating] = React.useState(false); + const [loginBarcode, setLoginBarcode] = React.useState(""); + const [refreshingBarcode, setRefreshingBarcode] = React.useState(false); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled + ? controlledOnOpenChange || (() => {}) + : setInternalOpen; + + const isAdmin = user?.is_admin; + + const handleMaintenanceToggle = async (enabled: boolean) => { + if (isEnvMaintenance) return; + + setUpdating(true); + try { + const res = await fetch(`${config.apiBaseUrl}/api/admin/v1/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + maintenance: enabled, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to update settings"); + } + + await refreshSettings(); + + toast.success( + `Maintenance mode ${enabled ? "enabled" : "disabled"} successfully` + ); + } catch (error) { + console.error("Failed to update maintenance mode:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to update maintenance mode" + ); + } finally { + setUpdating(false); + } + }; + + const handleRefreshLoginBarcode = async () => { + if (!user?.id) return; + + setRefreshingBarcode(true); + try { + const res = await fetch(`${config.apiBaseUrl}/api/v1/users/${user.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + generate_login_barcode: true, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to generate login barcode"); + } + + const data = await res.json(); + setLoginBarcode(data.data?.login_barcode || ""); + + toast.success("Login barcode generated successfully"); + } catch (error) { + console.error("Failed to generate login barcode:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to generate login barcode" + ); + } finally { + setRefreshingBarcode(false); + } + }; + + return ( + + {trigger && {trigger}} + + + Settings + + {isAdmin + ? "Manage application settings and preferences" + : "Customize your experience"} + + + + + +
+
+
+ +

User Preferences

+
+ +
+ + +

+ Choose how the app looks to you +

+
+ +
+ +
+ + +
+

+ Generate a barcode to log in with a scanner +

+
+ + {user && ( +
+

Logged in as

+

{user.name}

+ {isAdmin && ( +

+ Administrator +

+ )} +
+ )} +
+ + {isAdmin && ( + <> + +
+
+ +

Admin Settings

+
+ + {isEnvMaintenance && ( + + + Environment Variable Active + + Maintenance mode is forced by NEXT_PUBLIC_MAINTENANCE. + + + )} + +
+
+ +
+ Restrict site access to admins only +
+
+ + {updating && ( + + )} +
+ + {maintenanceMode && ( + + + ⚠️ Non-admin users are currently seeing the maintenance + page + + + )} +
+ + )} +
+
+
+ ); +} diff --git a/frontend/components/sidebar-footer-content.tsx b/frontend/components/sidebar-footer-content.tsx new file mode 100644 index 0000000..91f8a81 --- /dev/null +++ b/frontend/components/sidebar-footer-content.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useSelectedItems } from "@/components/selected-items-context"; +import CheckoutButton from "./checkout-button"; + +export function SidebarFooterContent() { + const { selectedItems } = useSelectedItems(); + + const totalInCents = selectedItems.reduce( + (sum, item) => sum + item.price * item.quantity, + 0 + ); + const totalInEuros = (totalInCents / 100).toFixed(2); + + return ( +
+
+ Total: + €{totalInEuros} +
+ {selectedItems.length > 0 && } +
+ ); +} diff --git a/frontend/components/site-header.tsx b/frontend/components/site-header.tsx new file mode 100644 index 0000000..59c4f02 --- /dev/null +++ b/frontend/components/site-header.tsx @@ -0,0 +1,30 @@ +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" + +export function SiteHeader() { + return ( +
+
+ + +

Documents

+
+ +
+
+
+ ) +} diff --git a/frontend/components/sse-connection-status.tsx b/frontend/components/sse-connection-status.tsx new file mode 100644 index 0000000..4c048c3 --- /dev/null +++ b/frontend/components/sse-connection-status.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React from "react"; +import { useSSE } from "@/components/sse-context"; + +export const SSEConnectionStatus = () => { + const { connectionStatus } = useSSE(); + + return ( +
+
+
+ SSE: {connectionStatus} +
+
+ ); +}; + +export default SSEConnectionStatus; diff --git a/frontend/components/sse-context.tsx b/frontend/components/sse-context.tsx new file mode 100644 index 0000000..ad26ba8 --- /dev/null +++ b/frontend/components/sse-context.tsx @@ -0,0 +1,193 @@ +"use client"; + +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { config } from "@/lib/config"; + +interface SSEEvent { + type: string; + data: SSENotificationPayload; +} + +interface SSENotificationPayload { + transaction_payload?: SSENotificationTransactionUpdatePayload; + content_payload?: SSENotificationContentUpdatePayload; +} + +interface SSENotificationTransactionUpdatePayload { + client_transaction_id: string; + transaction_status: "cancelled" | "failed" | "pending" | "successful"; +} + +interface SSENotificationContentUpdatePayload { + type: "users" | "items" | "settings"; +} + +interface SSEContextType { + isConnected: boolean; + lastEvent: SSEEvent | null; + connectionStatus: + | "connecting" + | "connected" + | "disconnected" + | "error" + | "reconnecting"; +} + +const SSEContext = createContext(undefined); + +export const SSEProvider = ({ children }: { children: React.ReactNode }) => { + const [isConnected, setIsConnected] = useState(false); + const [lastEvent, setLastEvent] = useState(null); + const [connectionStatus, setConnectionStatus] = useState< + "connecting" | "connected" | "disconnected" | "error" | "reconnecting" + >("disconnected"); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const hasEverConnected = useRef(false); + const reconnectDelay = 2500; // 2.5 seconds + + const connect = () => { + if (eventSourceRef.current?.readyState === EventSource.OPEN) { + return; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + if (!hasEverConnected.current) { + setConnectionStatus("connecting"); + console.log("Establishing SSE connection to payment events..."); + } + + try { + const eventSource = new EventSource( + `${config.apiBaseUrl}/api/payment/v1/events` + ); + + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + console.log("SSE connection established"); + setIsConnected(true); + setConnectionStatus("connected"); + hasEverConnected.current = true; + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log("SSE event received:", data); + + const eventType = data.type || "message"; + const eventData = data.data || data; + + setLastEvent({ + type: eventType, + data: eventData, + }); + + // Handle different event types and dispatch custom events + if (eventType === "content_update") { + console.log("Content update received:", data); + if (eventData.content_payload?.type === "settings") { + window.dispatchEvent(new CustomEvent("sse:settings")); + } + } else if (eventType === "transaction_update") { + console.log("Transaction update received:", data); + } + } catch (error) { + console.error("Failed to parse SSE event data:", error); + setLastEvent({ + type: "raw", + data: event.data, + }); + } + }; + + eventSource.onerror = (error) => { + console.error("SSE connection error:", error); + setIsConnected(false); + + const status = hasEverConnected.current ? "reconnecting" : "error"; + setConnectionStatus(status); + + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, reconnectDelay); + }; + } catch (error) { + console.error("Failed to create SSE connection:", error); + setConnectionStatus("error"); + } + }; + + const disconnect = () => { + console.log("Closing SSE connection..."); + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + setIsConnected(false); + setConnectionStatus("disconnected"); + }; + + useEffect(() => { + connect(); + + const handleBeforeUnload = () => { + disconnect(); + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + console.log("Page hidden, maintaining SSE connection"); + } else { + console.log("Page visible, ensuring SSE connection"); + if ( + !eventSourceRef.current || + eventSourceRef.current.readyState === EventSource.CLOSED + ) { + connect(); + } + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + document.removeEventListener("visibilitychange", handleVisibilityChange); + disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +}; + +export const useSSE = () => { + const context = useContext(SSEContext); + if (!context) { + throw new Error("useSSE must be used within an SSEProvider"); + } + return context; +}; diff --git a/frontend/components/theme-color-meta.tsx b/frontend/components/theme-color-meta.tsx new file mode 100644 index 0000000..9d50e09 --- /dev/null +++ b/frontend/components/theme-color-meta.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import { useTheme } from "next-themes"; + +export function ThemeColorMeta() { + const { resolvedTheme } = useTheme(); + + useEffect(() => { + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + + if (metaThemeColor) { + const color = resolvedTheme === "dark" ? "#0a0a0a" : "#ffffff"; + metaThemeColor.setAttribute("content", color); + } + }, [resolvedTheme]); + + return null; +} diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx new file mode 100644 index 0000000..e018a73 --- /dev/null +++ b/frontend/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children} +} \ No newline at end of file diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/frontend/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/frontend/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/frontend/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/breadcrumb.tsx b/frontend/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/frontend/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return