refactor: move auth token to HttpOnly cookie, fix CORS and artefacts

Backend:
- Set JWT as HttpOnly cookie on login, add cookie fallback in auth middleware
- Add /auth/logout endpoint to clear cookie
- Fix CORS to echo origin with Allow-Credentials for withCredentials requests
- Make SPA static serving optional via SERVE_SPA config flag

Frontend:
- Remove manual token management; rely on HttpOnly cookie + withCredentials
- Simplify auth-slice to authenticated boolean flag
- Fix Artefact interface to match backend fields (sha256Hash, fileName, fileSize)
- Fix artefact upload endpoint and send version in FormData
- Use KiB/MiB for file size display
- Move Redux hooks from store/hooks to hooks/store
This commit is contained in:
2026-07-07 15:57:26 +08:00
parent 163f5641e5
commit 442a1e2f9f
18 changed files with 307 additions and 209 deletions
+9
View File
@@ -63,3 +63,12 @@ BANDWIDTH_BPS=209715
# Default: 100 # Default: 100
# Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments) # Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments)
RATE_LIMIT_RPS=100 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
+172 -154
View File
@@ -1,187 +1,205 @@
package main package main
import ( import (
"context" "context"
"log" "errors"
"net/http" "log"
"os" "net/http"
"os/signal" "os"
"syscall" "os/signal"
"time" "syscall"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"onixbyte.com/pipely/internal/artefact" "onixbyte.com/pipely/internal/artefact"
"onixbyte.com/pipely/internal/auth" "onixbyte.com/pipely/internal/auth"
"onixbyte.com/pipely/internal/config" "onixbyte.com/pipely/internal/config"
"onixbyte.com/pipely/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/delta" "onixbyte.com/pipely/internal/delta"
"onixbyte.com/pipely/internal/deployment" "onixbyte.com/pipely/internal/deployment"
"onixbyte.com/pipely/internal/device" "onixbyte.com/pipely/internal/device"
"onixbyte.com/pipely/internal/download" "onixbyte.com/pipely/internal/download"
"onixbyte.com/pipely/internal/middleware" "onixbyte.com/pipely/internal/middleware"
) )
func main() { func main() {
cfg := config.Load() cfg := config.Load()
// Connect to PostgreSQL (auto-creates database if needed) // Connect to PostgreSQL (auto-creates database if needed)
db, err := database.Connect(cfg.DatabaseURL) db, err := database.Connect(cfg.DatabaseURL)
if err != nil { if err != nil {
log.Fatalf("Database connection failed: %v", err) log.Fatalf("Database connection failed: %v", err)
} }
// Auto-migrate tables // Auto-migrate tables
if err := database.AutoMigrate(db); err != nil { if err := database.AutoMigrate(db); err != nil {
log.Fatalf("Auto-migrate failed: %v", err) log.Fatalf("Auto-migrate failed: %v", err)
} }
// Initialise auth service and create default admin // Initialise auth service and create default admin
authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs) authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs)
if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil { if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil {
log.Printf("Warning: failed to create initial admin: %v", err) log.Printf("Warning: failed to create initial admin: %v", err)
} }
log.Println("[main] Default admin user ready (admin / admin)") log.Println("[main] Default admin user ready (admin / admin)")
// Ensure artefact storage directory exists // Ensure artefact storage directory exists
if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil { if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil {
log.Fatalf("Failed to create artefact directory: %v", err) log.Fatalf("Failed to create artefact directory: %v", err)
} }
// Global rate limiter // Global rate limiter
tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2) tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2)
go func() { go func() {
ticker := time.NewTicker(10 * time.Minute) ticker := time.NewTicker(10 * time.Minute)
for range ticker.C { for range ticker.C {
tokenBucket.Cleanup(30 * time.Minute) tokenBucket.Cleanup(30 * time.Minute)
} }
}() }()
// Gated rollout semaphore // Gated rollout semaphore
downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent) downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent)
// Offline device detection // Offline device detection
go func() { go func() {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
for range ticker.C { for range ticker.C {
offline, err := database.GetOfflineDevices(db, 10*time.Minute) offline, err := database.GetOfflineDevices(db, 10*time.Minute)
if err != nil { if err != nil {
log.Printf("[health] Offline check failed: %v", err) log.Printf("[health] Offline check failed: %v", err)
continue continue
} }
ids := make([]string, len(offline)) ids := make([]string, len(offline))
for i, d := range offline { for i, d := range offline {
ids[i] = d.DeviceID ids[i] = d.DeviceID
} }
if len(ids) > 0 { if len(ids) > 0 {
if err := database.MarkDevicesOffline(db, ids); err != nil { if err := database.MarkDevicesOffline(db, ids); err != nil {
log.Printf("[health] Mark offline failed: %v", err) log.Printf("[health] Mark offline failed: %v", err)
} else { } else {
log.Printf("[health] Marked %d devices as offline", len(ids)) log.Printf("[health] Marked %d devices as offline", len(ids))
} }
} }
} }
}() }()
// Initialise handlers // Initialise handlers
authHandler := auth.NewHandler(authSvc) authHandler := auth.NewHandler(authSvc)
deviceHandler := device.NewHandler(db) deviceHandler := device.NewHandler(db)
artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir) artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir)
deploymentHandler := deployment.NewHandler(db) deploymentHandler := deployment.NewHandler(db)
deltaHandler := delta.NewHandler(cfg.ArtefactDir) deltaHandler := delta.NewHandler(cfg.ArtefactDir)
downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS) downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS)
// Setup Gin // Setup Gin
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
// CORS // CORS — withCredentials requires explicit origin, not wildcard
r.Use(func(c *gin.Context) { r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*") origin := c.GetHeader("Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") if origin != "" {
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range") c.Header("Access-Control-Allow-Origin", origin)
if c.Request.Method == "OPTIONS" { c.Header("Access-Control-Allow-Credentials", "true")
c.AbortWithStatus(http.StatusNoContent) } else {
return c.Header("Access-Control-Allow-Origin", "*")
} }
c.Next() 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) -- // -- Public routes (daemon-facing) --
api := r.Group("/api/v1") api := r.Group("/api")
{ {
api.POST("/devices/register", deviceHandler.Register) api.POST("/devices/register", deviceHandler.Register)
api.POST("/devices/heartbeat", deviceHandler.Heartbeat) api.POST("/devices/heartbeat", deviceHandler.Heartbeat)
api.GET("/devices/check-update", deviceHandler.CheckUpdate) api.GET("/devices/check-update", deviceHandler.CheckUpdate)
} }
// -- Authenticated admin routes -- // -- Public auth routes --
admin := api.Group("") api.POST("/auth/login", authHandler.Login)
admin.Use(middleware.AuthMiddleware(authSvc))
{
api.POST("/auth/login", authHandler.Login)
admin.GET("/devices", deviceHandler.List) // -- Authenticated admin routes --
admin.GET("/devices/:deviceId", deviceHandler.Get) admin := api.Group("")
admin.Use(middleware.AuthMiddleware(authSvc))
{
api.POST("/auth/logout", authHandler.Logout)
admin.POST("/artefacts", artefactHandler.Upload) admin.GET("/devices", deviceHandler.List)
admin.GET("/artefacts", artefactHandler.List) admin.GET("/devices/:deviceId", deviceHandler.Get)
admin.GET("/artefacts/:id", artefactHandler.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.POST("/delta/generate", deltaHandler.GenerateDelta)
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.GET("/admin/users", authHandler.ListUsers) admin.POST("/deployments", deploymentHandler.Create)
admin.POST("/admin/users", authHandler.CreateUser) admin.GET("/deployments", deploymentHandler.List)
admin.DELETE("/admin/users/:id", authHandler.DeleteUser) admin.GET("/deployments/:id", deploymentHandler.Get)
admin.PUT("/admin/users/:id/password", authHandler.ChangePassword) 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) -- admin.GET("/admin/users", authHandler.ListUsers)
packages := api.Group("/packages") admin.POST("/admin/users", authHandler.CreateUser)
packages.Use(middleware.RateLimitMiddleware(tokenBucket)) admin.DELETE("/admin/users/:id", authHandler.DeleteUser)
packages.Use(middleware.GatedMiddleware(downloadGate)) admin.PUT("/admin/users/:id/password", authHandler.ChangePassword)
{ }
packages.GET("/download/:id", downloadHandler.HandleDownload)
}
// -- Device status update -- // -- Download endpoint (rate-limited + gated) --
api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus) 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) -- // -- Device status update --
r.StaticFS("/", http.Dir("./web/dist")) api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus)
// Graceful shutdown // -- Static admin panel (SPA fallback) --
srv := &http.Server{ // Enable with SERVE_SPA=true (default). Set to false when using Caddy/Nginx as reverse proxy.
Addr: ":" + cfg.ServerPort, if cfg.ServeSPA {
Handler: r, 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() { // Graceful shutdown
log.Printf("[main] OTA Manifest Server listening on :%s", cfg.ServerPort) srv := &http.Server{
log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS) Addr: ":" + cfg.ServerPort,
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { Handler: r,
log.Fatalf("Server error: %v", err) }
}
}()
quit := make(chan os.Signal, 1) go func() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) log.Printf("[main] Pipely listening on port :%s", cfg.ServerPort)
<-quit log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS)
log.Println("[main] Shutting down...") 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) quit := make(chan os.Signal, 1)
defer cancel() signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
if err := srv.Shutdown(ctx); err != nil { <-quit
log.Fatalf("Forced shutdown: %v", err) log.Println("[main] Shutting down...")
}
log.Println("[main] Server stopped") 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")
} }
+12 -1
View File
@@ -28,5 +28,16 @@ func (h *Handler) Login(c *gin.Context) {
return 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"})
} }
+4
View File
@@ -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) { func (s *Service) Login(username, password string) (*model.LoginResponse, error) {
user, err := database.GetAdminUserByUsername(s.db, username) user, err := database.GetAdminUserByUsername(s.db, username)
if err != nil { if err != nil {
+11
View File
@@ -17,6 +17,7 @@ type Config struct {
MaxConcurrent int MaxConcurrent int
BandwidthBPS int BandwidthBPS int
RateLimitRPS int RateLimitRPS int
ServeSPA bool
} }
func Load() *Config { func Load() *Config {
@@ -34,6 +35,7 @@ func Load() *Config {
MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50), MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50),
BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50), BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50),
RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100), RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100),
ServeSPA: envOrDefaultBool("SERVE_SPA", true),
} }
} }
@@ -52,3 +54,12 @@ func envOrDefaultInt(key string, def int) int {
} }
return def 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
}
+22 -10
View File
@@ -10,19 +10,13 @@ import (
func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc { func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") token := extractToken(c)
if authHeader == "" { if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation header is missing"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation token is missing"})
return return
} }
parts := strings.SplitN(authHeader, " ", 2) user, err := authSvc.ValidateToken(token)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorisation format"})
return
}
user, err := authSvc.ValidateToken(parts[1])
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()})
return return
@@ -32,3 +26,21 @@ func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
c.Next() 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 ""
}
+17
View File
@@ -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
+16 -8
View File
@@ -1,11 +1,13 @@
import WebClient from "@/shared/web-client" import WebClient from "@/shared/web-client"
export interface Artefact { export interface Artefact {
id: string id: number
name: string
version: string version: string
checksum: string fileName: string
size: number fileSize: number
sha256Hash: string
isDelta: boolean
deltaRef: string
createdAt: string createdAt: string
} }
@@ -33,11 +35,17 @@ export async function getArtefacts(query?: ArtefactsQuery): Promise<ArtefactsRes
return data return data
} }
export async function uploadArtefact(file: File): Promise<Artefact> { export async function uploadArtefact(version: string, file: File): Promise<Artefact> {
const { data } = await WebClient.post<Artefact>("/artefacts/upload", file) const form = new FormData()
form.append("version", version)
form.append("file", file)
const { data } = await WebClient.post<Artefact>("/artefacts", form, {
headers: { "Content-Type": "multipart/form-data" },
})
return data return data
} }
export async function deleteArtefact(id: string): Promise<void> { export async function deleteArtefact(id: number): Promise<void> {
await WebClient.delete<void>(`/artefacts/${id}`) await WebClient.delete(`/artefacts/${id}`)
} }
+4 -5
View File
@@ -5,11 +5,10 @@ export interface LoginRequest {
password: string password: string
} }
export interface LoginResponse { export async function login(credentials: LoginRequest): Promise<void> {
token: string await WebClient.post("/auth/login", credentials)
} }
export async function login(credentials: LoginRequest): Promise<LoginResponse> { export async function logout(): Promise<void> {
const { data } = await WebClient.post<LoginResponse>("/auth/login", credentials) await WebClient.post("/auth/logout")
return data
} }
+6 -4
View File
@@ -1,6 +1,5 @@
import { NavLink, useNavigate, Outlet } from "react-router-dom" import { NavLink, useNavigate, Outlet } from "react-router-dom"
import { useDispatch } from "react-redux" import { useDispatch } from "react-redux"
import { setToken } from "@/store/auth-slice"
import { import {
Drawer, Drawer,
List, List,
@@ -21,6 +20,8 @@ import {
People, People,
Logout, Logout,
} from "@mui/icons-material" } from "@mui/icons-material"
import { logoutAction } from "@/store/auth-slice"
import { logout } from "@/api/auth"
const drawerWidth = 240 const drawerWidth = 240
@@ -28,8 +29,9 @@ export default function Layout() {
const navigate = useNavigate() const navigate = useNavigate()
const dispatch = useDispatch() const dispatch = useDispatch()
function handleLogout() { async function handleLogout() {
dispatch(setToken("")) dispatch(logoutAction())
await logout().catch(() => {})
navigate("/") navigate("/")
} }
@@ -50,7 +52,7 @@ export default function Layout() {
}}> }}>
<Toolbar> <Toolbar>
<Typography variant="h6" noWrap component="div"> <Typography variant="h6" noWrap component="div">
OTA Manager Pipely
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
+10 -10
View File
@@ -1,5 +1,4 @@
import { useEffect, useState, type MouseEvent } from "react" import { useEffect, useState, type MouseEvent } from "react"
import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
import { import {
Box, Box,
Button, Button,
@@ -24,6 +23,7 @@ import {
Pagination, Pagination,
} from "@mui/material" } from "@mui/material"
import { Upload, Delete, Archive } from "@mui/icons-material" import { Upload, Delete, Archive } from "@mui/icons-material"
import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -34,7 +34,7 @@ export default function Artefacts() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = 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 [uploading, setUploading] = useState(false)
const [version, setVersion] = useState("") const [version, setVersion] = useState("")
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
@@ -73,7 +73,7 @@ export default function Artefacts() {
setUploading(true) setUploading(true)
try { try {
await uploadArtefact(file) await uploadArtefact(version, file)
setVersion("") setVersion("")
setFile(null) setFile(null)
setDialogOpen(false) 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 }) setDeleteTarget({ id, version })
setDeleteDialogOpen(true) setDeleteDialogOpen(true)
} }
@@ -203,10 +203,10 @@ export default function Artefacts() {
<TableCell> <TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Archive fontSize="small" color="action" /> <Archive fontSize="small" color="action" />
{a.name} {a.fileName}
</Box> </Box>
</TableCell> </TableCell>
<TableCell>{formatSize(a.size)}</TableCell> <TableCell>{formatSize(a.fileSize)}</TableCell>
<TableCell> <TableCell>
<Box <Box
component="span" component="span"
@@ -215,8 +215,8 @@ export default function Artefacts() {
fontSize: 12, fontSize: 12,
cursor: "help", cursor: "help",
}} }}
title={a.checksum}> title={a.sha256Hash}>
{a.checksum.slice(0, 12)}... {a.sha256Hash.slice(0, 12)}...
</Box> </Box>
</TableCell> </TableCell>
<TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell> <TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell>
@@ -280,6 +280,6 @@ export default function Artefacts() {
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + " B" if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB"
return (bytes / (1024 * 1024)).toFixed(1) + " MB" return (bytes / (1024 * 1024)).toFixed(1) + " MiB"
} }
+1 -1
View File
@@ -45,7 +45,7 @@ export default function Deployments() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false) 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 [name, setName] = useState("")
const [artefactId, setArtefactId] = useState("") const [artefactId, setArtefactId] = useState("")
const [targetGroups, setTargetGroups] = useState("") const [targetGroups, setTargetGroups] = useState("")
+7 -4
View File
@@ -1,6 +1,8 @@
import { useState, type SubmitEvent } from "react" import { useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { useDispatch } from "react-redux"
import { login } from "@/api/auth" import { login } from "@/api/auth"
import { loginAction } from "@/store/auth-slice"
import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material" import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material"
export default function Login() { export default function Login() {
@@ -9,15 +11,16 @@ export default function Login() {
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const dispatch = useDispatch()
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
setError("") setError("")
setLoading(true) setLoading(true)
try { try {
const res = await login({ username, password }) await login({ username, password })
console.log(res.token) dispatch(loginAction())
navigate("/dashboard") navigate("/dashboard")
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Login failed") setError(err instanceof Error ? err.message : "Login failed")
+1 -1
View File
@@ -4,7 +4,7 @@ import ErrorPage from "@/components/ErrorPage"
import Layout from "@/components/Layout" import Layout from "@/components/Layout"
import EmptyLayout from "@/components/EmptyLayout" import EmptyLayout from "@/components/EmptyLayout"
import { isAuthenticated } from "@/store/auth-slice" import { isAuthenticated } from "@/store/auth-slice"
import { useAppSelector } from "@/store" import { useAppSelector } from "@/hooks/store"
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) { function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
return async () => { return async () => {
+4 -1
View File
@@ -1,7 +1,10 @@
import axios from "axios" import axios from "axios"
import dayjs from "@/lib/dayjs" import dayjs from "@/lib/dayjs"
export default axios.create({ const WebClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(), timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
withCredentials: true,
}) })
export default WebClient
+10 -7
View File
@@ -1,24 +1,27 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { createSlice } from "@reduxjs/toolkit"
export interface AuthState { export interface AuthState {
token: string authenticated: boolean
} }
const initialState: AuthState = { const initialState: AuthState = {
token: "", authenticated: false,
} }
const authSlice = createSlice({ const authSlice = createSlice({
name: "auth", name: "auth",
initialState, initialState,
reducers: { reducers: {
setToken(state, action: PayloadAction<string>) { login(state) {
state.token = action.payload 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 authReducer = authSlice.reducer
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.token !== "" export const isAuthenticated = (state: { auth: AuthState }) => state.auth.authenticated
-2
View File
@@ -42,5 +42,3 @@ export default store
export type RootState = ReturnType<typeof rootReducer> export type RootState = ReturnType<typeof rootReducer>
export type AppDispatch = typeof store.dispatch export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store export type AppStore = typeof store
export { useAppSelector, useAppDispatch } from "./hooks"