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:
@@ -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
|
||||
@@ -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<ArtefactsRes
|
||||
return data
|
||||
}
|
||||
|
||||
export async function uploadArtefact(file: File): Promise<Artefact> {
|
||||
const { data } = await WebClient.post<Artefact>("/artefacts/upload", file)
|
||||
export async function uploadArtefact(version: string, file: File): Promise<Artefact> {
|
||||
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
|
||||
}
|
||||
|
||||
export async function deleteArtefact(id: string): Promise<void> {
|
||||
await WebClient.delete<void>(`/artefacts/${id}`)
|
||||
export async function deleteArtefact(id: number): Promise<void> {
|
||||
await WebClient.delete(`/artefacts/${id}`)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ export interface LoginRequest {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
export async function login(credentials: LoginRequest): Promise<void> {
|
||||
await WebClient.post("/auth/login", credentials)
|
||||
}
|
||||
|
||||
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await WebClient.post<LoginResponse>("/auth/login", credentials)
|
||||
return data
|
||||
}
|
||||
export async function logout(): Promise<void> {
|
||||
await WebClient.post("/auth/logout")
|
||||
}
|
||||
@@ -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() {
|
||||
}}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
OTA Manager
|
||||
Pipely
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
+10
-10
@@ -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<File | null>(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() {
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Archive fontSize="small" color="action" />
|
||||
{a.name}
|
||||
{a.fileName}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{formatSize(a.size)}</TableCell>
|
||||
<TableCell>{formatSize(a.fileSize)}</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
component="span"
|
||||
@@ -215,8 +215,8 @@ export default function Artefacts() {
|
||||
fontSize: 12,
|
||||
cursor: "help",
|
||||
}}
|
||||
title={a.checksum}>
|
||||
{a.checksum.slice(0, 12)}...
|
||||
title={a.sha256Hash}>
|
||||
{a.sha256Hash.slice(0, 12)}...
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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")
|
||||
|
||||
@@ -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<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
|
||||
return async () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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<string>) {
|
||||
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
|
||||
@@ -42,5 +42,3 @@ export default store
|
||||
export type RootState = ReturnType<typeof rootReducer>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
export type AppStore = typeof store
|
||||
|
||||
export { useAppSelector, useAppDispatch } from "./hooks"
|
||||
|
||||
Reference in New Issue
Block a user