Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createBrowserRouter, Navigate } from "react-router-dom";

import { appRoutes } from "@/app/routes";
import { ProtectedRoute, PublicOnlyRoute } from "@/features/auth/protected-route";
import { ProtectedRoute, PublicOnlyRoute, RequireAdmin } from "@/features/auth/protected-route";
import { AppLayout } from "@/layouts/AppLayout";
import { BannedIpsPage } from "@/pages/banned-ips/BannedIpsPage";
import { DashboardPage } from "@/pages/dashboard/DashboardPage";
import { ForbiddenPage } from "@/pages/forbidden/ForbiddenPage";
import { LoginPage } from "@/pages/login/LoginPage";
Expand Down Expand Up @@ -62,6 +63,15 @@ export const router = createBrowserRouter([
path: appRoutes.logs,
element: <LogsPage />,
},
{
element: <RequireAdmin />,
children: [
{
path: appRoutes.bannedIps,
element: <BannedIpsPage />,
},
],
},
],
},
],
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/app/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe("appRoutes", () => {
expect(appRoutes.dashboard).toBe("/dashboard");
expect(appRoutes.vhosts).toBe("/vhosts");
expect(appRoutes.policies).toBe("/policies");
expect(appRoutes.bannedIps).toBe("/banned-ips");
});

it("builds vhost detail paths", () => {
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const appRoutes = {
vhosts: "/vhosts",
policies: "/policies",
logs: "/logs",
bannedIps: "/banned-ips",
} as const;

export function getVHostDetailPath(vhostId: string | number) {
Expand Down
17 changes: 17 additions & 0 deletions src/frontend/src/components/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@ export function AlertTriangleIcon(props: IconProps) {
);
}

export function BanIcon(props: IconProps) {
return (
<svg
{...iconProps({
width: 18,
height: 18,
viewBox: "0 0 18 18",
strokeWidth: 1.5,
...props,
})}
>
<circle cx="9" cy="9" r="6" />
<line x1="4.8" y1="4.8" x2="13.2" y2="13.2" />
</svg>
);
}

export function PulseIcon(props: IconProps) {
return (
<svg
Expand Down
17 changes: 13 additions & 4 deletions src/frontend/src/components/layout/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@ import { useAuth } from "@/hooks/use-auth";
import { THEMES, type Theme, getThemeStorageKey } from "@/lib/theme";
import { cn } from "@/lib/utils";

const navigation = [
type NavItem = {
to: string;
label: string;
adminOnly?: boolean;
};

const navigation: NavItem[] = [
{ to: appRoutes.dashboard, label: "Dashboard" },
{ to: appRoutes.vhosts, label: "VHosts" },
{ to: appRoutes.policies, label: "Policies" },
{ to: appRoutes.logs, label: "Logs" },
{ to: appRoutes.bannedIps, label: "Banned IPs", adminOnly: true },
];

function navLinkClass(isActive: boolean, pill = false) {
Expand Down Expand Up @@ -61,7 +68,9 @@ function applyTheme(theme: Theme) {

export function NavBar() {
const navigate = useNavigate();
const { signOut, user } = useAuth();
const { signOut, user, hasRole } = useAuth();
const isAdmin = hasRole("admin");
const visibleNavigation = navigation.filter((item) => !item.adminOnly || isAdmin);
const runtimeStatus = useRuntimeStatus();
const { showNotice } = useApplyNotice();
const [mobileOpen, setMobileOpen] = useState(false);
Expand Down Expand Up @@ -116,7 +125,7 @@ export function NavBar() {
</Link>

<nav className="hidden items-center gap-1 md:flex" role="navigation">
{navigation.map((item) => (
{visibleNavigation.map((item) => (
<NavLink
key={item.to}
to={item.to}
Expand Down Expand Up @@ -198,7 +207,7 @@ export function NavBar() {
</Button>
</div>

{navigation.map((item) => (
{visibleNavigation.map((item) => (
<NavLink
key={item.to}
to={item.to}
Expand Down
33 changes: 32 additions & 1 deletion src/frontend/src/features/auth/protected-route.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { AuthContext } from "@/features/auth/auth-context.shared";
import type { AuthContextValue } from "@/features/auth/auth-context.types";

import { ProtectedRoute, PublicOnlyRoute } from "./protected-route";
import { ProtectedRoute, PublicOnlyRoute, RequireAdmin } from "./protected-route";

function makeAuthContextValue(
overrides: Partial<AuthContextValue> = {},
Expand Down Expand Up @@ -73,4 +73,35 @@ describe("route guards", () => {

expect(screen.getByText("Dashboard screen")).toBeInTheDocument();
});

function renderAdminGuard(hasAdminRole: boolean) {
render(
<AuthContext.Provider
value={makeAuthContextValue({
isAuthenticated: true,
role: hasAdminRole ? "admin" : "viewer",
hasRole: vi.fn().mockReturnValue(hasAdminRole),
})}
>
<MemoryRouter initialEntries={["/banned-ips"]}>
<Routes>
<Route element={<RequireAdmin />}>
<Route path="/banned-ips" element={<div>Admin-only screen</div>} />
</Route>
<Route path="/forbidden" element={<div>Forbidden screen</div>} />
</Routes>
</MemoryRouter>
</AuthContext.Provider>,
);
}

it("lets admins into admin-only routes", () => {
renderAdminGuard(true);
expect(screen.getByText("Admin-only screen")).toBeInTheDocument();
});

it("redirects non-admins from admin-only routes to forbidden", () => {
renderAdminGuard(false);
expect(screen.getByText("Forbidden screen")).toBeInTheDocument();
});
});
10 changes: 10 additions & 0 deletions src/frontend/src/features/auth/protected-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export function ProtectedRoute() {
return <Outlet />;
}

export function RequireAdmin() {
const { hasRole } = useAuth();

if (!hasRole("admin")) {
return <Navigate to={appRoutes.forbidden} replace />;
}

return <Outlet />;
}

export function PublicOnlyRoute() {
const { isAuthenticated, isLoading } = useAuth();

Expand Down
72 changes: 72 additions & 0 deletions src/frontend/src/features/banned-ips/UnbanIpDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useState } from "react";

import { Modal } from "@/components/shared/Modal";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api-client";

import { unbanIp } from "./api";
import type { BannedIp } from "./types";

type UnbanIpDialogProps = {
bannedIp: BannedIp;
onSuccess: () => void;
onClose: () => void;
};

export function UnbanIpDialog({ bannedIp, onSuccess, onClose }: UnbanIpDialogProps) {
const { accessToken } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);

async function handleUnban() {
if (!accessToken) return;
setSubmitting(true);
setServerError(null);
try {
await unbanIp(accessToken, bannedIp.ip);
onSuccess();
} catch (err) {
setServerError(
err instanceof ApiError ? err.detail : "An unexpected error occurred",
);
setSubmitting(false);
}
}

return (
<Modal
title="Unban IP address"
onClose={onClose}
footer={
<>
<Button type="button" onClick={onClose} variant="outline">
Cancel
</Button>
<Button
type="button"
disabled={submitting}
onClick={() => void handleUnban()}
variant="destructive"
>
{submitting ? "Unbanning…" : "Unban"}
</Button>
</>
}
>
{serverError && (
<Alert variant="destructive" aria-live="assertive">
{serverError}
</Alert>
)}
<p className="text-sm text-foreground">
Are you sure you want to unban{" "}
<span className="font-mono font-semibold text-foreground">{bannedIp.ip}</span>{" "}
across <span className="font-semibold text-foreground">all virtual hosts</span>?
This clears the address from every active ban table, not just{" "}
<span className="font-semibold text-foreground">{bannedIp.domain}</span>.
</p>
</Modal>
);
}
68 changes: 68 additions & 0 deletions src/frontend/src/features/banned-ips/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { listBannedIps, unbanIp } from "./api";

function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}

describe("banned-ips API", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

it("fetches the banned-ips list", async () => {
vi.stubEnv("VITE_API_BASE_URL", "");
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
items: [
{
ip: "203.0.113.10",
vhost_id: 1,
domain: "app.example.com",
gpc0: 12,
ban_threshold: 10,
banned: true,
expires_in_seconds: 120,
},
],
total: 1,
}),
);

const response = await listBannedIps("token");

expect(response.items[0]?.ip).toBe("203.0.113.10");
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/v1/security/banned-ips");
});

it("sends a DELETE request with the IP URI-encoded in the path", async () => {
vi.stubEnv("VITE_API_BASE_URL", "");
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(jsonResponse({ ip: "203.0.113.10", cleared: 1 }));

const response = await unbanIp("token", "203.0.113.10");

expect(response).toEqual({ ip: "203.0.113.10", cleared: 1 });
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/v1/security/banned-ips/203.0.113.10");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Encoding coverage only asserts a simple IPv4 path segment. unbanIp correctly uses encodeURIComponent, which matters for IPv6 (:%3A), but there is no regression test for that case.

Suggestion: Add one case with an IPv6 literal (e.g. 2001:db8::1) expecting the encoded path. Low priority.

expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE");
});

it("URI-encodes IPv6 literals in the delete path", async () => {
vi.stubEnv("VITE_API_BASE_URL", "");
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(jsonResponse({ ip: "2001:db8::1", cleared: 1 }));

await unbanIp("token", "2001:db8::1");

expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/api/v1/security/banned-ips/2001%3Adb8%3A%3A1",
);
});
});
14 changes: 14 additions & 0 deletions src/frontend/src/features/banned-ips/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { apiRequest } from "@/lib/api-client";

import type { BannedIpListResponse, UnbanResponse } from "./types";

export function listBannedIps(token: string, signal?: AbortSignal) {
return apiRequest<BannedIpListResponse>("/security/banned-ips", { token, signal });
}

export function unbanIp(token: string, ip: string) {
return apiRequest<UnbanResponse>(`/security/banned-ips/${encodeURIComponent(ip)}`, {
method: "DELETE",
token,
});
}
19 changes: 19 additions & 0 deletions src/frontend/src/features/banned-ips/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type BannedIp = {
ip: string;
vhost_id: number;
domain: string;
gpc0: number;
ban_threshold: number;
banned: boolean;
expires_in_seconds: number;
};

export type BannedIpListResponse = {
items: BannedIp[];
total: number;
};

export type UnbanResponse = {
ip: string;
cleared: number;
};
Loading
Loading