feat: initial website commit + installation guide page
- Full nixpanel-website monorepo: web (React/Vite) + api (Fastify) - Add /install page with one-liner, manual steps, requirements, software table, firewall ports, service management, and update/uninstall - Add Install link to header nav and footer - Add 'Install guide' CTA button to home page hero - Add 'outline' variant to Button component - Add .gitignore and .gitattributes Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Vite exposes vars prefixed with VITE_ to the browser
|
||||
VITE_API_URL=https://nixpanel.io/api/v1
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="NixPanel — The open hosting control panel. Manage your servers with confidence." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>NixPanel — Hosting Control Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "nixpanel-website-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"axios": "^1.7.2",
|
||||
"zustand": "^4.5.4",
|
||||
"@stripe/stripe-js": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
import Home from '@/pages/Home';
|
||||
import Pricing from '@/pages/Pricing';
|
||||
import Login from '@/pages/Login';
|
||||
import Register from '@/pages/Register';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import VerifyEmail from '@/pages/VerifyEmail';
|
||||
import ForgotPassword from '@/pages/ForgotPassword';
|
||||
import ResetPassword from '@/pages/ResetPassword';
|
||||
import Install from '@/pages/Install';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore(s => s.token);
|
||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/install" element={<Install />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={
|
||||
<PrivateRoute><Dashboard /></PrivateRoute>
|
||||
} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Button from './ui/Button';
|
||||
|
||||
interface PricingCardProps {
|
||||
name: string;
|
||||
planKey: string;
|
||||
monthlyPrice: number;
|
||||
annualPrice: number;
|
||||
interval: 'monthly' | 'annual';
|
||||
features: string[];
|
||||
limits: {
|
||||
domains: string;
|
||||
accounts: string;
|
||||
emails: string;
|
||||
databases: string;
|
||||
disk: string;
|
||||
};
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export default function PricingCard({
|
||||
name, planKey, monthlyPrice, annualPrice, interval,
|
||||
features, limits, highlighted = false,
|
||||
}: PricingCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const isFree = monthlyPrice === 0;
|
||||
const price = interval === 'annual' ? annualPrice : monthlyPrice;
|
||||
const perMonth = interval === 'annual' ? (annualPrice / 12).toFixed(2) : monthlyPrice.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
relative rounded-2xl border p-8 flex flex-col gap-6
|
||||
${highlighted
|
||||
? 'border-brand-500 bg-brand-900/20 shadow-lg shadow-brand-500/10'
|
||||
: 'border-gray-800 bg-gray-900/50'}
|
||||
`}>
|
||||
{highlighted && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand-600 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
||||
Most Popular
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{name}</h3>
|
||||
<div className="mt-3 flex items-end gap-1">
|
||||
{isFree ? (
|
||||
<span className="text-4xl font-bold text-white">Free</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-4xl font-bold text-white">${perMonth}</span>
|
||||
<span className="text-gray-400 mb-1">/mo</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isFree && interval === 'annual' && (
|
||||
<p className="text-xs text-green-400 mt-1">
|
||||
Billed ${price.toFixed(2)}/yr — 2 months free
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 text-sm text-gray-300 flex-1">
|
||||
<li className="flex gap-2"><Check />{limits.domains} domains</li>
|
||||
<li className="flex gap-2"><Check />{limits.accounts} accounts</li>
|
||||
<li className="flex gap-2"><Check />{limits.emails} email accounts</li>
|
||||
<li className="flex gap-2"><Check />{limits.databases} databases</li>
|
||||
<li className="flex gap-2"><Check />{limits.disk} disk space</li>
|
||||
{features.map(f => (
|
||||
<li key={f} className="flex gap-2"><Check />{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={highlighted ? 'primary' : 'secondary'}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => navigate(isFree ? '/register' : `/register?plan=${planKey}`)}
|
||||
>
|
||||
{isFree ? 'Get started free' : 'Get started'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-gray-800 bg-gray-950 py-10 mt-16">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-500">
|
||||
<span>
|
||||
© {new Date().getFullYear()} NixPanel. All rights reserved.
|
||||
</span>
|
||||
<div className="flex gap-6">
|
||||
<Link to="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
|
||||
<Link to="/install" className="hover:text-gray-300 transition-colors">Install</Link>
|
||||
<a href="https://docs.nixpanel.io" target="_blank" rel="noreferrer"
|
||||
className="hover:text-gray-300 transition-colors">Docs</a>
|
||||
<Link to="/login" className="hover:text-gray-300 transition-colors">Login</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
|
||||
export default function Header() {
|
||||
const { customer, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-b border-gray-800 bg-gray-950/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold text-white">
|
||||
Nix<span className="text-brand-500">Panel</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6 text-sm text-gray-400">
|
||||
<Link to="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<Link to="/pricing" className="hover:text-white transition-colors">Pricing</Link>
|
||||
<Link to="/install" className="hover:text-white transition-colors">Install</Link>
|
||||
<a href="https://docs.nixpanel.io" target="_blank" rel="noreferrer"
|
||||
className="hover:text-white transition-colors">Docs</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{customer ? (
|
||||
<>
|
||||
<Link to="/dashboard"
|
||||
className="text-sm text-gray-300 hover:text-white transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
<button onClick={handleLogout}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-gray-700 text-gray-300 hover:border-gray-500 hover:text-white transition-colors">
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login"
|
||||
className="text-sm text-gray-300 hover:text-white transition-colors">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/register"
|
||||
className="text-sm px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors">
|
||||
Get started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent',
|
||||
secondary: 'bg-transparent hover:bg-gray-800 text-gray-300 border-gray-700',
|
||||
outline: 'bg-transparent hover:bg-brand-900/30 text-brand-400 border-brand-500/50',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
|
||||
ghost: 'bg-transparent hover:bg-gray-800 text-gray-400 border-transparent',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 font-medium rounded-lg border
|
||||
transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-gray-950
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variants[variant]} ${sizes[size]} ${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type InputHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, hint, className = '', id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full px-3 py-2 rounded-lg bg-gray-900 border text-gray-100
|
||||
placeholder:text-gray-600 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${error ? 'border-red-500' : 'border-gray-700'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-gray-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
export default Input;
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100 font-sans antialiased;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { billingApi, licenseApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: 'Free', solo: 'Solo', host: 'Host', agency: 'Agency',
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const customer = useAuthStore(s => s.customer);
|
||||
const [license, setLicense] = useState<any>(null);
|
||||
const [sub, setSub] = useState<any>(null);
|
||||
const [newIp, setNewIp] = useState('');
|
||||
const [ipError, setIpError] = useState('');
|
||||
const [ipMsg, setIpMsg] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ipLoading, setIpLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([licenseApi.get(), billingApi.subscription()])
|
||||
.then(([l, s]) => { setLicense(l.data); setSub(s.data); })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handlePortal() {
|
||||
const { data } = await billingApi.portal();
|
||||
window.location.href = data.url;
|
||||
}
|
||||
|
||||
async function handleIpChange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIpError(''); setIpMsg('');
|
||||
setIpLoading(true);
|
||||
try {
|
||||
await licenseApi.changeIp(newIp);
|
||||
setIpMsg(`IP updated to ${newIp}`);
|
||||
setNewIp('');
|
||||
const l = await licenseApi.get();
|
||||
setLicense(l.data);
|
||||
} catch (err: any) {
|
||||
setIpError(err.response?.data?.error || 'Failed to update IP.');
|
||||
} finally {
|
||||
setIpLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!confirm('Cancel at end of billing period?')) return;
|
||||
await billingApi.cancel();
|
||||
const s = await billingApi.subscription();
|
||||
setSub(s.data);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh] text-gray-500">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-2xl font-bold text-white mb-8">Dashboard</h1>
|
||||
|
||||
{/* Account info */}
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6 mb-6">
|
||||
<h2 className="font-semibold text-white mb-4">Account</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Name</p>
|
||||
<p className="text-white">{customer?.full_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Email</p>
|
||||
<p className="text-white">{customer?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
{sub ? (
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-white">Subscription</h2>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||
sub.status === 'active' ? 'bg-green-900/40 text-green-400' : 'bg-yellow-900/40 text-yellow-400'
|
||||
}`}>
|
||||
{sub.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-4 text-sm mb-6">
|
||||
<div>
|
||||
<p className="text-gray-500">Plan</p>
|
||||
<p className="text-white font-medium">{PLAN_LABELS[sub.plan_key]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Billing</p>
|
||||
<p className="text-white capitalize">{sub.billing_interval}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Renews</p>
|
||||
<p className="text-white">{new Date(sub.current_period_end).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{sub.cancel_at_period_end && (
|
||||
<p className="text-yellow-400 text-sm mb-4">
|
||||
Cancels on {new Date(sub.current_period_end).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button variant="secondary" size="sm" onClick={handlePortal}>
|
||||
Manage billing
|
||||
</Button>
|
||||
{!sub.cancel_at_period_end && (
|
||||
<Button variant="danger" size="sm" onClick={handleCancel}>
|
||||
Cancel plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6 mb-6 text-center">
|
||||
<p className="text-gray-400 mb-4">No active subscription.</p>
|
||||
<Button onClick={() => window.location.href = '/pricing'}>View plans</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License */}
|
||||
{license && (
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6 mb-6">
|
||||
<h2 className="font-semibold text-white mb-4">License</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4 text-sm mb-6">
|
||||
<div>
|
||||
<p className="text-gray-500">License ID</p>
|
||||
<p className="text-white font-mono text-xs">{license.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Server IP</p>
|
||||
<p className="text-white font-mono">{license.server_ip || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Status</p>
|
||||
<p className="text-green-400 capitalize">{license.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Last verified</p>
|
||||
<p className="text-white">{license.last_verified
|
||||
? new Date(license.last_verified).toLocaleString()
|
||||
: 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IP change form */}
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<h3 className="text-sm font-medium text-white mb-3">Change server IP</h3>
|
||||
<form onSubmit={handleIpChange} className="flex gap-3 items-start">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="New IPv4 address"
|
||||
value={newIp}
|
||||
onChange={e => setNewIp(e.target.value)}
|
||||
error={ipError}
|
||||
pattern="^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
||||
title="Valid IPv4 address"
|
||||
required
|
||||
/>
|
||||
{ipMsg && <p className="text-xs text-green-400 mt-1">{ipMsg}</p>}
|
||||
</div>
|
||||
<Button type="submit" loading={ipLoading} variant="secondary" className="mt-0.5">
|
||||
Update IP
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { authApi } from '@/services/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
await authApi.forgotPassword(email).catch(() => {});
|
||||
setSent(true);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (sent) return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="text-5xl mb-4">📬</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Check your inbox</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
If an account exists for <strong className="text-white">{email}</strong>,
|
||||
a reset link has been sent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Reset password</h1>
|
||||
<p className="text-gray-400 text-sm mb-8">Enter your email and we'll send a reset link.</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={loading} size="lg" className="w-full">
|
||||
Send reset link
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
const features = [
|
||||
{ icon: '⚡', title: 'Lightning Fast', body: 'Built for performance. Nginx + Apache support out of the box.' },
|
||||
{ icon: '🔒', title: 'Security First', body: 'Hardened defaults, Let\'s Encrypt SSL, and two-factor auth.' },
|
||||
{ icon: '🌐', title: 'WordPress Ready', body: 'One-click WordPress installs with WP-CLI and auto-updates.' },
|
||||
{ icon: '📊', title: 'Full Control', body: 'Manage DNS, email, databases, and cron jobs in one place.' },
|
||||
{ icon: '🔄', title: 'Easy Migration', body: 'Import from cPanel with a single command.' },
|
||||
{ icon: '💳', title: 'Simple Licensing', body: 'Tied to your server IP. No per-user fees, no surprises.' },
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pt-24 pb-20 text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-brand-900/40 border border-brand-500/30 text-brand-400 text-xs font-medium px-3 py-1.5 rounded-full mb-6">
|
||||
Now in public beta
|
||||
</div>
|
||||
<h1 className="text-5xl sm:text-6xl font-extrabold text-white leading-tight tracking-tight">
|
||||
The hosting control panel<br />
|
||||
<span className="text-brand-500">you actually want to use.</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-gray-400 max-w-2xl mx-auto">
|
||||
NixPanel is a modern, open-source alternative to cPanel. Manage your Linux server,
|
||||
host WordPress sites, and control everything from a clean, fast dashboard.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/register">
|
||||
<Button size="lg">Get started free</Button>
|
||||
</Link>
|
||||
<Link to="/install">
|
||||
<Button size="lg" variant="outline">Install guide</Button>
|
||||
</Link>
|
||||
<Link to="/pricing">
|
||||
<Button size="lg" variant="secondary">View pricing</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<h2 className="text-3xl font-bold text-white text-center mb-12">
|
||||
Everything you need to run a server
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map(f => (
|
||||
<div key={f.title} className="bg-gray-900/50 border border-gray-800 rounded-xl p-6">
|
||||
<div className="text-3xl mb-3">{f.icon}</div>
|
||||
<h3 className="font-semibold text-white mb-2">{f.title}</h3>
|
||||
<p className="text-sm text-gray-400">{f.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="bg-brand-900/20 border border-brand-500/20 rounded-2xl p-12 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Ready to take control?</h2>
|
||||
<p className="text-gray-400 mb-8">Start free. Upgrade when you need more.</p>
|
||||
<Link to="/register">
|
||||
<Button size="lg">Create your account</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={copy}
|
||||
className="ml-3 flex-shrink-0 text-xs px-2.5 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ code, lang = 'bash' }: { code: string; lang?: string }) {
|
||||
return (
|
||||
<div className="relative mt-3 rounded-lg bg-gray-950 border border-gray-800 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900/60">
|
||||
<span className="text-xs text-gray-500 font-mono">{lang}</span>
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="px-4 py-4 text-sm text-gray-300 font-mono overflow-x-auto whitespace-pre leading-relaxed">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-bold mt-0.5">
|
||||
{n}
|
||||
</div>
|
||||
<div className="flex-1 pb-10 border-l border-gray-800 pl-6 ml-[-1.25rem]">
|
||||
<h3 className="text-white font-semibold text-lg mb-2">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ children, color = 'gray' }: { children: React.ReactNode; color?: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
green: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
blue: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
gray: 'bg-gray-800 text-gray-400 border-gray-700',
|
||||
brand: 'bg-brand-900/40 text-brand-400 border-brand-500/30',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center text-xs font-medium px-2.5 py-1 rounded-full border ${colors[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ONE_LINER = `curl -fsSL https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/scripts/install.sh | sudo bash`;
|
||||
|
||||
const MANUAL_CLONE = `git clone https://github.com/NixReaper/nixpanel-server.git /opt/nixpanel
|
||||
cd /opt/nixpanel`;
|
||||
|
||||
const MANUAL_ENV = `cp .env.example .env
|
||||
nano .env # set your passwords and secrets`;
|
||||
|
||||
const MANUAL_INSTALL = `npm install --workspaces --include-workspace-root
|
||||
cd server && npx prisma generate && npx prisma migrate deploy
|
||||
ADMIN_USERNAME=admin ADMIN_PASSWORD=yourpass npx tsx src/db/seed.ts
|
||||
cd ..
|
||||
npm run build --workspace=nixserver
|
||||
npm run build --workspace=nixclient`;
|
||||
|
||||
const MANUAL_SERVICE = `# Copy and enable the systemd service
|
||||
cp scripts/nixpanel.service /etc/systemd/system/nixpanel.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nixpanel`;
|
||||
|
||||
const UPDATE_CMD = `sudo bash /opt/nixpanel/scripts/update.sh`;
|
||||
|
||||
const UNINSTALL_CMD = `sudo bash /opt/nixpanel/scripts/uninstall.sh`;
|
||||
|
||||
const softwareItems = [
|
||||
{ name: 'Node.js 22 LTS', desc: 'Application runtime' },
|
||||
{ name: 'MariaDB', desc: 'Panel database' },
|
||||
{ name: 'Nginx', desc: 'Web server & reverse proxy' },
|
||||
{ name: 'PHP 8.3 + FPM', desc: 'PHP runtime with common extensions' },
|
||||
{ name: 'Postfix + Dovecot', desc: 'SMTP & IMAP mail server' },
|
||||
{ name: 'vsftpd', desc: 'FTP server' },
|
||||
{ name: 'BIND9', desc: 'DNS server' },
|
||||
{ name: 'Certbot', desc: "Let's Encrypt SSL automation" },
|
||||
{ name: 'Fail2ban', desc: 'Brute-force protection' },
|
||||
{ name: 'UFW / firewalld', desc: 'Firewall (auto-configured)' },
|
||||
];
|
||||
|
||||
const ports = [
|
||||
{ port: '2087', name: 'NixServer', desc: 'Admin panel (NixServer)' },
|
||||
{ port: '2083', name: 'NixClient', desc: 'User panel (NixClient)' },
|
||||
{ port: '80', name: 'HTTP', desc: 'Web traffic + ACME challenges' },
|
||||
{ port: '443', name: 'HTTPS', desc: 'SSL web traffic' },
|
||||
{ port: '21', name: 'FTP', desc: 'FTP control' },
|
||||
{ port: '25', name: 'SMTP', desc: 'Inbound mail' },
|
||||
{ port: '587', name: 'Submission', desc: 'Outbound mail (authenticated)' },
|
||||
{ port: '993', name: 'IMAPS', desc: 'Secure IMAP' },
|
||||
{ port: '53', name: 'DNS', desc: 'DNS resolution (TCP + UDP)' },
|
||||
];
|
||||
|
||||
export default function Install() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-brand-900/40 border border-brand-500/30 text-brand-400 text-xs font-medium px-3 py-1.5 rounded-full mb-4">
|
||||
Installation Guide
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold text-white mb-4">Install NixPanel</h1>
|
||||
<p className="text-gray-400 text-lg max-w-2xl">
|
||||
Get your NixServer admin panel and NixClient user panel running on any supported Linux server in under five minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="mb-12 grid sm:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6">
|
||||
<h2 className="text-white font-semibold mb-4">Supported OS</h2>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
'Ubuntu 20.04 / 22.04 / 24.04',
|
||||
'Debian 11 / 12',
|
||||
'AlmaLinux 8 / 9',
|
||||
'Rocky Linux 8 / 9',
|
||||
'RHEL 8 / 9',
|
||||
].map(os => (
|
||||
<li key={os} className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span className="text-emerald-500">✓</span> {os}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6">
|
||||
<h2 className="text-white font-semibold mb-4">Minimum Requirements</h2>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-center gap-2"><span className="text-emerald-500">✓</span> Root access (sudo)</li>
|
||||
<li className="flex items-center gap-2"><span className="text-emerald-500">✓</span> x86_64 or aarch64 architecture</li>
|
||||
<li className="flex items-center gap-2"><span className="text-emerald-500">✓</span> 1 GB RAM (2 GB+ recommended)</li>
|
||||
<li className="flex items-center gap-2"><span className="text-emerald-500">✓</span> 10 GB disk space</li>
|
||||
<li className="flex items-center gap-2"><span className="text-emerald-500">✓</span> Fresh / minimal OS install</li>
|
||||
<li className="flex items-center gap-2"><span className="text-emerald-500">✓</span> Internet access from the server</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick install */}
|
||||
<div className="mb-14">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Quick Install</h2>
|
||||
<p className="text-gray-400 text-sm mb-1">
|
||||
Run this single command as root on a fresh server. The installer handles everything automatically.
|
||||
</p>
|
||||
<CodeBlock code={ONE_LINER} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Badge color="green">Auto-detects OS</Badge>
|
||||
<Badge color="green">Generates random secrets</Badge>
|
||||
<Badge color="green">Configures firewall</Badge>
|
||||
<Badge color="green">Creates systemd service</Badge>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
You can also pass custom credentials to avoid the interactive prompt:
|
||||
</p>
|
||||
<CodeBlock code={`curl -fsSL https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/scripts/install.sh | sudo bash -s -- \\
|
||||
--admin-user admin \\
|
||||
--admin-pass "YourSecurePassword" \\
|
||||
--admin-email "[email protected]"`} />
|
||||
</div>
|
||||
|
||||
{/* After install */}
|
||||
<div className="mb-14 bg-brand-900/20 border border-brand-500/20 rounded-xl p-6">
|
||||
<h2 className="text-white font-semibold text-lg mb-3">After Installation</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Once complete, the installer prints your credentials and saves them to{' '}
|
||||
<code className="text-brand-400 bg-gray-900 px-1.5 py-0.5 rounded text-xs">/root/.nixpanel_credentials</code>.
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-900/60 border border-gray-800 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 mb-1 uppercase tracking-wide font-medium">NixServer — Admin Panel</p>
|
||||
<p className="text-white font-mono text-sm">http://<your-ip>:2087</p>
|
||||
</div>
|
||||
<div className="bg-gray-900/60 border border-gray-800 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 mb-1 uppercase tracking-wide font-medium">NixClient — User Panel</p>
|
||||
<p className="text-white font-mono text-sm">http://<your-ip>:2083</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual install */}
|
||||
<div className="mb-14">
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Manual Installation</h2>
|
||||
<p className="text-gray-400 text-sm mb-8">
|
||||
Prefer full control? Follow the steps below. You'll need to install the system dependencies (Node.js 22, MariaDB, Nginx, etc.) yourself before starting.
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-4 top-0 bottom-0 w-px bg-gray-800" />
|
||||
<div className="space-y-0">
|
||||
|
||||
<Step n={1} title="Clone the repository">
|
||||
<CodeBlock code={MANUAL_CLONE} />
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Configure environment variables">
|
||||
<p className="text-sm text-gray-400 mb-1">
|
||||
Copy the example file and fill in your database credentials, JWT secrets, and admin password.
|
||||
</p>
|
||||
<CodeBlock code={MANUAL_ENV} />
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Install, migrate, and build">
|
||||
<CodeBlock code={MANUAL_INSTALL} />
|
||||
</Step>
|
||||
|
||||
<Step n={4} title="Create the systemd service">
|
||||
<CodeBlock code={MANUAL_SERVICE} />
|
||||
</Step>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What gets installed */}
|
||||
<div className="mb-14">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">What Gets Installed</h2>
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left text-gray-500 font-medium px-5 py-3">Package</th>
|
||||
<th className="text-left text-gray-500 font-medium px-5 py-3">Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{softwareItems.map((item, i) => (
|
||||
<tr key={item.name} className={`border-b border-gray-800 last:border-0 ${i % 2 === 0 ? '' : 'bg-gray-900/30'}`}>
|
||||
<td className="px-5 py-3 text-white font-mono text-xs">{item.name}</td>
|
||||
<td className="px-5 py-3 text-gray-400">{item.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open ports */}
|
||||
<div className="mb-14">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Firewall Ports</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
The installer automatically opens these ports via UFW (Debian/Ubuntu) or firewalld (RHEL/AlmaLinux/Rocky).
|
||||
</p>
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left text-gray-500 font-medium px-5 py-3">Port</th>
|
||||
<th className="text-left text-gray-500 font-medium px-5 py-3">Name</th>
|
||||
<th className="text-left text-gray-500 font-medium px-5 py-3">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ports.map((p, i) => (
|
||||
<tr key={p.port} className={`border-b border-gray-800 last:border-0 ${i % 2 === 0 ? '' : 'bg-gray-900/30'}`}>
|
||||
<td className="px-5 py-3 font-mono text-brand-400 text-xs">{p.port}</td>
|
||||
<td className="px-5 py-3 text-white">{p.name}</td>
|
||||
<td className="px-5 py-3 text-gray-400">{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updating */}
|
||||
<div className="mb-14">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Updating NixPanel</h2>
|
||||
<p className="text-gray-400 text-sm mb-1">
|
||||
Pull the latest code, rebuild, and restart — all in one command:
|
||||
</p>
|
||||
<CodeBlock code={UPDATE_CMD} />
|
||||
</div>
|
||||
|
||||
{/* Service management */}
|
||||
<div className="mb-14">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Managing the Service</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Check status', cmd: 'systemctl status nixpanel' },
|
||||
{ label: 'View live logs', cmd: 'journalctl -u nixpanel -f' },
|
||||
{ label: 'Restart', cmd: 'systemctl restart nixpanel' },
|
||||
{ label: 'Stop', cmd: 'systemctl stop nixpanel' },
|
||||
].map(({ label, cmd }) => (
|
||||
<div key={label} className="bg-gray-900/50 border border-gray-800 rounded-lg px-4 py-3">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-sm text-gray-300 font-mono">{cmd}</code>
|
||||
<CopyButton text={cmd} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uninstall */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Uninstalling</h2>
|
||||
<p className="text-gray-400 text-sm mb-1">
|
||||
Removes the NixPanel service, nginx config, install directory, and database. Does not remove Nginx, PHP, or other system packages.
|
||||
</p>
|
||||
<CodeBlock code={UNINSTALL_CMD} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore(s => s.setAuth);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await authApi.login(email, password);
|
||||
const me = await authApi.me();
|
||||
setAuth(data.token, me.data);
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Login failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Sign in</h1>
|
||||
<p className="text-gray-400 text-sm mb-8">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-brand-400 hover:underline">Get started free</Link>
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/40 text-red-400 text-sm px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="text-right">
|
||||
<Link to="/forgot-password" className="text-xs text-gray-500 hover:text-gray-300">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Button type="submit" loading={loading} size="lg" className="w-full mt-1">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import PricingCard from '@/components/PricingCard';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: 'Free', planKey: 'free',
|
||||
monthlyPrice: 0, annualPrice: 0,
|
||||
highlighted: false,
|
||||
limits: { domains: '1', accounts: '1', emails: '5', databases: '2', disk: '5 GB' },
|
||||
features: ['Community support', 'Let\'s Encrypt SSL'],
|
||||
},
|
||||
{
|
||||
name: 'Solo', planKey: 'solo',
|
||||
monthlyPrice: 9.99, annualPrice: 95.90,
|
||||
highlighted: false,
|
||||
limits: { domains: '5', accounts: '5', emails: '50', databases: '10', disk: '50 GB' },
|
||||
features: ['Email support', 'Let\'s Encrypt SSL', 'WP one-click install'],
|
||||
},
|
||||
{
|
||||
name: 'Host', planKey: 'host',
|
||||
monthlyPrice: 29.99, annualPrice: 287.90,
|
||||
highlighted: true,
|
||||
limits: { domains: '50', accounts: '50', emails: '500', databases: '100', disk: '250 GB' },
|
||||
features: ['Priority support', 'Let\'s Encrypt SSL', 'WP one-click install', 'WHM reseller panel'],
|
||||
},
|
||||
{
|
||||
name: 'Agency', planKey: 'agency',
|
||||
monthlyPrice: 79.99, annualPrice: 767.90,
|
||||
highlighted: false,
|
||||
limits: { domains: 'Unlimited', accounts: 'Unlimited', emails: 'Unlimited', databases: 'Unlimited', disk: 'Unlimited' },
|
||||
features: ['Priority support', 'Let\'s Encrypt SSL', 'WP one-click install', 'WHM reseller panel', 'White-label ready'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Pricing() {
|
||||
const [interval, setInterval] = useState<'monthly' | 'annual'>('monthly');
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">Simple, transparent pricing</h1>
|
||||
<p className="text-gray-400">Tied to your server IP. No per-user fees. Cancel anytime.</p>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="inline-flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 mt-8">
|
||||
<button
|
||||
onClick={() => setInterval('monthly')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
interval === 'monthly' ? 'bg-brand-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>Monthly</button>
|
||||
<button
|
||||
onClick={() => setInterval('annual')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
interval === 'annual' ? 'bg-brand-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Annual
|
||||
<span className="ml-2 text-xs bg-green-500/20 text-green-400 px-1.5 py-0.5 rounded">
|
||||
2 months free
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{plans.map(plan => (
|
||||
<PricingCard key={plan.planKey} {...plan} interval={interval} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-10">
|
||||
All plans include 1 server IP. Need multiple servers?{' '}
|
||||
<a href="mailto:[email protected]" className="text-brand-400 hover:underline">Contact us.</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { authApi } from '@/services/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const plan = params.get('plan') || '';
|
||||
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.register({ email, password, fullName });
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.response?.data?.error || 'Registration failed.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="text-5xl mb-4">📬</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Check your email</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
We sent a verification link to <strong className="text-white">{email}</strong>.
|
||||
Click it to activate your account, then{' '}
|
||||
<Link to="/login" className="text-brand-400 hover:underline">sign in</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Create your account</h1>
|
||||
<p className="text-gray-400 text-sm mb-8">
|
||||
Already have one?{' '}
|
||||
<Link to="/login" className="text-brand-400 hover:underline">Sign in</Link>
|
||||
</p>
|
||||
|
||||
{plan && (
|
||||
<div className="bg-brand-900/30 border border-brand-500/30 text-brand-300 text-sm px-4 py-3 rounded-lg mb-4">
|
||||
You'll be set up on the <strong className="capitalize">{plan}</strong> plan after verifying your email.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/40 text-red-400 text-sm px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Full name"
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={e => setFullName(e.target.value)}
|
||||
placeholder="Jane Smith"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button type="submit" loading={loading} size="lg" className="w-full mt-1">
|
||||
Create account
|
||||
</Button>
|
||||
<p className="text-xs text-gray-600 text-center">
|
||||
By creating an account you agree to our Terms of Service and Privacy Policy.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '@/services/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const token = params.get('token');
|
||||
if (!token) { setError('Invalid reset link.'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.resetPassword(token, password);
|
||||
navigate('/login');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to reset password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Choose a new password</h1>
|
||||
<p className="text-gray-400 text-sm mb-8">Must be at least 8 characters.</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/40 text-red-400 text-sm px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Button type="submit" loading={loading} size="lg" className="w-full">
|
||||
Reset password
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const [params] = useSearchParams();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
const token = params.get('token');
|
||||
if (!token) { setStatus('error'); return; }
|
||||
axios.get(`/api/v1/auth/verify-email?token=${token}`)
|
||||
.then(() => setStatus('success'))
|
||||
.catch(() => setStatus('error'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{status === 'loading' && <p className="text-gray-400">Verifying...</p>}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Email verified</h2>
|
||||
<p className="text-gray-400 mb-6">Your account is active. You can now sign in.</p>
|
||||
<Link to="/login" className="text-brand-400 hover:underline">Go to login</Link>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="text-5xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Invalid link</h2>
|
||||
<p className="text-gray-400 mb-6">This link is expired or invalid. Try registering again.</p>
|
||||
<Link to="/register" className="text-brand-400 hover:underline">Back to register</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
|
||||
const api = axios.create({ baseURL: '/api/v1' });
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
r => r,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth
|
||||
export const authApi = {
|
||||
register: (data: { email: string; password: string; fullName: string }) =>
|
||||
api.post('/auth/register', data),
|
||||
login: (email: string, password: string) =>
|
||||
api.post<{ token: string }>('/auth/login', { email, password }),
|
||||
me: () =>
|
||||
api.get('/auth/me'),
|
||||
forgotPassword: (email: string) =>
|
||||
api.post('/auth/forgot-password', { email }),
|
||||
resetPassword: (token: string, password: string) =>
|
||||
api.post('/auth/reset-password', { token, password }),
|
||||
};
|
||||
|
||||
// Billing
|
||||
export const billingApi = {
|
||||
plans: () =>
|
||||
api.get('/billing/plans'),
|
||||
checkout: (planKey: string, interval: 'monthly' | 'annual', serverIp: string) =>
|
||||
api.post<{ url: string }>('/billing/checkout', { planKey, interval, serverIp }),
|
||||
portal: () =>
|
||||
api.post<{ url: string }>('/billing/portal'),
|
||||
cancel: () =>
|
||||
api.post('/billing/cancel'),
|
||||
changePlan: (planKey: string, interval: 'monthly' | 'annual') =>
|
||||
api.post('/billing/change-plan', { planKey, interval }),
|
||||
subscription: () =>
|
||||
api.get('/billing/subscription'),
|
||||
};
|
||||
|
||||
// License
|
||||
export const licenseApi = {
|
||||
get: () =>
|
||||
api.get('/license'),
|
||||
changeIp: (newIp: string) =>
|
||||
api.post('/license/change-ip', { newIp }),
|
||||
ipHistory: () =>
|
||||
api.get('/license/ip-history'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
customer: Customer | null;
|
||||
setAuth: (token: string, customer: Customer) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
customer: null,
|
||||
setAuth: (token, customer) => set({ token, customer }),
|
||||
logout: () => set({ token: null, customer: null }),
|
||||
}),
|
||||
{ name: 'nixpanel-auth' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
900: '#1e3a5f',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user