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:
NixReaper
2026-04-05 11:16:42 -05:00
co-authored by Claude Sonnet 4.6
commit 72f5cc1060
48 changed files with 2695 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.local
.DS_Store
+44
View File
@@ -0,0 +1,44 @@
# Server
NODE_ENV=production
PORT=4000
HOST=0.0.0.0
# CORS
CORS_ORIGIN=https://nixpanel.io
# Database (MariaDB / MySQL)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=nixpanel_web
DB_PASSWORD=your_secure_password_here
DB_NAME=nixpanel_website
# JWT — generate with: openssl rand -hex 64
JWT_SECRET=changeme_use_openssl_rand_hex_64
JWT_EXPIRES_IN=7d
# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Stripe Price IDs (create in Stripe Dashboard)
STRIPE_PRICE_SOLO_MONTHLY=price_...
STRIPE_PRICE_SOLO_ANNUAL=price_...
STRIPE_PRICE_HOST_MONTHLY=price_...
STRIPE_PRICE_HOST_ANNUAL=price_...
STRIPE_PRICE_AGENCY_MONTHLY=price_...
STRIPE_PRICE_AGENCY_ANNUAL=price_...
# Internal link to the licensing server
LICENSING_SERVER_URL=http://localhost:3000
LICENSING_SERVER_API_KEY=your_internal_api_key_here
# Email (SMTP)
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USER=[email protected]
MAIL_PASSWORD=your_smtp_password
MAIL_FROM=[email protected]
# Public URL (used in emails and redirects)
APP_URL=https://nixpanel.io
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+35
View File
@@ -0,0 +1,35 @@
{
"name": "nixpanel-website-api",
"version": "1.0.0",
"description": "NixPanel.io website API — customer auth, Stripe billing, license management",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"migrate": "ts-node src/db/migrate.ts"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"@fastify/helmet": "^11.1.1",
"@fastify/rate-limit": "^9.1.0",
"mysql2": "^3.9.8",
"stripe": "^16.2.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"uuid": "^9.0.1",
"nodemailer": "^6.9.14"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/nodemailer": "^6.4.15",
"@types/node": "^20.14.9",
"@types/uuid": "^9.0.8",
"typescript": "^5.5.3",
"ts-node-dev": "^2.0.0",
"ts-node": "^10.9.2"
}
}
+60
View File
@@ -0,0 +1,60 @@
import dotenv from 'dotenv';
dotenv.config();
function require(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing required environment variable: ${key}`);
return val;
}
export const config = {
env: process.env.NODE_ENV || 'production',
port: parseInt(process.env.PORT || '4000', 10),
host: process.env.HOST || '0.0.0.0',
db: {
host: process.env.DB_HOST || '127.0.0.1',
port: parseInt(process.env.DB_PORT || '3306', 10),
user: require('DB_USER'),
password: require('DB_PASSWORD'),
name: require('DB_NAME'),
},
jwt: {
secret: require('JWT_SECRET'),
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
stripe: {
secretKey: require('STRIPE_SECRET_KEY'),
webhookSecret: require('STRIPE_WEBHOOK_SECRET'),
plans: {
// Stripe Price IDs — set after creating products in Stripe dashboard
solo_monthly: process.env.STRIPE_PRICE_SOLO_MONTHLY || '',
solo_annual: process.env.STRIPE_PRICE_SOLO_ANNUAL || '',
host_monthly: process.env.STRIPE_PRICE_HOST_MONTHLY || '',
host_annual: process.env.STRIPE_PRICE_HOST_ANNUAL || '',
agency_monthly: process.env.STRIPE_PRICE_AGENCY_MONTHLY || '',
agency_annual: process.env.STRIPE_PRICE_AGENCY_ANNUAL || '',
},
},
licensingServer: {
url: require('LICENSING_SERVER_URL'),
apiKey: require('LICENSING_SERVER_API_KEY'),
},
mail: {
host: process.env.MAIL_HOST || 'smtp.mailgun.org',
port: parseInt(process.env.MAIL_PORT || '587', 10),
user: process.env.MAIL_USER || '',
password: process.env.MAIL_PASSWORD || '',
from: process.env.MAIL_FROM || '[email protected]',
},
appUrl: process.env.APP_URL || 'https://nixpanel.io',
cors: {
origin: process.env.CORS_ORIGIN || 'https://nixpanel.io',
},
};
+35
View File
@@ -0,0 +1,35 @@
import mysql from 'mysql2/promise';
import { config } from '../config';
let pool: mysql.Pool;
export function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.name,
waitForConnections: true,
connectionLimit: 20,
queueLimit: 0,
timezone: 'Z',
});
}
return pool;
}
export async function query<T = mysql.RowDataPacket[]>(
sql: string,
params?: unknown[]
): Promise<T> {
const [rows] = await getPool().execute(sql, params);
return rows as T;
}
export async function testConnection(): Promise<void> {
const conn = await getPool().getConnection();
await conn.ping();
conn.release();
}
+33
View File
@@ -0,0 +1,33 @@
import fs from 'fs';
import path from 'path';
import { getPool, testConnection } from './index';
async function migrate(): Promise<void> {
await testConnection();
console.log('Connected to database.');
const migrationsDir = path.join(__dirname, 'migrations');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
const pool = getPool();
for (const file of files) {
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
const statements = sql
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0 && !s.startsWith('--'));
for (const statement of statements) {
await pool.execute(statement);
}
console.log(`Applied: ${file}`);
}
console.log('Migration complete.');
process.exit(0);
}
migrate().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
});
+55
View File
@@ -0,0 +1,55 @@
-- NixPanel Website — Customer Schema
-- MariaDB / MySQL compatible
CREATE TABLE IF NOT EXISTS customers (
id VARCHAR(36) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
is_verified TINYINT(1) NOT NULL DEFAULT 0,
verify_token VARCHAR(128) NULL,
reset_token VARCHAR(128) NULL,
reset_token_expires DATETIME NULL,
stripe_customer_id VARCHAR(100) NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_stripe_customer (stripe_customer_id)
);
CREATE TABLE IF NOT EXISTS subscriptions (
id VARCHAR(36) NOT NULL PRIMARY KEY,
customer_id VARCHAR(36) NOT NULL,
license_id VARCHAR(36) NOT NULL,
stripe_subscription_id VARCHAR(100) NOT NULL UNIQUE,
stripe_price_id VARCHAR(100) NOT NULL,
plan_key ENUM('free','solo','host','agency') NOT NULL DEFAULT 'free',
billing_interval ENUM('monthly','annual') NOT NULL DEFAULT 'monthly',
status ENUM('active','past_due','canceled','incomplete','trialing') NOT NULL DEFAULT 'active',
server_ip VARCHAR(45) NOT NULL,
current_period_start DATETIME NOT NULL,
current_period_end DATETIME NOT NULL,
cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0,
canceled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
INDEX idx_customer_id (customer_id),
INDEX idx_license_id (license_id),
INDEX idx_stripe_sub (stripe_subscription_id),
INDEX idx_server_ip (server_ip)
);
CREATE TABLE IF NOT EXISTS ip_change_requests (
id VARCHAR(36) NOT NULL PRIMARY KEY,
customer_id VARCHAR(36) NOT NULL,
subscription_id VARCHAR(36) NOT NULL,
old_ip VARCHAR(45) NOT NULL,
new_ip VARCHAR(45) NOT NULL,
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
INDEX idx_customer_id (customer_id)
);
+61
View File
@@ -0,0 +1,61 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import { config } from './config';
import { testConnection } from './db';
import authRoutes from './routes/auth';
import billingRoutes from './routes/billing';
import licenseRoutes from './routes/license';
const app = Fastify({
logger: {
level: config.env === 'production' ? 'warn' : 'info',
transport: config.env !== 'production' ? { target: 'pino-pretty' } : undefined,
},
trustProxy: true,
});
// Add rawBody support for Stripe webhooks
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
(req as any).rawBody = body;
try {
done(null, JSON.parse(body.toString()));
} catch (err) {
done(err as Error, undefined);
}
});
async function bootstrap(): Promise<void> {
await app.register(helmet, { global: true });
await app.register(cors, {
origin: config.env === 'production' ? config.cors.origin : true,
credentials: true,
});
await app.register(rateLimit, {
max: 120,
timeWindow: '1 minute',
keyGenerator: (req) => req.ip,
});
app.get('/health', async (_req, reply) => {
return reply.send({ status: 'ok', ts: new Date().toISOString() });
});
await app.register(authRoutes, { prefix: '/api/v1/auth' });
await app.register(billingRoutes, { prefix: '/api/v1/billing' });
await app.register(licenseRoutes, { prefix: '/api/v1/license' });
await testConnection();
app.log.info('Database connection verified');
await app.listen({ port: config.port, host: config.host });
app.log.info(`NixPanel Website API running on ${config.host}:${config.port}`);
}
bootstrap().catch(err => {
console.error('Failed to start API:', err);
process.exit(1);
});
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import { verifyJwt } from '../services/authService';
import { getCustomerById } from '../services/authService';
declare module 'fastify' {
interface FastifyRequest {
customer?: { id: string; email: string };
}
}
export async function requireCustomerAuth(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
try {
const payload = verifyJwt(authHeader.slice(7));
const customer = await getCustomerById(payload.sub);
if (!customer) {
reply.code(401).send({ error: 'Account not found' });
return;
}
request.customer = { id: customer.id, email: customer.email };
} catch {
reply.code(401).send({ error: 'Invalid or expired session' });
}
}
+108
View File
@@ -0,0 +1,108 @@
import type { FastifyInstance } from 'fastify';
import { requireCustomerAuth } from '../middleware/auth';
import {
registerCustomer,
loginCustomer,
verifyEmail,
getCustomerById,
initiatePasswordReset,
resetPassword,
} from '../services/authService';
import {
sendVerificationEmail,
sendPasswordResetEmail,
} from '../services/mailService';
export default async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /auth/register
app.post<{
Body: { email: string; password: string; fullName: string };
}>('/register', {
schema: {
body: {
type: 'object',
required: ['email', 'password', 'fullName'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 },
fullName: { type: 'string', minLength: 2 },
},
},
},
}, async (request, reply) => {
const { email, password, fullName } = request.body;
const { customer, verifyToken } = await registerCustomer({ email, password, fullName });
await sendVerificationEmail(email, verifyToken);
return reply.code(201).send({
message: 'Account created. Check your email to verify your account.',
id: customer.id,
});
});
// POST /auth/login
app.post<{
Body: { email: string; password: string };
}>('/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
},
},
}, async (request, reply) => {
const { email, password } = request.body;
const token = await loginCustomer(email, password);
return reply.send({ token });
});
// GET /auth/me
app.get('/me', { preHandler: requireCustomerAuth }, async (request, reply) => {
const customer = await getCustomerById(request.customer!.id);
return reply.send(customer);
});
// GET /auth/verify-email?token=...
app.get<{ Querystring: { token: string } }>('/verify-email', async (request, reply) => {
const ok = await verifyEmail(request.query.token);
if (!ok) return reply.code(400).send({ error: 'Invalid or expired verification link' });
return reply.send({ message: 'Email verified. You can now log in.' });
});
// POST /auth/forgot-password
app.post<{ Body: { email: string } }>('/forgot-password', {
schema: {
body: {
type: 'object',
required: ['email'],
properties: { email: { type: 'string', format: 'email' } },
},
},
}, async (request, reply) => {
const token = await initiatePasswordReset(request.body.email);
if (token) await sendPasswordResetEmail(request.body.email, token);
// Always return success to prevent email enumeration
return reply.send({ message: 'If that email exists, a reset link has been sent.' });
});
// POST /auth/reset-password
app.post<{ Body: { token: string; password: string } }>('/reset-password', {
schema: {
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 8 },
},
},
},
}, async (request, reply) => {
const ok = await resetPassword(request.body.token, request.body.password);
if (!ok) return reply.code(400).send({ error: 'Invalid or expired reset token' });
return reply.send({ message: 'Password updated. You can now log in.' });
});
}
+237
View File
@@ -0,0 +1,237 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { requireCustomerAuth } from '../middleware/auth';
import { getCustomerById } from '../services/authService';
import {
getOrCreateStripeCustomer,
createCheckoutSession,
createBillingPortalSession,
cancelSubscription,
changeSubscriptionPlan,
constructWebhookEvent,
PLAN_PRICES,
} from '../services/stripeService';
import { createLicense, revokeLicense } from '../services/licensingService';
import { sendLicenseConfirmationEmail } from '../services/mailService';
import { query } from '../db';
import { config } from '../config';
import crypto from 'crypto';
// Plan key → licensing server plan ID mapping
const PLAN_ID_MAP: Record<string, string> = {
free: '00000000-0000-0000-0000-000000000001',
solo: '00000000-0000-0000-0000-000000000002',
host: '00000000-0000-0000-0000-000000000003',
agency: '00000000-0000-0000-0000-000000000004',
};
export default async function billingRoutes(app: FastifyInstance): Promise<void> {
// GET /billing/plans — public
app.get('/plans', async (_request, reply) => {
return reply.send(PLAN_PRICES);
});
// POST /billing/checkout — start Stripe checkout
app.post<{
Body: { planKey: string; interval: 'monthly' | 'annual'; serverIp: string };
}>('/checkout', {
preHandler: requireCustomerAuth,
schema: {
body: {
type: 'object',
required: ['planKey', 'interval', 'serverIp'],
properties: {
planKey: { type: 'string', enum: ['solo', 'host', 'agency'] },
interval: { type: 'string', enum: ['monthly', 'annual'] },
serverIp: { type: 'string' },
},
},
},
}, async (request, reply) => {
const { planKey, interval, serverIp } = request.body;
const customer = await getCustomerById(request.customer!.id);
if (!customer) return reply.code(404).send({ error: 'Account not found' });
const stripeCustomerId = await getOrCreateStripeCustomer({
customerId: customer.id,
email: customer.email,
name: customer.full_name,
stripeCustomerId: customer.stripe_customer_id,
});
const priceKey = `${planKey}_${interval}` as keyof typeof config.stripe.plans;
const priceId = config.stripe.plans[priceKey];
if (!priceId) return reply.code(400).send({ error: 'Plan not configured' });
const url = await createCheckoutSession({
stripeCustomerId,
priceId,
planKey,
interval,
serverIp,
customerId: customer.id,
successUrl: `${config.appUrl}/dashboard?success=1`,
cancelUrl: `${config.appUrl}/pricing`,
});
return reply.send({ url });
});
// POST /billing/portal — Stripe customer portal (manage payment method, invoices)
app.post('/portal', { preHandler: requireCustomerAuth }, async (request, reply) => {
const customer = await getCustomerById(request.customer!.id);
if (!customer?.stripe_customer_id) {
return reply.code(400).send({ error: 'No billing account found' });
}
const url = await createBillingPortalSession(
customer.stripe_customer_id,
`${config.appUrl}/dashboard`
);
return reply.send({ url });
});
// POST /billing/cancel — cancel at period end
app.post('/cancel', { preHandler: requireCustomerAuth }, async (request, reply) => {
const subs = await query<any[]>(
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
[request.customer!.id]
);
if (!subs.length) return reply.code(404).send({ error: 'No active subscription' });
await cancelSubscription(subs[0].stripe_subscription_id);
return reply.send({ message: 'Subscription will cancel at end of billing period.' });
});
// POST /billing/change-plan — upgrade or downgrade
app.post<{
Body: { planKey: string; interval: 'monthly' | 'annual' };
}>('/change-plan', {
preHandler: requireCustomerAuth,
schema: {
body: {
type: 'object',
required: ['planKey', 'interval'],
properties: {
planKey: { type: 'string', enum: ['solo', 'host', 'agency'] },
interval: { type: 'string', enum: ['monthly', 'annual'] },
},
},
},
}, async (request, reply) => {
const { planKey, interval } = request.body;
const subs = await query<any[]>(
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
[request.customer!.id]
);
if (!subs.length) return reply.code(404).send({ error: 'No active subscription' });
const priceKey = `${planKey}_${interval}` as keyof typeof config.stripe.plans;
const priceId = config.stripe.plans[priceKey];
if (!priceId) return reply.code(400).send({ error: 'Plan not configured' });
await changeSubscriptionPlan({ stripeSubscriptionId: subs[0].stripe_subscription_id, newPriceId: priceId });
await query(
'UPDATE subscriptions SET plan_key = ?, billing_interval = ? WHERE id = ?',
[planKey, interval, subs[0].id]
);
return reply.send({ message: 'Plan updated.' });
});
// GET /billing/subscription — current subscription details
app.get('/subscription', { preHandler: requireCustomerAuth }, async (request, reply) => {
const subs = await query<any[]>(
'SELECT * FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1',
[request.customer!.id]
);
return reply.send(subs[0] || null);
});
// POST /billing/webhook — Stripe webhook (no auth, validated by signature)
app.post('/webhook', {
config: { rawBody: true },
}, async (request: FastifyRequest & { rawBody?: Buffer }, reply) => {
const sig = request.headers['stripe-signature'] as string;
if (!sig || !request.rawBody) {
return reply.code(400).send({ error: 'Missing signature' });
}
let event;
try {
event = await constructWebhookEvent(request.rawBody, sig);
} catch {
return reply.code(400).send({ error: 'Invalid webhook signature' });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as any;
const { nixpanel_customer_id, plan_key, billing_interval, server_ip } = session.metadata;
// Create license on licensing server
const cust = await getCustomerById(nixpanel_customer_id);
if (!cust) break;
const license = await createLicense({
planId: PLAN_ID_MAP[plan_key],
ownerEmail: cust.email,
ownerName: cust.full_name,
serverIp: server_ip,
});
const stripeSubscription = session.subscription;
const subId = crypto.randomUUID();
await query(
`INSERT INTO subscriptions
(id, customer_id, license_id, stripe_subscription_id, stripe_price_id,
plan_key, billing_interval, status, server_ip,
current_period_start, current_period_end)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 MONTH))`,
[subId, nixpanel_customer_id, license.id, stripeSubscription,
session.metadata?.price_id || '', plan_key, billing_interval, server_ip]
);
await sendLicenseConfirmationEmail(cust.email, {
plan: PLAN_PRICES[plan_key]?.label || plan_key,
serverIp: server_ip,
interval: billing_interval,
});
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object as any;
await query(
`UPDATE subscriptions
SET status = ?, current_period_start = ?, current_period_end = ?,
cancel_at_period_end = ?
WHERE stripe_subscription_id = ?`,
[
sub.status,
new Date(sub.current_period_start * 1000),
new Date(sub.current_period_end * 1000),
sub.cancel_at_period_end ? 1 : 0,
sub.id,
]
);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as any;
const rows = await query<any[]>(
'SELECT * FROM subscriptions WHERE stripe_subscription_id = ?',
[sub.id]
);
if (rows.length) {
await query(
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = ?",
[sub.id]
);
await revokeLicense(rows[0].license_id);
}
break;
}
}
return reply.send({ received: true });
});
}
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyInstance } from 'fastify';
import { requireCustomerAuth } from '../middleware/auth';
import { getLicense, updateServerIp } from '../services/licensingService';
import { query } from '../db';
export default async function licenseRoutes(app: FastifyInstance): Promise<void> {
app.addHook('preHandler', requireCustomerAuth);
// GET /license — current license details
app.get('/', async (request, reply) => {
const subs = await query<any[]>(
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
[request.customer!.id]
);
if (!subs.length) return reply.send(null);
const license = await getLicense(subs[0].license_id);
return reply.send({ ...license, subscription: subs[0] });
});
// POST /license/change-ip — request IP address change
app.post<{
Body: { newIp: string };
}>('/change-ip', {
schema: {
body: {
type: 'object',
required: ['newIp'],
properties: {
newIp: {
type: 'string',
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]?)$',
},
},
},
},
}, async (request, reply) => {
const { newIp } = request.body;
const subs = await query<any[]>(
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
[request.customer!.id]
);
if (!subs.length) return reply.code(404).send({ error: 'No active subscription' });
const sub = subs[0];
if (sub.server_ip === newIp) {
return reply.code(400).send({ error: 'New IP is the same as current IP' });
}
// Check if new IP is already in use
const conflict = await query<any[]>(
"SELECT id FROM subscriptions WHERE server_ip = ? AND status = 'active' AND id != ?",
[newIp, sub.id]
);
if (conflict.length) {
return reply.code(409).send({ error: 'That IP address is already in use by another license' });
}
// Log the request and apply immediately
const reqId = require('crypto').randomUUID();
await query(
`INSERT INTO ip_change_requests (id, customer_id, subscription_id, old_ip, new_ip, status, processed_at)
VALUES (?, ?, ?, ?, ?, 'approved', NOW())`,
[reqId, request.customer!.id, sub.id, sub.server_ip, newIp]
);
await updateServerIp(sub.license_id, newIp);
await query(
'UPDATE subscriptions SET server_ip = ? WHERE id = ?',
[newIp, sub.id]
);
return reply.send({ message: 'IP address updated successfully.', newIp });
});
// GET /license/ip-history — past IP changes
app.get('/ip-history', async (request, reply) => {
const history = await query<any[]>(
`SELECT * FROM ip_change_requests WHERE customer_id = ? ORDER BY requested_at DESC`,
[request.customer!.id]
);
return reply.send(history);
});
}
+112
View File
@@ -0,0 +1,112 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { query } from '../db';
import { config } from '../config';
export interface Customer {
id: string;
email: string;
full_name: string;
is_verified: boolean;
stripe_customer_id: string | null;
created_at: Date;
}
export async function getCustomerByEmail(email: string): Promise<any | null> {
const rows = await query<any[]>('SELECT * FROM customers WHERE email = ?', [email]);
return rows[0] || null;
}
export async function getCustomerById(id: string): Promise<Customer | null> {
const rows = await query<any[]>(
'SELECT id, email, full_name, is_verified, stripe_customer_id, created_at FROM customers WHERE id = ?',
[id]
);
return rows[0] || null;
}
export async function registerCustomer(data: {
email: string;
password: string;
fullName: string;
}): Promise<{ customer: Customer; verifyToken: string }> {
const existing = await getCustomerByEmail(data.email);
if (existing) throw new Error('Email already registered');
const id = crypto.randomUUID();
const hash = await bcrypt.hash(data.password, 12);
const verifyToken = crypto.randomBytes(32).toString('hex');
await query(
`INSERT INTO customers (id, email, password_hash, full_name, verify_token)
VALUES (?, ?, ?, ?, ?)`,
[id, data.email.toLowerCase().trim(), hash, data.fullName.trim(), verifyToken]
);
const customer = await getCustomerById(id) as Customer;
return { customer, verifyToken };
}
export async function verifyEmail(token: string): Promise<boolean> {
const rows = await query<any[]>(
'SELECT id FROM customers WHERE verify_token = ?',
[token]
);
if (!rows.length) return false;
await query(
'UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?',
[rows[0].id]
);
return true;
}
export async function loginCustomer(email: string, password: string): Promise<string> {
const customer = await getCustomerByEmail(email.toLowerCase().trim());
if (!customer) throw new Error('Invalid credentials');
if (!customer.is_verified) throw new Error('Please verify your email before logging in');
const valid = await bcrypt.compare(password, customer.password_hash);
if (!valid) throw new Error('Invalid credentials');
return issueJwt(customer.id, customer.email);
}
export function issueJwt(id: string, email: string): string {
return jwt.sign({ sub: id, email }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
} as jwt.SignOptions);
}
export function verifyJwt(token: string): { sub: string; email: string } {
return jwt.verify(token, config.jwt.secret) as any;
}
export async function initiatePasswordReset(email: string): Promise<string | null> {
const customer = await getCustomerByEmail(email.toLowerCase().trim());
if (!customer) return null; // don't reveal whether email exists
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 1000 * 60 * 60); // 1 hour
await query(
'UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
[token, expires, customer.id]
);
return token;
}
export async function resetPassword(token: string, newPassword: string): Promise<boolean> {
const rows = await query<any[]>(
'SELECT * FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()',
[token]
);
if (!rows.length) return false;
const hash = await bcrypt.hash(newPassword, 12);
await query(
'UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
[hash, rows[0].id]
);
return true;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* HTTP bridge to the nixpanel-licensing server.
* The website API calls this internally — never exposes licensing server directly.
*/
import { config } from '../config';
async function licensingRequest<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${config.licensingServer.url}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.licensingServer.apiKey}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error((err as any).error || `Licensing server error: ${res.status}`);
}
return res.json() as T;
}
export async function createLicense(data: {
planId: string;
ownerEmail: string;
ownerName: string;
serverIp: string;
}): Promise<{ id: string; status: string }> {
return licensingRequest('POST', '/api/v1/licenses', data);
}
export async function getLicense(licenseId: string): Promise<any> {
return licensingRequest('GET', `/api/v1/licenses/${licenseId}`);
}
export async function revokeLicense(licenseId: string): Promise<void> {
await licensingRequest('POST', `/api/v1/licenses/${licenseId}/revoke`);
}
export async function updateServerIp(licenseId: string, serverIp: string): Promise<void> {
await licensingRequest('POST', `/api/v1/licenses/${licenseId}/rebind`, { serverIp });
}
export async function getPlans(): Promise<any[]> {
return licensingRequest('GET', '/api/v1/admin/plans');
}
+62
View File
@@ -0,0 +1,62 @@
import nodemailer from 'nodemailer';
import { config } from '../config';
const transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
auth: { user: config.mail.user, pass: config.mail.password },
});
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
const url = `${config.appUrl}/verify-email?token=${token}`;
await transporter.sendMail({
from: config.mail.from,
to: email,
subject: 'Verify your NixPanel account',
html: `
<h2>Welcome to NixPanel</h2>
<p>Click the link below to verify your email address:</p>
<a href="${url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Verify Email
</a>
<p>This link expires in 24 hours.</p>
`,
});
}
export async function sendPasswordResetEmail(email: string, token: string): Promise<void> {
const url = `${config.appUrl}/reset-password?token=${token}`;
await transporter.sendMail({
from: config.mail.from,
to: email,
subject: 'Reset your NixPanel password',
html: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
}
export async function sendLicenseConfirmationEmail(email: string, data: {
plan: string;
serverIp: string;
interval: string;
}): Promise<void> {
await transporter.sendMail({
from: config.mail.from,
to: email,
subject: `Your NixPanel ${data.plan} license is active`,
html: `
<h2>License Activated</h2>
<p>Your <strong>${data.plan}</strong> plan (${data.interval}) is now active for server IP <strong>${data.serverIp}</strong>.</p>
<p>Log in to your dashboard to manage your license:</p>
<a href="${config.appUrl}/dashboard" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard
</a>
`,
});
}
+110
View File
@@ -0,0 +1,110 @@
import Stripe from 'stripe';
import { config } from '../config';
import { query } from '../db';
const stripe = new Stripe(config.stripe.secretKey, { apiVersion: '2024-06-20' });
// Annual plans are 20% off (pay 10 months, get 12)
export const PLAN_PRICES: Record<string, { monthly: number; annual: number; label: string }> = {
free: { monthly: 0, annual: 0, label: 'Free' },
solo: { monthly: 9.99, annual: 95.90, label: 'Solo' },
host: { monthly: 29.99, annual: 287.90, label: 'Host' },
agency: { monthly: 79.99, annual: 767.90, label: 'Agency' },
};
export async function getOrCreateStripeCustomer(data: {
customerId: string;
email: string;
name: string;
stripeCustomerId?: string | null;
}): Promise<string> {
if (data.stripeCustomerId) return data.stripeCustomerId;
const stripeCustomer = await stripe.customers.create({
email: data.email,
name: data.name,
metadata: { nixpanel_customer_id: data.customerId },
});
await query(
'UPDATE customers SET stripe_customer_id = ? WHERE id = ?',
[stripeCustomer.id, data.customerId]
);
return stripeCustomer.id;
}
export async function createCheckoutSession(data: {
stripeCustomerId: string;
priceId: string;
planKey: string;
interval: 'monthly' | 'annual';
serverIp: string;
customerId: string;
successUrl: string;
cancelUrl: string;
}): Promise<string> {
const session = await stripe.checkout.sessions.create({
customer: data.stripeCustomerId,
mode: 'subscription',
line_items: [{ price: data.priceId, quantity: 1 }],
success_url: data.successUrl,
cancel_url: data.cancelUrl,
metadata: {
nixpanel_customer_id: data.customerId,
plan_key: data.planKey,
billing_interval: data.interval,
server_ip: data.serverIp,
},
subscription_data: {
metadata: {
nixpanel_customer_id: data.customerId,
plan_key: data.planKey,
server_ip: data.serverIp,
},
},
});
return session.url!;
}
export async function createBillingPortalSession(
stripeCustomerId: string,
returnUrl: string
): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl,
});
return session.url;
}
export async function cancelSubscription(stripeSubscriptionId: string): Promise<void> {
await stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: true,
});
await query(
'UPDATE subscriptions SET cancel_at_period_end = 1 WHERE stripe_subscription_id = ?',
[stripeSubscriptionId]
);
}
export async function changeSubscriptionPlan(data: {
stripeSubscriptionId: string;
newPriceId: string;
}): Promise<void> {
const sub = await stripe.subscriptions.retrieve(data.stripeSubscriptionId);
await stripe.subscriptions.update(data.stripeSubscriptionId, {
items: [{ id: sub.items.data[0].id, price: data.newPriceId }],
proration_behavior: 'create_prorations',
});
}
export async function constructWebhookEvent(
payload: Buffer,
signature: string
): Promise<Stripe.Event> {
return stripe.webhooks.constructEvent(payload, signature, config.stripe.webhookSecret);
}
export { stripe };
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+3
View File
@@ -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_...
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+16
View File
@@ -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>
+28
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+39
View File
@@ -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>
);
}
+91
View File
@@ -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 &mdash; 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>
);
}
+22
View File
@@ -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>
&copy; {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>
);
}
+58
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+40
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-950 text-gray-100 font-sans antialiased;
}
}
+10
View File
@@ -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>
);
+181
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+324
View File
@@ -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://&lt;your-ip&gt;: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://&lt;your-ip&gt;: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>
);
}
+77
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+63
View File
@@ -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;
+27
View File
@@ -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' }
)
);
+22
View File
@@ -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: [],
};
+23
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -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,
},
},
},
});