diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c41b214 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,15 @@ +# +# CODEOWNERS for ProxmoxVE +# + +# Order is important; the last matching pattern takes the most +# precedence. + + +# Codeowners for specific folders and files +# Remember ending folders with / + + +# Set default reviewers +* @community-scripts/Contributor + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a344bd0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: "🐞 Script Issue Report" +description: Report a specific issue. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + ## ⚠️ **IMPORTANT - READ FIRST** + - 🔍 **Search first:** Before submitting, check if the issue has already been reported or resolved in [closed issues](https://github.com/community-scripts/ProxmoxVE-Local/issues?q=is%3Aissue+is%3Aclosed). If found, comment on that issue instead of creating a new one. + Thank you for taking the time to report an issue! Please provide as much detail as possible to help us address the problem efficiently. + + + - type: input + id: guidelines + attributes: + label: ✅ Have you read and understood the above guidelines? + placeholder: "yes" + validations: + required: true + + - type: textarea + id: issue_description + attributes: + label: 📝 Provide a clear and concise description of the issue. + validations: + required: true + + - type: textarea + id: steps_to_reproduce + attributes: + label: 🔄 Steps to reproduce the issue. + placeholder: "e.g., Step 1: ..., Step 2: ..." + validations: + required: true + + - type: textarea + id: error_output + attributes: + label: ❌ Paste the full error output (if available). + placeholder: "Include any relevant logs or error messages." + validations: + required: true + + - type: textarea + id: additional_context + attributes: + label: 🖼️ Additional context (optional). + placeholder: "Include screenshots, code blocks (use triple backticks ```), or any other relevant information." + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7f06527 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: "✨ Feature Request" +description: "Suggest a new feature or enhancement." +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + # ✨ **Feature Request** + Have an idea for a new feature? Share your thoughts below! + + - type: input + id: feature_summary + attributes: + label: "🌟 Briefly describe the feature" + placeholder: "e.g., Add support for XYZ" + validations: + required: true + + - type: textarea + id: feature_description + attributes: + label: "📝 Detailed description" + placeholder: "Explain the feature in detail" + validations: + required: true + + - type: textarea + id: use_case + attributes: + label: "💡 Why is this useful?" + placeholder: "Describe the benefit of this feature" + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fbd9308 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ + +## ✍️ Description + + + +## 🔗 Related PR / Issue +Link: # + + +## ✅ Prerequisites (**X** in brackets) + +- [ ] **Self-review completed** – Code follows project standards. +- [ ] **Tested thoroughly** – Changes work as expected. +- [ ] **No security risks** – No hardcoded secrets, unnecessary privilege escalations, or permission issues. + +## Screenshot for frontend Change + +--- + +## 🛠️ Type of Change (**X** in brackets) + +- [ ] 🐞 **Bug fix** – Resolves an issue without breaking functionality. +- [ ] ✨ **New feature** – Adds new, non-breaking functionality. +- [ ] 💥 **Breaking change** – Alters existing functionality in a way that may require updates. diff --git a/README.md b/README.md index 371ab4b..0746405 100644 --- a/README.md +++ b/README.md @@ -301,4 +301,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- -**Note**: This is beat software. Use with caution in production environments and always backup your Proxmox configuration before running scripts. +**Note**: This is beta software. Use with caution in production environments and always backup your Proxmox configuration before running scripts. diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..e092dab --- /dev/null +++ b/middleware.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getDatabase } from './src/server/database'; + +// Paths that don't require authentication +const publicPaths = [ + '/api/auth/login', + '/api/auth/setup', + '/login', + '/setup', + '/_next', + '/favicon', + '/favicon.ico', + '/favicon.png' +]; + +// Paths that should redirect to setup if no users exist +const protectedPaths = [ + '/', + '/api/servers', + '/api/trpc' +]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Allow public paths + if (publicPaths.some(path => pathname.startsWith(path))) { + return NextResponse.next(); + } + + try { + const db = getDatabase(); + + // Check if this is a first-time setup (no admin user exists) + const adminUser = db.getUserByUsername('admin'); + if (!adminUser && protectedPaths.some(path => pathname.startsWith(path))) { + // Redirect to setup page + return NextResponse.redirect(new URL('/setup', request.url)); + } + + // Check for valid session + const sessionId = request.cookies.get('session')?.value; + if (!sessionId) { + // Redirect to login + return NextResponse.redirect(new URL('/login', request.url)); + } + + const session = db.getSession(sessionId); + if (!session) { + // Invalid session, redirect to login + const response = NextResponse.redirect(new URL('/login', request.url)); + response.cookies.delete('session'); + return response; + } + + // Valid session, allow access + return NextResponse.next(); + + } catch (error) { + console.error('Middleware error:', error); + // On error, redirect to login + return NextResponse.redirect(new URL('/login', request.url)); + } +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api/auth (authentication routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!api/auth|_next/static|_next/image|favicon.ico).*)', + ], +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c8ca24e..9b31e3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "bcryptjs": "^3.0.2", "better-sqlite3": "^9.6.0", "next": "^15.5.3", "node-pty": "^1.0.0", @@ -28,6 +29,7 @@ "server-only": "^0.0.1", "strip-ansi": "^7.1.2", "superjson": "^2.2.1", + "uuid": "^13.0.0", "ws": "^8.18.3", "zod": "^3.24.2" }, @@ -37,10 +39,12 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.8", "@types/node": "^24.3.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.0.2", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", @@ -2804,6 +2808,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -2910,6 +2921,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4067,6 +4085,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", @@ -10183,6 +10210,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", diff --git a/package.json b/package.json index 01096c1..de8f2ef 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "bcryptjs": "^3.0.2", "better-sqlite3": "^9.6.0", "next": "^15.5.3", "node-pty": "^1.0.0", @@ -42,6 +43,7 @@ "server-only": "^0.0.1", "strip-ansi": "^7.1.2", "superjson": "^2.2.1", + "uuid": "^13.0.0", "ws": "^8.18.3", "zod": "^3.24.2" }, @@ -51,10 +53,12 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.8", "@types/node": "^24.3.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.0.2", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", diff --git a/src/app/_components/AuthProvider.tsx b/src/app/_components/AuthProvider.tsx new file mode 100644 index 0000000..76910d7 --- /dev/null +++ b/src/app/_components/AuthProvider.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; + +interface User { + id: number; + username: string; +} + +interface AuthContextType { + user: User | null; + isLoading: boolean; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + useEffect(() => { + // Check authentication status on mount + const checkAuth = async () => { + try { + const response = await fetch('/api/auth/me'); + if (response.ok) { + const data = await response.json() as { user: User }; + setUser(data.user); + } else { + setUser(null); + } + } catch { + setUser(null); + } finally { + setIsLoading(false); + } + }; + + void checkAuth(); + }, []); + + const logout = async () => { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + setUser(null); + router.push('/login'); + } catch { + // Even if logout request fails, clear user state and redirect + setUser(null); + router.push('/login'); + } + }; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/_components/LogoutButton.tsx b/src/app/_components/LogoutButton.tsx new file mode 100644 index 0000000..e5b7dcb --- /dev/null +++ b/src/app/_components/LogoutButton.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export function LogoutButton() { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const handleLogout = async () => { + setIsLoading(true); + + try { + const response = await fetch('/api/auth/logout', { + method: 'POST', + }); + + if (response.ok) { + // Redirect to login page + router.push('/login'); + router.refresh(); + } else { + console.error('Logout failed'); + } + } catch (error) { + console.error('Logout error:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/_components/ProtectedRoute.tsx b/src/app/_components/ProtectedRoute.tsx new file mode 100644 index 0000000..8242410 --- /dev/null +++ b/src/app/_components/ProtectedRoute.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect, ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from './AuthProvider'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !user) { + router.push('/login'); + } + }, [user, isLoading, router]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!user) { + return null; // Will redirect to login + } + + return <>{children}; +} \ No newline at end of file diff --git a/src/app/_components/ServerForm.tsx b/src/app/_components/ServerForm.tsx index 9a35a95..97f7a72 100644 --- a/src/app/_components/ServerForm.tsx +++ b/src/app/_components/ServerForm.tsx @@ -17,6 +17,8 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel ip: '', user: '', password: '', + ssh_key: '', + auth_method: 'password', } ); @@ -43,8 +45,21 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel newErrors.user = 'Username is required'; } - if (!formData.password.trim()) { - newErrors.password = 'Password is required'; + // Validate authentication method + if (formData.auth_method === 'password') { + if (!formData.password?.trim()) { + newErrors.password = 'Password is required for password authentication'; + } + } else if (formData.auth_method === 'ssh_key') { + if (!formData.ssh_key?.trim()) { + newErrors.ssh_key = 'SSH private key is required for key authentication'; + } else { + // Basic SSH key validation + const sshKeyPattern = /^-----BEGIN (RSA|OPENSSH|DSA|EC|ED25519) PRIVATE KEY-----/; + if (!sshKeyPattern.test(formData.ssh_key.trim())) { + newErrors.ssh_key = 'Invalid SSH private key format'; + } + } } setErrors(newErrors); @@ -56,7 +71,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel if (validateForm()) { onSubmit(formData); if (!isEditing) { - setFormData({ name: '', ip: '', user: '', password: '' }); + setFormData({ name: '', ip: '', user: '', password: '', ssh_key: '', auth_method: 'password' }); } } }; @@ -125,6 +140,37 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel {errors.user &&

{errors.user}

} +
+ + +
+ + + {formData.auth_method === 'password' && (
+ )} + + {formData.auth_method === 'ssh_key' && ( +
+ +