Skip to content
Draft
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
53 changes: 52 additions & 1 deletion components/frontend/next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,57 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone'
output: 'standalone',

async headers() {
return [
{
// Apply security headers to all routes
source: '/:path*',
headers: [
// Content Security Policy
// - script-src: Allow 'self' scripts only (next-themes handles dark mode without inline scripts)
// - style-src: Allow 'self' and 'unsafe-inline' (required for Tailwind CSS)
// - connect-src: Allow 'self' and WebSocket connections (ws:/wss:) for backend communication
// - default-src: Restrict all other resources to same-origin only
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"connect-src 'self' ws: wss:",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; '),
},
// Prevent clickjacking attacks by disallowing iframe embedding
{
key: 'X-Frame-Options',
value: 'DENY',
},
// Prevent MIME type sniffing
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
// Control referrer information sent with requests
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
// Restrict browser features and APIs
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
},
],
},
];
},
}

module.exports = nextConfig
1 change: 1 addition & 0 deletions components/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"highlight.js": "^11.11.1",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"next-themes": "^0.4.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
Expand Down
18 changes: 13 additions & 5 deletions components/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { Navigation } from "@/components/navigation";
import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { Toaster } from "@/components/ui/toaster";
import { env } from "@/lib/env";

Expand All @@ -27,11 +28,18 @@ export default function RootLayout({
<meta name="backend-ws-base" content={wsBase} />
</head>
<body className={`${inter.className} min-h-screen flex flex-col`} suppressHydrationWarning>
<QueryProvider>
<Navigation feedbackUrl={feedbackUrl} />
<main className="flex-1 bg-background overflow-auto">{children}</main>
<Toaster />
</QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryProvider>
<Navigation feedbackUrl={feedbackUrl} />
<main className="flex-1 bg-background overflow-auto">{children}</main>
<Toaster />
</QueryProvider>
</ThemeProvider>
</body>
</html>
);
Expand Down
4 changes: 3 additions & 1 deletion components/frontend/src/components/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { UserBubble } from "@/components/user-bubble";
import ThemeToggle from "@/components/theme-toggle";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Plug, LogOut } from "lucide-react";
import { useVersion } from "@/services/queries/use-version";
Expand Down Expand Up @@ -44,7 +45,7 @@ export function Navigation({ feedbackUrl }: NavigationProps) {
</div>
<div className="flex items-center gap-3">
{feedbackUrl && (
<a
<a
href={feedbackUrl}
target="_blank"
rel="noopener noreferrer"
Expand All @@ -53,6 +54,7 @@ export function Navigation({ feedbackUrl }: NavigationProps) {
Share feedback
</a>
)}
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger className="outline-none">
<UserBubble />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client';

import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
24 changes: 24 additions & 0 deletions components/frontend/src/components/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';

export default function ThemeToggle() {
const { theme, setTheme } = useTheme();

return (
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
{theme === 'light' ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
);
}
Loading