diff --git a/.env.example b/.env.example index 6110bc1..8e68eb5 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,12 @@ BANDWIDTH_BPS=209715 # Default: 100 # Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments) RATE_LIMIT_RPS=100 + +# ── Web Admin Panel ─────────────────────────────────────── + +# Whether the Go server should serve the built SPA frontend from ./web/dist. +# Set to false when using Caddy, Nginx, or another reverse proxy to serve +# the frontend and proxy API requests to this server. +# Default: true +# Example: SERVE_SPA=false (frontend served by external web server) +SERVE_SPA=true diff --git a/cmd/server/main.go b/cmd/server/main.go index 52dd259..91dc5c5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,187 +1,205 @@ package main import ( - "context" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" - "onixbyte.com/pipely/internal/artefact" - "onixbyte.com/pipely/internal/auth" - "onixbyte.com/pipely/internal/config" - "onixbyte.com/pipely/internal/database" - "onixbyte.com/pipely/internal/delta" - "onixbyte.com/pipely/internal/deployment" - "onixbyte.com/pipely/internal/device" - "onixbyte.com/pipely/internal/download" - "onixbyte.com/pipely/internal/middleware" + "onixbyte.com/pipely/internal/artefact" + "onixbyte.com/pipely/internal/auth" + "onixbyte.com/pipely/internal/config" + "onixbyte.com/pipely/internal/database" + "onixbyte.com/pipely/internal/delta" + "onixbyte.com/pipely/internal/deployment" + "onixbyte.com/pipely/internal/device" + "onixbyte.com/pipely/internal/download" + "onixbyte.com/pipely/internal/middleware" ) func main() { - cfg := config.Load() + cfg := config.Load() - // Connect to PostgreSQL (auto-creates database if needed) - db, err := database.Connect(cfg.DatabaseURL) - if err != nil { - log.Fatalf("Database connection failed: %v", err) - } + // Connect to PostgreSQL (auto-creates database if needed) + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Database connection failed: %v", err) + } - // Auto-migrate tables - if err := database.AutoMigrate(db); err != nil { - log.Fatalf("Auto-migrate failed: %v", err) - } + // Auto-migrate tables + if err := database.AutoMigrate(db); err != nil { + log.Fatalf("Auto-migrate failed: %v", err) + } - // Initialise auth service and create default admin - authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs) - if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil { - log.Printf("Warning: failed to create initial admin: %v", err) - } - log.Println("[main] Default admin user ready (admin / admin)") + // Initialise auth service and create default admin + authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs) + if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil { + log.Printf("Warning: failed to create initial admin: %v", err) + } + log.Println("[main] Default admin user ready (admin / admin)") - // Ensure artefact storage directory exists - if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil { - log.Fatalf("Failed to create artefact directory: %v", err) - } + // Ensure artefact storage directory exists + if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil { + log.Fatalf("Failed to create artefact directory: %v", err) + } - // Global rate limiter - tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2) - go func() { - ticker := time.NewTicker(10 * time.Minute) - for range ticker.C { - tokenBucket.Cleanup(30 * time.Minute) - } - }() + // Global rate limiter + tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2) + go func() { + ticker := time.NewTicker(10 * time.Minute) + for range ticker.C { + tokenBucket.Cleanup(30 * time.Minute) + } + }() - // Gated rollout semaphore - downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent) + // Gated rollout semaphore + downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent) - // Offline device detection - go func() { - ticker := time.NewTicker(5 * time.Minute) - for range ticker.C { - offline, err := database.GetOfflineDevices(db, 10*time.Minute) - if err != nil { - log.Printf("[health] Offline check failed: %v", err) - continue - } - ids := make([]string, len(offline)) - for i, d := range offline { - ids[i] = d.DeviceID - } - if len(ids) > 0 { - if err := database.MarkDevicesOffline(db, ids); err != nil { - log.Printf("[health] Mark offline failed: %v", err) - } else { - log.Printf("[health] Marked %d devices as offline", len(ids)) - } - } - } - }() + // Offline device detection + go func() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + offline, err := database.GetOfflineDevices(db, 10*time.Minute) + if err != nil { + log.Printf("[health] Offline check failed: %v", err) + continue + } + ids := make([]string, len(offline)) + for i, d := range offline { + ids[i] = d.DeviceID + } + if len(ids) > 0 { + if err := database.MarkDevicesOffline(db, ids); err != nil { + log.Printf("[health] Mark offline failed: %v", err) + } else { + log.Printf("[health] Marked %d devices as offline", len(ids)) + } + } + } + }() - // Initialise handlers - authHandler := auth.NewHandler(authSvc) - deviceHandler := device.NewHandler(db) - artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir) - deploymentHandler := deployment.NewHandler(db) - deltaHandler := delta.NewHandler(cfg.ArtefactDir) - downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS) + // Initialise handlers + authHandler := auth.NewHandler(authSvc) + deviceHandler := device.NewHandler(db) + artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir) + deploymentHandler := deployment.NewHandler(db) + deltaHandler := delta.NewHandler(cfg.ArtefactDir) + downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS) - // Setup Gin - gin.SetMode(gin.ReleaseMode) - r := gin.New() - r.Use(gin.Recovery()) + // Setup Gin + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) - // CORS - r.Use(func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(http.StatusNoContent) - return - } - c.Next() - }) + // CORS — withCredentials requires explicit origin, not wildcard + r.Use(func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin != "" { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + } else { + c.Header("Access-Control-Allow-Origin", "*") + } + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + }) - // -- Public routes (daemon-facing) -- - api := r.Group("/api/v1") - { - api.POST("/devices/register", deviceHandler.Register) - api.POST("/devices/heartbeat", deviceHandler.Heartbeat) - api.GET("/devices/check-update", deviceHandler.CheckUpdate) - } + // -- Public routes (daemon-facing) -- + api := r.Group("/api") + { + api.POST("/devices/register", deviceHandler.Register) + api.POST("/devices/heartbeat", deviceHandler.Heartbeat) + api.GET("/devices/check-update", deviceHandler.CheckUpdate) + } - // -- Authenticated admin routes -- - admin := api.Group("") - admin.Use(middleware.AuthMiddleware(authSvc)) - { - api.POST("/auth/login", authHandler.Login) + // -- Public auth routes -- + api.POST("/auth/login", authHandler.Login) - admin.GET("/devices", deviceHandler.List) - admin.GET("/devices/:deviceId", deviceHandler.Get) + // -- Authenticated admin routes -- + admin := api.Group("") + admin.Use(middleware.AuthMiddleware(authSvc)) + { + api.POST("/auth/logout", authHandler.Logout) - admin.POST("/artefacts", artefactHandler.Upload) - admin.GET("/artefacts", artefactHandler.List) - admin.GET("/artefacts/:id", artefactHandler.Get) + admin.GET("/devices", deviceHandler.List) + admin.GET("/devices/:deviceId", deviceHandler.Get) - admin.POST("/delta/generate", deltaHandler.GenerateDelta) + admin.POST("/artefacts", artefactHandler.Upload) + admin.GET("/artefacts", artefactHandler.List) + admin.GET("/artefacts/:id", artefactHandler.Get) - admin.POST("/deployments", deploymentHandler.Create) - admin.GET("/deployments", deploymentHandler.List) - admin.GET("/deployments/:id", deploymentHandler.Get) - admin.POST("/deployments/:id/activate", deploymentHandler.Activate) - admin.POST("/deployments/:id/pause", deploymentHandler.Pause) - admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices) - admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback) + admin.POST("/delta/generate", deltaHandler.GenerateDelta) - admin.GET("/admin/users", authHandler.ListUsers) - admin.POST("/admin/users", authHandler.CreateUser) - admin.DELETE("/admin/users/:id", authHandler.DeleteUser) - admin.PUT("/admin/users/:id/password", authHandler.ChangePassword) - } + admin.POST("/deployments", deploymentHandler.Create) + admin.GET("/deployments", deploymentHandler.List) + admin.GET("/deployments/:id", deploymentHandler.Get) + admin.POST("/deployments/:id/activate", deploymentHandler.Activate) + admin.POST("/deployments/:id/pause", deploymentHandler.Pause) + admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices) + admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback) - // -- Download endpoint (rate-limited + gated) -- - packages := api.Group("/packages") - packages.Use(middleware.RateLimitMiddleware(tokenBucket)) - packages.Use(middleware.GatedMiddleware(downloadGate)) - { - packages.GET("/download/:id", downloadHandler.HandleDownload) - } + admin.GET("/admin/users", authHandler.ListUsers) + admin.POST("/admin/users", authHandler.CreateUser) + admin.DELETE("/admin/users/:id", authHandler.DeleteUser) + admin.PUT("/admin/users/:id/password", authHandler.ChangePassword) + } - // -- Device status update -- - api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus) + // -- Download endpoint (rate-limited + gated) -- + packages := api.Group("/packages") + packages.Use(middleware.RateLimitMiddleware(tokenBucket)) + packages.Use(middleware.GatedMiddleware(downloadGate)) + { + packages.GET("/download/:id", downloadHandler.HandleDownload) + } - // -- Static admin panel (served at root; API routes take precedence) -- - r.StaticFS("/", http.Dir("./web/dist")) + // -- Device status update -- + api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus) - // Graceful shutdown - srv := &http.Server{ - Addr: ":" + cfg.ServerPort, - Handler: r, - } + // -- Static admin panel (SPA fallback) -- + // Enable with SERVE_SPA=true (default). Set to false when using Caddy/Nginx as reverse proxy. + if cfg.ServeSPA { + r.Static("/assets", "./web/dist/assets") + r.GET("/favicon.ico", func(c *gin.Context) { c.File("./web/dist/favicon.ico") }) + r.NoRoute(func(c *gin.Context) { + c.File("./web/dist/index.html") + }) + } - go func() { - log.Printf("[main] OTA Manifest Server listening on :%s", cfg.ServerPort) - log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server error: %v", err) - } - }() + // Graceful shutdown + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: r, + } - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - log.Println("[main] Shutting down...") + go func() { + log.Printf("[main] Pipely listening on port :%s", cfg.ServerPort) + log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Server error: %v", err) + } + }() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - log.Fatalf("Forced shutdown: %v", err) - } - log.Println("[main] Server stopped") + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("[main] Shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Forced shutdown: %v", err) + } + log.Println("[main] Server stopped") } diff --git a/internal/auth/handler.go b/internal/auth/handler.go index fb19aa8..5492f85 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -28,5 +28,16 @@ func (h *Handler) Login(c *gin.Context) { return } - c.JSON(http.StatusOK, resp) + // Set JWT as HttpOnly cookie so the frontend doesn't need to manage it. + // The browser sends it automatically on every request. + maxAge := h.svc.ExpiryHrs() * 3600 + c.SetCookie("pipely_token", resp.Token, maxAge, "/", "", false, true) + + c.JSON(http.StatusOK, gin.H{"message": "Login successful"}) +} + +func (h *Handler) Logout(c *gin.Context) { + // Clear the auth cookie by setting MaxAge=-1 + c.SetCookie("pipely_token", "", -1, "/", "", false, true) + c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) } diff --git a/internal/auth/service.go b/internal/auth/service.go index 5376a97..0ee0a7f 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -33,6 +33,10 @@ func NewService(db *gorm.DB, secret []byte, expiryHrs int) *Service { } } +func (s *Service) ExpiryHrs() int { + return int(s.expiry.Hours()) +} + func (s *Service) Login(username, password string) (*model.LoginResponse, error) { user, err := database.GetAdminUserByUsername(s.db, username) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 2400a61..84dbd04 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { MaxConcurrent int BandwidthBPS int RateLimitRPS int + ServeSPA bool } func Load() *Config { @@ -34,6 +35,7 @@ func Load() *Config { MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50), BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50), RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100), + ServeSPA: envOrDefaultBool("SERVE_SPA", true), } } @@ -52,3 +54,12 @@ func envOrDefaultInt(key string, def int) int { } return def } + +func envOrDefaultBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return def +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index c4d795b..8cc5e37 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -10,19 +10,13 @@ import ( func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation header is missing"}) + token := extractToken(c) + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation token is missing"}) return } - parts := strings.SplitN(authHeader, " ", 2) - if !(len(parts) == 2 && parts[0] == "Bearer") { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorisation format"}) - return - } - - user, err := authSvc.ValidateToken(parts[1]) + user, err := authSvc.ValidateToken(token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()}) return @@ -32,3 +26,21 @@ func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc { c.Next() } } + +func extractToken(c *gin.Context) string { + // First try the Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + return parts[1] + } + } + + // Fall back to the cookie set at login + if cookie, err := c.Cookie("pipely_token"); err == nil { + return cookie + } + + return "" +} diff --git a/web/.env.development b/web/.env.development new file mode 100644 index 0000000..cb5932f --- /dev/null +++ b/web/.env.development @@ -0,0 +1,17 @@ +# ───────────────────────────────────────────────────────── +# Pipely Web — Environment Variables +# ───────────────────────────────────────────────────────── +# Copy this file to .env and adjust values for your environment: +# cp .env.example .env +# ───────────────────────────────────────────────────────── + +# ── API Server ─────────────────────────────────────────── + +# Base URL for the OTA Manifest Server API. +# Default: https://pipely.onixbyte.com/ +# Development: http://localhost:8080 +VITE_API_BASE_URL=http://localhost:5001/api + +# Redux Persistent storage location +# Options: `local` (default), `session` +VITE_REDUX_STORAGE=local diff --git a/web/src/api/artefacts/index.ts b/web/src/api/artefacts/index.ts index fed2501..2783731 100644 --- a/web/src/api/artefacts/index.ts +++ b/web/src/api/artefacts/index.ts @@ -1,11 +1,13 @@ import WebClient from "@/shared/web-client" export interface Artefact { - id: string - name: string + id: number version: string - checksum: string - size: number + fileName: string + fileSize: number + sha256Hash: string + isDelta: boolean + deltaRef: string createdAt: string } @@ -33,11 +35,17 @@ export async function getArtefacts(query?: ArtefactsQuery): Promise { - const { data } = await WebClient.post("/artefacts/upload", file) +export async function uploadArtefact(version: string, file: File): Promise { + const form = new FormData() + form.append("version", version) + form.append("file", file) + + const { data } = await WebClient.post("/artefacts", form, { + headers: { "Content-Type": "multipart/form-data" }, + }) return data } -export async function deleteArtefact(id: string): Promise { - await WebClient.delete(`/artefacts/${id}`) +export async function deleteArtefact(id: number): Promise { + await WebClient.delete(`/artefacts/${id}`) } diff --git a/web/src/api/auth/index.ts b/web/src/api/auth/index.ts index dc30aeb..0b73f96 100644 --- a/web/src/api/auth/index.ts +++ b/web/src/api/auth/index.ts @@ -5,11 +5,10 @@ export interface LoginRequest { password: string } -export interface LoginResponse { - token: string +export async function login(credentials: LoginRequest): Promise { + await WebClient.post("/auth/login", credentials) } -export async function login(credentials: LoginRequest): Promise { - const { data } = await WebClient.post("/auth/login", credentials) - return data -} +export async function logout(): Promise { + await WebClient.post("/auth/logout") +} \ No newline at end of file diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index fc86925..d5d6328 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,6 +1,5 @@ import { NavLink, useNavigate, Outlet } from "react-router-dom" import { useDispatch } from "react-redux" -import { setToken } from "@/store/auth-slice" import { Drawer, List, @@ -21,6 +20,8 @@ import { People, Logout, } from "@mui/icons-material" +import { logoutAction } from "@/store/auth-slice" +import { logout } from "@/api/auth" const drawerWidth = 240 @@ -28,8 +29,9 @@ export default function Layout() { const navigate = useNavigate() const dispatch = useDispatch() - function handleLogout() { - dispatch(setToken("")) + async function handleLogout() { + dispatch(logoutAction()) + await logout().catch(() => {}) navigate("/") } @@ -50,7 +52,7 @@ export default function Layout() { }}> - OTA Manager + Pipely diff --git a/web/src/store/hooks.ts b/web/src/hooks/store.ts similarity index 100% rename from web/src/store/hooks.ts rename to web/src/hooks/store.ts diff --git a/web/src/pages/Artefacts.tsx b/web/src/pages/Artefacts.tsx index 97d9e65..c1c86d0 100644 --- a/web/src/pages/Artefacts.tsx +++ b/web/src/pages/Artefacts.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, type MouseEvent } from "react" -import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts" import { Box, Button, @@ -24,6 +23,7 @@ import { Pagination, } from "@mui/material" import { Upload, Delete, Archive } from "@mui/icons-material" +import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts" const PAGE_SIZE = 20 @@ -34,7 +34,7 @@ export default function Artefacts() { const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deleteTarget, setDeleteTarget] = useState<{ id: string; version: string } | null>(null) + const [deleteTarget, setDeleteTarget] = useState<{ id: number; version: string } | null>(null) const [uploading, setUploading] = useState(false) const [version, setVersion] = useState("") const [file, setFile] = useState(null) @@ -73,7 +73,7 @@ export default function Artefacts() { setUploading(true) try { - await uploadArtefact(file) + await uploadArtefact(version, file) setVersion("") setFile(null) setDialogOpen(false) @@ -90,7 +90,7 @@ export default function Artefacts() { } } - function handleDeleteClick(id: string, version: string) { + function handleDeleteClick(id: number, version: string) { setDeleteTarget({ id, version }) setDeleteDialogOpen(true) } @@ -203,10 +203,10 @@ export default function Artefacts() { - {a.name} + {a.fileName} - {formatSize(a.size)} + {formatSize(a.fileSize)} - {a.checksum.slice(0, 12)}... + title={a.sha256Hash}> + {a.sha256Hash.slice(0, 12)}... {new Date(a.createdAt).toLocaleDateString()} @@ -280,6 +280,6 @@ export default function Artefacts() { function formatSize(bytes: number): string { if (bytes < 1024) return bytes + " B" - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" - return (bytes / (1024 * 1024)).toFixed(1) + " MB" + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB" + return (bytes / (1024 * 1024)).toFixed(1) + " MiB" } diff --git a/web/src/pages/Deployments.tsx b/web/src/pages/Deployments.tsx index 459bb4d..5b13e71 100644 --- a/web/src/pages/Deployments.tsx +++ b/web/src/pages/Deployments.tsx @@ -45,7 +45,7 @@ export default function Deployments() { const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) - const [artefacts, setArtefacts] = useState<{ id: string; version: string }[]>([]) + const [artefacts, setArtefacts] = useState<{ id: number; version: string }[]>([]) const [name, setName] = useState("") const [artefactId, setArtefactId] = useState("") const [targetGroups, setTargetGroups] = useState("") diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 4a9d74f..014550c 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,6 +1,8 @@ -import { useState, type SubmitEvent } from "react" +import { useState } from "react" import { useNavigate } from "react-router-dom" +import { useDispatch } from "react-redux" import { login } from "@/api/auth" +import { loginAction } from "@/store/auth-slice" import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material" export default function Login() { @@ -9,15 +11,16 @@ export default function Login() { const [error, setError] = useState("") const [loading, setLoading] = useState(false) const navigate = useNavigate() + const dispatch = useDispatch() - async function handleSubmit(e: SubmitEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError("") setLoading(true) try { - const res = await login({ username, password }) - console.log(res.token) + await login({ username, password }) + dispatch(loginAction()) navigate("/dashboard") } catch (err) { setError(err instanceof Error ? err.message : "Login failed") diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index a320376..57dbe30 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -4,7 +4,7 @@ import ErrorPage from "@/components/ErrorPage" import Layout from "@/components/Layout" import EmptyLayout from "@/components/EmptyLayout" import { isAuthenticated } from "@/store/auth-slice" -import { useAppSelector } from "@/store" +import { useAppSelector } from "@/hooks/store" function lazy }>(importer: () => Promise) { return async () => { diff --git a/web/src/shared/web-client/index.ts b/web/src/shared/web-client/index.ts index 74844fd..99c13a2 100644 --- a/web/src/shared/web-client/index.ts +++ b/web/src/shared/web-client/index.ts @@ -1,7 +1,10 @@ import axios from "axios" import dayjs from "@/lib/dayjs" -export default axios.create({ +const WebClient = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(), + withCredentials: true, }) + +export default WebClient \ No newline at end of file diff --git a/web/src/store/auth-slice.ts b/web/src/store/auth-slice.ts index bc97d16..4dbbcf5 100644 --- a/web/src/store/auth-slice.ts +++ b/web/src/store/auth-slice.ts @@ -1,24 +1,27 @@ -import { createSlice, type PayloadAction } from "@reduxjs/toolkit" +import { createSlice } from "@reduxjs/toolkit" export interface AuthState { - token: string + authenticated: boolean } const initialState: AuthState = { - token: "", + authenticated: false, } const authSlice = createSlice({ name: "auth", initialState, reducers: { - setToken(state, action: PayloadAction) { - state.token = action.payload + login(state) { + state.authenticated = true + }, + logout(state) { + state.authenticated = false }, }, }) -export const { setToken } = authSlice.actions +export const { login: loginAction, logout: logoutAction } = authSlice.actions export const authReducer = authSlice.reducer -export const isAuthenticated = (state: { auth: AuthState }) => state.auth.token !== "" +export const isAuthenticated = (state: { auth: AuthState }) => state.auth.authenticated \ No newline at end of file diff --git a/web/src/store/index.ts b/web/src/store/index.ts index fc9f4e0..0618ab8 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -42,5 +42,3 @@ export default store export type RootState = ReturnType export type AppDispatch = typeof store.dispatch export type AppStore = typeof store - -export { useAppSelector, useAppDispatch } from "./hooks"