Skip to content

Commit 6c6f031

Browse files
rootclaude
andcommitted
feat: Add authentication system to secure the application
- Implement session-based authentication with SQLite database - Add user management tables (users, sessions) to database schema - Create authentication API endpoints (/api/auth/login, /logout, /me, /setup) - Add login and initial setup pages with proper UI - Implement AuthProvider context for global authentication state - Add ProtectedRoute component to secure main application - Include logout functionality in the UI - Support bcrypt password hashing for secure storage - Implement 24-hour session expiration - Add automatic redirect to login for unauthenticated users This authentication system ensures the application can be safely exposed to the internet by requiring users to log in before accessing any Proxmox script management functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent be65cee commit 6c6f031

File tree

15 files changed

+1057
-65
lines changed

15 files changed

+1057
-65
lines changed

middleware.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextResponse } from 'next/server';
2+
import type { NextRequest } from 'next/server';
3+
import { getDatabase } from './src/server/database';
4+
5+
// Paths that don't require authentication
6+
const publicPaths = [
7+
'/api/auth/login',
8+
'/api/auth/setup',
9+
'/login',
10+
'/setup',
11+
'/_next',
12+
'/favicon',
13+
'/favicon.ico',
14+
'/favicon.png'
15+
];
16+
17+
// Paths that should redirect to setup if no users exist
18+
const protectedPaths = [
19+
'/',
20+
'/api/servers',
21+
'/api/trpc'
22+
];
23+
24+
export function middleware(request: NextRequest) {
25+
const { pathname } = request.nextUrl;
26+
27+
// Allow public paths
28+
if (publicPaths.some(path => pathname.startsWith(path))) {
29+
return NextResponse.next();
30+
}
31+
32+
try {
33+
const db = getDatabase();
34+
35+
// Check if this is a first-time setup (no admin user exists)
36+
const adminUser = db.getUserByUsername('admin');
37+
if (!adminUser && protectedPaths.some(path => pathname.startsWith(path))) {
38+
// Redirect to setup page
39+
return NextResponse.redirect(new URL('/setup', request.url));
40+
}
41+
42+
// Check for valid session
43+
const sessionId = request.cookies.get('session')?.value;
44+
if (!sessionId) {
45+
// Redirect to login
46+
return NextResponse.redirect(new URL('/login', request.url));
47+
}
48+
49+
const session = db.getSession(sessionId);
50+
if (!session) {
51+
// Invalid session, redirect to login
52+
const response = NextResponse.redirect(new URL('/login', request.url));
53+
response.cookies.delete('session');
54+
return response;
55+
}
56+
57+
// Valid session, allow access
58+
return NextResponse.next();
59+
60+
} catch (error) {
61+
console.error('Middleware error:', error);
62+
// On error, redirect to login
63+
return NextResponse.redirect(new URL('/login', request.url));
64+
}
65+
}
66+
67+
export const config = {
68+
matcher: [
69+
/*
70+
* Match all request paths except for the ones starting with:
71+
* - api/auth (authentication routes)
72+
* - _next/static (static files)
73+
* - _next/image (image optimization files)
74+
* - favicon.ico (favicon file)
75+
*/
76+
'/((?!api/auth|_next/static|_next/image|favicon.ico).*)',
77+
],
78+
};

package-lock.json

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@xterm/addon-fit": "^0.10.0",
3333
"@xterm/addon-web-links": "^0.11.0",
3434
"@xterm/xterm": "^5.5.0",
35+
"bcryptjs": "^3.0.2",
3536
"better-sqlite3": "^9.6.0",
3637
"next": "^15.5.3",
3738
"node-pty": "^1.0.0",
@@ -42,6 +43,7 @@
4243
"server-only": "^0.0.1",
4344
"strip-ansi": "^7.1.2",
4445
"superjson": "^2.2.1",
46+
"uuid": "^13.0.0",
4547
"ws": "^8.18.3",
4648
"zod": "^3.24.2"
4749
},
@@ -51,10 +53,12 @@
5153
"@testing-library/jest-dom": "^6.8.0",
5254
"@testing-library/react": "^16.3.0",
5355
"@testing-library/user-event": "^14.6.1",
56+
"@types/bcryptjs": "^2.4.6",
5457
"@types/better-sqlite3": "^7.6.8",
5558
"@types/node": "^24.3.1",
5659
"@types/react": "^19.0.0",
5760
"@types/react-dom": "^19.0.0",
61+
"@types/uuid": "^10.0.0",
5862
"@vitejs/plugin-react": "^5.0.2",
5963
"@vitest/coverage-v8": "^3.2.4",
6064
"@vitest/ui": "^3.2.4",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client';
2+
3+
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
6+
interface User {
7+
id: number;
8+
username: string;
9+
}
10+
11+
interface AuthContextType {
12+
user: User | null;
13+
isLoading: boolean;
14+
logout: () => Promise<void>;
15+
}
16+
17+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
18+
19+
export function useAuth() {
20+
const context = useContext(AuthContext);
21+
if (context === undefined) {
22+
throw new Error('useAuth must be used within an AuthProvider');
23+
}
24+
return context;
25+
}
26+
27+
interface AuthProviderProps {
28+
children: ReactNode;
29+
}
30+
31+
export function AuthProvider({ children }: AuthProviderProps) {
32+
const [user, setUser] = useState<User | null>(null);
33+
const [isLoading, setIsLoading] = useState(true);
34+
const router = useRouter();
35+
36+
useEffect(() => {
37+
// Check authentication status on mount
38+
const checkAuth = async () => {
39+
try {
40+
const response = await fetch('/api/auth/me');
41+
if (response.ok) {
42+
const data = await response.json() as { user: User };
43+
setUser(data.user);
44+
} else {
45+
setUser(null);
46+
}
47+
} catch {
48+
setUser(null);
49+
} finally {
50+
setIsLoading(false);
51+
}
52+
};
53+
54+
void checkAuth();
55+
}, []);
56+
57+
const logout = async () => {
58+
try {
59+
await fetch('/api/auth/logout', { method: 'POST' });
60+
setUser(null);
61+
router.push('/login');
62+
} catch {
63+
// Even if logout request fails, clear user state and redirect
64+
setUser(null);
65+
router.push('/login');
66+
}
67+
};
68+
69+
return (
70+
<AuthContext.Provider value={{ user, isLoading, logout }}>
71+
{children}
72+
</AuthContext.Provider>
73+
);
74+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
6+
export function LogoutButton() {
7+
const [isLoading, setIsLoading] = useState(false);
8+
const router = useRouter();
9+
10+
const handleLogout = async () => {
11+
setIsLoading(true);
12+
13+
try {
14+
const response = await fetch('/api/auth/logout', {
15+
method: 'POST',
16+
});
17+
18+
if (response.ok) {
19+
// Redirect to login page
20+
router.push('/login');
21+
router.refresh();
22+
} else {
23+
console.error('Logout failed');
24+
}
25+
} catch (error) {
26+
console.error('Logout error:', error);
27+
} finally {
28+
setIsLoading(false);
29+
}
30+
};
31+
32+
return (
33+
<button
34+
onClick={handleLogout}
35+
disabled={isLoading}
36+
className="inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 disabled:opacity-50"
37+
title="Sign out"
38+
>
39+
<svg
40+
className="w-5 h-5 mr-2"
41+
fill="none"
42+
stroke="currentColor"
43+
viewBox="0 0 24 24"
44+
xmlns="http://www.w3.org/2000/svg"
45+
>
46+
<path
47+
strokeLinecap="round"
48+
strokeLinejoin="round"
49+
strokeWidth={2}
50+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
51+
/>
52+
</svg>
53+
{isLoading ? 'Signing out...' : 'Sign out'}
54+
</button>
55+
);
56+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client';
2+
3+
import { useEffect, ReactNode } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { useAuth } from './AuthProvider';
6+
7+
interface ProtectedRouteProps {
8+
children: ReactNode;
9+
}
10+
11+
export function ProtectedRoute({ children }: ProtectedRouteProps) {
12+
const { user, isLoading } = useAuth();
13+
const router = useRouter();
14+
15+
useEffect(() => {
16+
if (!isLoading && !user) {
17+
router.push('/login');
18+
}
19+
}, [user, isLoading, router]);
20+
21+
if (isLoading) {
22+
return (
23+
<div className="min-h-screen flex items-center justify-center bg-gray-50">
24+
<div className="text-center">
25+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
26+
<p className="mt-4 text-gray-600">Loading...</p>
27+
</div>
28+
</div>
29+
);
30+
}
31+
32+
if (!user) {
33+
return null; // Will redirect to login
34+
}
35+
36+
return <>{children}</>;
37+
}

0 commit comments

Comments
 (0)