feat: add logout functionality and auto-logout on 401 responses

Add AUTH_EXPIRED_EVENT constant and dispatch event on 401 responses in API client, clearing stored token. Add handleLogout function to App component and wire up event listener to trigger logout on auth expiration. Pass onLogout prop to Layout component and add Logout button to topbar-actions. Update CSS to apply flex layout to topbar-actions and make responsive. Add backend hostname and network aliases in docker-compose to ensure consistent
This commit is contained in:
2026-03-17 19:39:13 +01:00
parent 61d2b4b25c
commit e2362c6033
5 changed files with 40 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export const AUTH_EXPIRED_EVENT = "nexavpn-admin-auth-expired";
export type User = { export type User = {
id: string; id: string;
@@ -85,6 +86,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem("nexavpn_admin_token");
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT));
}
throw new Error(`Request failed: ${response.status}`); throw new Error(`Request failed: ${response.status}`);
} }

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { AUTH_EXPIRED_EVENT } from "../api/client";
import { Layout } from "../components/Layout"; import { Layout } from "../components/Layout";
import { AuditPage } from "../features/audit/AuditPage"; import { AuditPage } from "../features/audit/AuditPage";
import { LoginPage } from "../features/auth/LoginPage"; import { LoginPage } from "../features/auth/LoginPage";
@@ -20,13 +21,24 @@ export function App() {
setToken(accessToken); setToken(accessToken);
} }
function handleLogout() {
localStorage.removeItem("nexavpn_admin_token");
setToken("");
}
useEffect(() => {
const onExpired = () => handleLogout();
window.addEventListener(AUTH_EXPIRED_EVENT, onExpired);
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired);
}, []);
return ( return (
<Routes> <Routes>
<Route <Route
path="/login" path="/login"
element={authenticated ? <Navigate to="/" replace /> : <LoginPage onAuthenticated={handleAuthenticated} />} element={authenticated ? <Navigate to="/" replace /> : <LoginPage onAuthenticated={handleAuthenticated} />}
/> />
<Route element={authenticated ? <Layout /> : <Navigate to="/login" replace />}> <Route element={authenticated ? <Layout onLogout={handleLogout} /> : <Navigate to="/login" replace />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/devices" element={<DevicesPage />} /> <Route path="/devices" element={<DevicesPage />} />

View File

@@ -10,7 +10,11 @@ const items = [
["Settings", "/settings"] ["Settings", "/settings"]
]; ];
export function Layout() { type LayoutProps = {
onLogout: () => void;
};
export function Layout({ onLogout }: LayoutProps) {
return ( return (
<div className="shell"> <div className="shell">
<aside className="sidebar"> <aside className="sidebar">
@@ -43,7 +47,12 @@ export function Layout() {
<h2>Self-hosted VPN management</h2> <h2>Self-hosted VPN management</h2>
</div> </div>
</div> </div>
<div className="pill">Secure by design</div> <div className="topbar-actions">
<div className="pill">Secure by design</div>
<button className="ghost-button" onClick={onLogout} type="button">
Logout
</button>
</div>
</header> </header>
<Outlet /> <Outlet />
</main> </main>

View File

@@ -63,7 +63,8 @@ button {
.auth-brand, .auth-brand,
.brand-block, .brand-block,
.topbar-brand { .topbar-brand,
.topbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
@@ -300,6 +301,7 @@ button {
.auth-brand, .auth-brand,
.brand-block, .brand-block,
.topbar-brand, .topbar-brand,
.topbar-actions,
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;

View File

@@ -18,6 +18,7 @@ services:
build: build:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
hostname: backend
env_file: env_file:
- .env - .env
depends_on: depends_on:
@@ -25,8 +26,12 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:
- control control:
- gateway aliases:
- backend
gateway:
aliases:
- backend
admin-web: admin-web:
build: build: