feat: rewrite website in PHP, drop Node.js stack
Replace React/Vite frontend + Fastify API with a single PHP application. - Front controller router in public/index.php (clean URLs via .htaccess) - Session-based auth (no JWT) — register, login, verify email, forgot/reset password, logout - Stripe checkout sessions, billing portal, webhook handler (checkout.session.completed, subscription.updated/deleted) - License IP change via internal licensing server API - PHP classes: DB (PDO), Auth, Mailer (PHPMailer), StripeClient - Views: home, pricing, install, login, register, dashboard, verify-email, forgot-password, reset-password, 404 - Tailwind CSS via CDN — no build step required - Composer dependencies: stripe/stripe-php, phpmailer/phpmailer, vlucas/phpdotenv, ramsey/uuid - CSRF protection on all POST forms - PRG pattern with session flash messages Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
72f5cc1060
commit
faf0818dde
@@ -1,27 +1,21 @@
|
||||
# Server
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
APP_URL=https://nixpanel.io
|
||||
APP_ENV=production
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://nixpanel.io
|
||||
|
||||
# Database (MariaDB / MySQL)
|
||||
# Database
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=nixpanel_web
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_NAME=nixpanel_website
|
||||
DB_USER=nixpanel_web
|
||||
DB_PASSWORD=your_secure_password
|
||||
|
||||
# JWT — generate with: openssl rand -hex 64
|
||||
JWT_SECRET=changeme_use_openssl_rand_hex_64
|
||||
JWT_EXPIRES_IN=7d
|
||||
# Session
|
||||
SESSION_SECRET=change_this_to_a_long_random_string
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_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_...
|
||||
@@ -29,16 +23,14 @@ 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 (SMTP)
|
||||
MAIL_HOST=smtp.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USER=[email protected]
|
||||
MAIL_PASSWORD=your_smtp_password
|
||||
MAIL_FROM=[email protected]
|
||||
MAIL_FROM_NAME=NixPanel
|
||||
|
||||
# Public URL (used in emails and redirects)
|
||||
APP_URL=https://nixpanel.io
|
||||
# Internal licensing server
|
||||
LICENSING_SERVER_URL=http://localhost:3000
|
||||
LICENSING_SERVER_API_KEY=your_internal_api_key
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
vendor/
|
||||
.env
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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.' });
|
||||
});
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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 };
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "nixpanel/website",
|
||||
"description": "NixPanel.io marketing website and customer portal",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"stripe/stripe-php": "^13.0",
|
||||
"phpmailer/phpmailer": "^6.9",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"ramsey/uuid": "^4.7"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"NixPanel\\": "src/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
-- NixPanel Website — Customer Schema
|
||||
-- MariaDB / MySQL compatible
|
||||
-- NixPanel Website — Database Schema
|
||||
-- Run with: mariadb -u root nixpanel_website < migrations/001_schema.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
@@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS customers (
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_stripe_customer (stripe_customer_id)
|
||||
);
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
INDEX idx_license_id (license_id),
|
||||
INDEX idx_stripe_sub (stripe_subscription_id),
|
||||
INDEX idx_server_ip (server_ip)
|
||||
);
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_change_requests (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
@@ -52,4 +52,4 @@ CREATE TABLE IF NOT EXISTS ip_change_requests (
|
||||
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)
|
||||
);
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,9 @@
|
||||
Options -Indexes
|
||||
RewriteEngine On
|
||||
|
||||
# Skip real files and directories
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Route everything through index.php
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../src/helpers.php';
|
||||
|
||||
use NixPanel\Auth;
|
||||
use NixPanel\DB;
|
||||
use NixPanel\Mailer;
|
||||
use NixPanel\StripeClient;
|
||||
|
||||
// Load .env
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
|
||||
$dotenv->load();
|
||||
|
||||
// Stripe webhook — must capture raw body before session start
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($uri === '/webhook/stripe' && $method === 'POST') {
|
||||
$payload = file_get_contents('php://input');
|
||||
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
|
||||
$event = StripeClient::constructWebhookEvent($payload, $sigHeader);
|
||||
|
||||
try {
|
||||
handleStripeWebhook($event);
|
||||
http_response_code(200);
|
||||
echo json_encode(['received' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Stripe webhook error: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Start session for all other requests
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
if ($_ENV['APP_ENV'] === 'production') {
|
||||
ini_set('session.cookie_secure', '1');
|
||||
}
|
||||
session_start();
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
match (true) {
|
||||
|
||||
// ── Public GET pages ──────────────────────────────────────────────────────
|
||||
$method === 'GET' && $uri === '/' => view('home'),
|
||||
$method === 'GET' && $uri === '/pricing' => view('pricing'),
|
||||
$method === 'GET' && $uri === '/install' => view('install'),
|
||||
|
||||
// ── Auth GET pages ────────────────────────────────────────────────────────
|
||||
$method === 'GET' && $uri === '/login' => (function () {
|
||||
Auth::requireGuest();
|
||||
view('login');
|
||||
})(),
|
||||
|
||||
$method === 'GET' && $uri === '/register' => (function () {
|
||||
Auth::requireGuest();
|
||||
view('register');
|
||||
})(),
|
||||
|
||||
$method === 'GET' && $uri === '/verify-email' => (function () {
|
||||
$token = $_GET['token'] ?? '';
|
||||
$ok = \NixPanel\Auth::verifyEmail($token);
|
||||
view('verify-email', ['verified' => $ok]);
|
||||
})(),
|
||||
|
||||
$method === 'GET' && $uri === '/forgot-password' => (function () {
|
||||
Auth::requireGuest();
|
||||
view('forgot-password');
|
||||
})(),
|
||||
|
||||
$method === 'GET' && $uri === '/reset-password' => (function () {
|
||||
Auth::requireGuest();
|
||||
$token = $_GET['token'] ?? '';
|
||||
view('reset-password', ['token' => $token]);
|
||||
})(),
|
||||
|
||||
$method === 'GET' && $uri === '/dashboard' => (function () {
|
||||
Auth::requireLogin();
|
||||
$customer = Auth::customer();
|
||||
$subscription = DB::fetch(
|
||||
'SELECT * FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[$customer['id']]
|
||||
);
|
||||
$license = null;
|
||||
if ($subscription) {
|
||||
$license = fetchLicense($subscription['license_id']);
|
||||
}
|
||||
view('dashboard', compact('customer', 'subscription', 'license'));
|
||||
})(),
|
||||
|
||||
$method === 'GET' && $uri === '/logout' => (function () {
|
||||
Auth::logout();
|
||||
redirect('/');
|
||||
})(),
|
||||
|
||||
// ── Auth POST handlers ────────────────────────────────────────────────────
|
||||
$method === 'POST' && $uri === '/login' => (function () {
|
||||
csrf_verify();
|
||||
Auth::requireGuest();
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
try {
|
||||
Auth::login($email, $password);
|
||||
redirect('/dashboard');
|
||||
} catch (\RuntimeException $e) {
|
||||
flash('error', $e->getMessage());
|
||||
save_old(['email']);
|
||||
redirect('/login');
|
||||
}
|
||||
})(),
|
||||
|
||||
$method === 'POST' && $uri === '/register' => (function () {
|
||||
csrf_verify();
|
||||
Auth::requireGuest();
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$fullName = trim($_POST['full_name'] ?? '');
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
flash('error', 'Password must be at least 8 characters.');
|
||||
save_old(['email', 'full_name']);
|
||||
redirect('/register');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = \NixPanel\Auth::register($email, $password, $fullName);
|
||||
Mailer::sendVerificationEmail($email, $result['verify_token']);
|
||||
flash('success', 'Account created! Check your email to verify your account.');
|
||||
redirect('/login');
|
||||
} catch (\RuntimeException $e) {
|
||||
flash('error', $e->getMessage());
|
||||
save_old(['email', 'full_name']);
|
||||
redirect('/register');
|
||||
}
|
||||
})(),
|
||||
|
||||
$method === 'POST' && $uri === '/forgot-password' => (function () {
|
||||
csrf_verify();
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$token = \NixPanel\Auth::initiatePasswordReset($email);
|
||||
if ($token) {
|
||||
try {
|
||||
Mailer::sendPasswordResetEmail($email, $token);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Failed to send reset email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
flash('success', 'If that email exists, a reset link has been sent.');
|
||||
redirect('/forgot-password');
|
||||
})(),
|
||||
|
||||
$method === 'POST' && $uri === '/reset-password' => (function () {
|
||||
csrf_verify();
|
||||
$token = $_POST['token'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
$confirm = $_POST['password_confirm'] ?? '';
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
flash('error', 'Password must be at least 8 characters.');
|
||||
redirect('/reset-password?token=' . urlencode($token));
|
||||
}
|
||||
if ($password !== $confirm) {
|
||||
flash('error', 'Passwords do not match.');
|
||||
redirect('/reset-password?token=' . urlencode($token));
|
||||
}
|
||||
|
||||
$ok = \NixPanel\Auth::resetPassword($token, $password);
|
||||
if (!$ok) {
|
||||
flash('error', 'Invalid or expired reset link. Please request a new one.');
|
||||
redirect('/forgot-password');
|
||||
}
|
||||
|
||||
flash('success', 'Password updated. You can now log in.');
|
||||
redirect('/login');
|
||||
})(),
|
||||
|
||||
// ── Billing ───────────────────────────────────────────────────────────────
|
||||
$method === 'POST' && $uri === '/checkout' => (function () {
|
||||
csrf_verify();
|
||||
Auth::requireLogin();
|
||||
$customer = Auth::customer();
|
||||
$planKey = $_POST['plan_key'] ?? '';
|
||||
$interval = $_POST['interval'] === 'annual' ? 'annual' : 'monthly';
|
||||
$serverIp = trim($_POST['server_ip'] ?? '');
|
||||
|
||||
if (!in_array($planKey, ['solo', 'host', 'agency'], true)) {
|
||||
flash('error', 'Invalid plan selected.');
|
||||
redirect('/pricing');
|
||||
}
|
||||
if (!is_valid_ip($serverIp)) {
|
||||
flash('error', 'Please enter a valid server IPv4 address.');
|
||||
redirect('/pricing');
|
||||
}
|
||||
|
||||
try {
|
||||
$url = StripeClient::createCheckoutSession(
|
||||
$customer['id'], $customer['email'], $planKey, $interval, $serverIp
|
||||
);
|
||||
redirect($url);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Checkout error: ' . $e->getMessage());
|
||||
flash('error', 'Could not start checkout. Please try again.');
|
||||
redirect('/pricing');
|
||||
}
|
||||
})(),
|
||||
|
||||
$method === 'POST' && $uri === '/billing/portal' => (function () {
|
||||
csrf_verify();
|
||||
Auth::requireLogin();
|
||||
$customer = Auth::customer();
|
||||
$row = DB::fetch('SELECT stripe_customer_id FROM customers WHERE id = ?', [$customer['id']]);
|
||||
|
||||
if (!$row || !$row['stripe_customer_id']) {
|
||||
flash('error', 'No billing account found.');
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
try {
|
||||
$url = StripeClient::createPortalSession($row['stripe_customer_id']);
|
||||
redirect($url);
|
||||
} catch (\Throwable $e) {
|
||||
flash('error', 'Could not open billing portal.');
|
||||
redirect('/dashboard');
|
||||
}
|
||||
})(),
|
||||
|
||||
$method === 'POST' && $uri === '/billing/cancel' => (function () {
|
||||
csrf_verify();
|
||||
Auth::requireLogin();
|
||||
$customer = Auth::customer();
|
||||
$subscription = DB::fetch(
|
||||
'SELECT * FROM subscriptions WHERE customer_id = ? AND status = "active" LIMIT 1',
|
||||
[$customer['id']]
|
||||
);
|
||||
|
||||
if (!$subscription) {
|
||||
flash('error', 'No active subscription found.');
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
try {
|
||||
StripeClient::cancelSubscription($subscription['stripe_subscription_id']);
|
||||
DB::execute(
|
||||
'UPDATE subscriptions SET cancel_at_period_end = 1 WHERE id = ?',
|
||||
[$subscription['id']]
|
||||
);
|
||||
flash('success', 'Your subscription will cancel at the end of the billing period.');
|
||||
} catch (\Throwable $e) {
|
||||
flash('error', 'Could not cancel subscription. Please try again.');
|
||||
}
|
||||
|
||||
redirect('/dashboard');
|
||||
})(),
|
||||
|
||||
$method === 'POST' && $uri === '/license/change-ip' => (function () {
|
||||
csrf_verify();
|
||||
Auth::requireLogin();
|
||||
$customer = Auth::customer();
|
||||
$newIp = trim($_POST['server_ip'] ?? '');
|
||||
|
||||
if (!is_valid_ip($newIp)) {
|
||||
flash('error', 'Invalid IP address.');
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
$subscription = DB::fetch(
|
||||
'SELECT * FROM subscriptions WHERE customer_id = ? AND status = "active" LIMIT 1',
|
||||
[$customer['id']]
|
||||
);
|
||||
|
||||
if (!$subscription) {
|
||||
flash('error', 'No active subscription found.');
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
// Call internal licensing server
|
||||
$result = callLicensingServer('PUT', '/licenses/' . $subscription['license_id'] . '/ip', ['server_ip' => $newIp]);
|
||||
|
||||
if ($result['ok']) {
|
||||
DB::execute(
|
||||
'UPDATE subscriptions SET server_ip = ? WHERE id = ?',
|
||||
[$newIp, $subscription['id']]
|
||||
);
|
||||
flash('success', 'Server IP updated to ' . $newIp . '.');
|
||||
} else {
|
||||
flash('error', $result['error'] ?? 'Failed to update IP. Please try again.');
|
||||
}
|
||||
|
||||
redirect('/dashboard');
|
||||
})(),
|
||||
|
||||
// ── 404 ───────────────────────────────────────────────────────────────────
|
||||
default => (function () {
|
||||
http_response_code(404);
|
||||
view('404');
|
||||
})(),
|
||||
};
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function fetchLicense(string $licenseId): ?array
|
||||
{
|
||||
$result = callLicensingServer('GET', '/licenses/' . $licenseId);
|
||||
return $result['ok'] ? $result['data'] : null;
|
||||
}
|
||||
|
||||
function callLicensingServer(string $method, string $path, array $body = []): array
|
||||
{
|
||||
$url = rtrim($_ENV['LICENSING_SERVER_URL'], '/') . $path;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Api-Key: ' . $_ENV['LICENSING_SERVER_API_KEY'],
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode >= 500) {
|
||||
return ['ok' => false, 'error' => 'Licensing server unavailable'];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
return ['ok' => $httpCode < 400, 'data' => $data, 'error' => $data['error'] ?? null];
|
||||
}
|
||||
|
||||
function handleStripeWebhook(\Stripe\Event $event): void
|
||||
{
|
||||
$data = $event->data->object;
|
||||
|
||||
switch ($event->type) {
|
||||
case 'checkout.session.completed':
|
||||
if ($data->mode !== 'subscription') break;
|
||||
$meta = $data->metadata;
|
||||
$customerId = $meta->customer_id;
|
||||
$planKey = $meta->plan_key;
|
||||
$interval = $meta->interval;
|
||||
$serverIp = $meta->server_ip;
|
||||
$stripeSubId = $data->subscription;
|
||||
$stripeCustId = $data->customer;
|
||||
|
||||
// Fetch subscription details from Stripe
|
||||
\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);
|
||||
$sub = \Stripe\Subscription::retrieve($stripeSubId);
|
||||
|
||||
// Create license via licensing server
|
||||
$licenseResult = callLicensingServer('POST', '/licenses', [
|
||||
'customer_id' => $customerId,
|
||||
'plan_key' => $planKey,
|
||||
'server_ip' => $serverIp,
|
||||
]);
|
||||
|
||||
$licenseId = $licenseResult['data']['id'] ?? \Ramsey\Uuid\Uuid::uuid4()->toString();
|
||||
|
||||
DB::execute(
|
||||
'UPDATE customers SET stripe_customer_id = ? WHERE id = ?',
|
||||
[$stripeCustId, $customerId]
|
||||
);
|
||||
|
||||
DB::execute(
|
||||
'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", ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?))',
|
||||
[
|
||||
\Ramsey\Uuid\Uuid::uuid4()->toString(),
|
||||
$customerId, $licenseId, $stripeSubId,
|
||||
$sub->items->data[0]->price->id,
|
||||
$planKey, $interval, $serverIp,
|
||||
$sub->current_period_start, $sub->current_period_end,
|
||||
]
|
||||
);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
$status = $data->status;
|
||||
$cancelAtPeriodEnd = $data->cancel_at_period_end ? 1 : 0;
|
||||
DB::execute(
|
||||
'UPDATE subscriptions SET status = ?, cancel_at_period_end = ?,
|
||||
current_period_start = FROM_UNIXTIME(?), current_period_end = FROM_UNIXTIME(?)
|
||||
WHERE stripe_subscription_id = ?',
|
||||
[
|
||||
$status, $cancelAtPeriodEnd,
|
||||
$data->current_period_start, $data->current_period_end,
|
||||
$data->id,
|
||||
]
|
||||
);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
DB::execute(
|
||||
'UPDATE subscriptions SET status = "canceled", canceled_at = NOW() WHERE stripe_subscription_id = ?',
|
||||
[$data->id]
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace NixPanel;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class Auth
|
||||
{
|
||||
public static function isLoggedIn(): bool
|
||||
{
|
||||
return isset($_SESSION['customer_id']);
|
||||
}
|
||||
|
||||
public static function customer(): ?array
|
||||
{
|
||||
return $_SESSION['customer'] ?? null;
|
||||
}
|
||||
|
||||
public static function requireLogin(): void
|
||||
{
|
||||
if (!self::isLoggedIn()) {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireGuest(): void
|
||||
{
|
||||
if (self::isLoggedIn()) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
public static function register(string $email, string $password, string $fullName): array
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
if (DB::fetch('SELECT id FROM customers WHERE email = ?', [$email])) {
|
||||
throw new \RuntimeException('An account with that email already exists.');
|
||||
}
|
||||
|
||||
$id = Uuid::uuid4()->toString();
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$verifyToken = bin2hex(random_bytes(32));
|
||||
|
||||
DB::execute(
|
||||
'INSERT INTO customers (id, email, password_hash, full_name, verify_token) VALUES (?, ?, ?, ?, ?)',
|
||||
[$id, $email, $hash, $fullName, $verifyToken]
|
||||
);
|
||||
|
||||
return ['id' => $id, 'email' => $email, 'verify_token' => $verifyToken];
|
||||
}
|
||||
|
||||
public static function login(string $email, string $password): array
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
$customer = DB::fetch('SELECT * FROM customers WHERE email = ?', [$email]);
|
||||
|
||||
if (!$customer || !password_verify($password, $customer['password_hash'])) {
|
||||
throw new \RuntimeException('Invalid email or password.');
|
||||
}
|
||||
|
||||
if (!$customer['is_verified']) {
|
||||
throw new \RuntimeException('Please verify your email before logging in.');
|
||||
}
|
||||
|
||||
unset($customer['password_hash'], $customer['verify_token'], $customer['reset_token'], $customer['reset_token_expires']);
|
||||
|
||||
$_SESSION['customer_id'] = $customer['id'];
|
||||
$_SESSION['customer'] = $customer;
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
public static function verifyEmail(string $token): bool
|
||||
{
|
||||
$customer = DB::fetch('SELECT id FROM customers WHERE verify_token = ? AND is_verified = 0', [$token]);
|
||||
|
||||
if (!$customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::execute(
|
||||
'UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?',
|
||||
[$customer['id']]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function initiatePasswordReset(string $email): ?string
|
||||
{
|
||||
$customer = DB::fetch('SELECT id FROM customers WHERE email = ?', [strtolower($email)]);
|
||||
if (!$customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||
|
||||
DB::execute(
|
||||
'UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
|
||||
[$token, $expires, $customer['id']]
|
||||
);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function resetPassword(string $token, string $newPassword): bool
|
||||
{
|
||||
$customer = DB::fetch(
|
||||
'SELECT id FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()',
|
||||
[$token]
|
||||
);
|
||||
|
||||
if (!$customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
|
||||
DB::execute(
|
||||
'UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
|
||||
[$hash, $customer['id']]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace NixPanel;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class DB
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function connect(): PDO
|
||||
{
|
||||
if (self::$pdo !== null) {
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||
$_ENV['DB_HOST'],
|
||||
$_ENV['DB_PORT'] ?? 3306,
|
||||
$_ENV['DB_NAME']
|
||||
);
|
||||
|
||||
try {
|
||||
self::$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('DB connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
die('Database connection error.');
|
||||
}
|
||||
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
public static function query(string $sql, array $params = []): \PDOStatement
|
||||
{
|
||||
$stmt = self::connect()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public static function fetch(string $sql, array $params = []): ?array
|
||||
{
|
||||
$row = self::query($sql, $params)->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
return self::query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
public static function execute(string $sql, array $params = []): int
|
||||
{
|
||||
return self::query($sql, $params)->rowCount();
|
||||
}
|
||||
|
||||
public static function lastInsertId(): string
|
||||
{
|
||||
return self::connect()->lastInsertId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace NixPanel;
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
private static function make(): PHPMailer
|
||||
{
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $_ENV['MAIL_HOST'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $_ENV['MAIL_USER'];
|
||||
$mail->Password = $_ENV['MAIL_PASSWORD'];
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 587);
|
||||
$mail->setFrom($_ENV['MAIL_FROM'], $_ENV['MAIL_FROM_NAME'] ?? 'NixPanel');
|
||||
$mail->isHTML(true);
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public static function sendVerificationEmail(string $email, string $token): void
|
||||
{
|
||||
$url = rtrim($_ENV['APP_URL'], '/') . '/verify-email?token=' . urlencode($token);
|
||||
$mail = self::make();
|
||||
$mail->addAddress($email);
|
||||
$mail->Subject = 'Verify your NixPanel account';
|
||||
$mail->Body = self::template('Verify your email', "
|
||||
<p style='color:#9ca3af;font-size:15px;'>Thanks for signing up! Click the button below to verify your email address.</p>
|
||||
<p style='margin-top:28px;'>
|
||||
<a href='{$url}' style='background:#2563eb;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;'>
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
<p style='color:#6b7280;font-size:13px;margin-top:24px;'>Or copy this link:<br><a href='{$url}' style='color:#3b82f6;'>{$url}</a></p>
|
||||
<p style='color:#6b7280;font-size:13px;margin-top:16px;'>This link expires in 24 hours.</p>
|
||||
");
|
||||
$mail->send();
|
||||
}
|
||||
|
||||
public static function sendPasswordResetEmail(string $email, string $token): void
|
||||
{
|
||||
$url = rtrim($_ENV['APP_URL'], '/') . '/reset-password?token=' . urlencode($token);
|
||||
$mail = self::make();
|
||||
$mail->addAddress($email);
|
||||
$mail->Subject = 'Reset your NixPanel password';
|
||||
$mail->Body = self::template('Reset your password', "
|
||||
<p style='color:#9ca3af;font-size:15px;'>We received a request to reset your password. Click the button below.</p>
|
||||
<p style='margin-top:28px;'>
|
||||
<a href='{$url}' style='background:#2563eb;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;'>
|
||||
Reset Password
|
||||
</a>
|
||||
</p>
|
||||
<p style='color:#6b7280;font-size:13px;margin-top:24px;'>This link expires in 1 hour. If you didn't request a reset, ignore this email.</p>
|
||||
");
|
||||
$mail->send();
|
||||
}
|
||||
|
||||
private static function template(string $title, string $body): string
|
||||
{
|
||||
return "
|
||||
<!DOCTYPE html>
|
||||
<html><body style='background:#030712;font-family:system-ui,sans-serif;padding:40px 20px;margin:0;'>
|
||||
<div style='max-width:520px;margin:0 auto;background:#111827;border:1px solid #1f2937;border-radius:12px;padding:40px;'>
|
||||
<p style='font-size:22px;font-weight:700;color:#fff;margin:0 0 24px;'>
|
||||
Nix<span style='color:#3b82f6;'>Panel</span>
|
||||
</p>
|
||||
<h1 style='color:#fff;font-size:20px;margin:0 0 16px;'>{$title}</h1>
|
||||
{$body}
|
||||
</div>
|
||||
<p style='text-align:center;color:#374151;font-size:12px;margin-top:24px;'>© " . date('Y') . " NixPanel. All rights reserved.</p>
|
||||
</body></html>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace NixPanel;
|
||||
|
||||
use Stripe\Stripe;
|
||||
use Stripe\Checkout\Session as CheckoutSession;
|
||||
use Stripe\BillingPortal\Session as PortalSession;
|
||||
use Stripe\Webhook;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
|
||||
class StripeClient
|
||||
{
|
||||
private static bool $configured = false;
|
||||
|
||||
private static function configure(): void
|
||||
{
|
||||
if (!self::$configured) {
|
||||
Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);
|
||||
self::$configured = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map plan_key + interval to a Stripe price ID from .env */
|
||||
public static function priceId(string $planKey, string $interval): string
|
||||
{
|
||||
$map = [
|
||||
'solo' => ['monthly' => 'STRIPE_PRICE_SOLO_MONTHLY', 'annual' => 'STRIPE_PRICE_SOLO_ANNUAL'],
|
||||
'host' => ['monthly' => 'STRIPE_PRICE_HOST_MONTHLY', 'annual' => 'STRIPE_PRICE_HOST_ANNUAL'],
|
||||
'agency' => ['monthly' => 'STRIPE_PRICE_AGENCY_MONTHLY', 'annual' => 'STRIPE_PRICE_AGENCY_ANNUAL'],
|
||||
];
|
||||
|
||||
$key = $map[$planKey][$interval] ?? null;
|
||||
if (!$key || empty($_ENV[$key])) {
|
||||
throw new \InvalidArgumentException("Unknown plan: {$planKey}/{$interval}");
|
||||
}
|
||||
|
||||
return $_ENV[$key];
|
||||
}
|
||||
|
||||
public static function createCheckoutSession(
|
||||
string $customerId,
|
||||
string $email,
|
||||
string $planKey,
|
||||
string $interval,
|
||||
string $serverIp
|
||||
): string {
|
||||
self::configure();
|
||||
|
||||
$priceId = self::priceId($planKey, $interval);
|
||||
$appUrl = rtrim($_ENV['APP_URL'], '/');
|
||||
|
||||
$params = [
|
||||
'mode' => 'subscription',
|
||||
'line_items' => [['price' => $priceId, 'quantity' => 1]],
|
||||
'customer_email' => $email,
|
||||
'success_url' => $appUrl . '/dashboard?checkout=success',
|
||||
'cancel_url' => $appUrl . '/pricing',
|
||||
'metadata' => [
|
||||
'customer_id' => $customerId,
|
||||
'plan_key' => $planKey,
|
||||
'interval' => $interval,
|
||||
'server_ip' => $serverIp,
|
||||
],
|
||||
'subscription_data' => [
|
||||
'metadata' => [
|
||||
'customer_id' => $customerId,
|
||||
'plan_key' => $planKey,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Attach existing Stripe customer if we have one
|
||||
$customer = DB::fetch('SELECT stripe_customer_id FROM customers WHERE id = ?', [$customerId]);
|
||||
if ($customer && $customer['stripe_customer_id']) {
|
||||
$params['customer'] = $customer['stripe_customer_id'];
|
||||
unset($params['customer_email']);
|
||||
}
|
||||
|
||||
$session = CheckoutSession::create($params);
|
||||
return $session->url;
|
||||
}
|
||||
|
||||
public static function createPortalSession(string $stripeCustomerId): string
|
||||
{
|
||||
self::configure();
|
||||
|
||||
$session = PortalSession::create([
|
||||
'customer' => $stripeCustomerId,
|
||||
'return_url' => rtrim($_ENV['APP_URL'], '/') . '/dashboard',
|
||||
]);
|
||||
|
||||
return $session->url;
|
||||
}
|
||||
|
||||
public static function constructWebhookEvent(string $payload, string $sigHeader): \Stripe\Event
|
||||
{
|
||||
self::configure();
|
||||
|
||||
try {
|
||||
return Webhook::constructEvent($payload, $sigHeader, $_ENV['STRIPE_WEBHOOK_SECRET']);
|
||||
} catch (SignatureVerificationException $e) {
|
||||
http_response_code(400);
|
||||
exit('Webhook signature verification failed.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function cancelSubscription(string $stripeSubscriptionId): void
|
||||
{
|
||||
self::configure();
|
||||
|
||||
\Stripe\Subscription::update($stripeSubscriptionId, [
|
||||
'cancel_at_period_end' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
function redirect(string $path, int $code = 302): never
|
||||
{
|
||||
header('Location: ' . $path, true, $code);
|
||||
exit;
|
||||
}
|
||||
|
||||
function flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
function get_flash(): ?array
|
||||
{
|
||||
$flash = $_SESSION['flash'] ?? null;
|
||||
unset($_SESSION['flash']);
|
||||
return $flash;
|
||||
}
|
||||
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars(csrf_token()) . '">';
|
||||
}
|
||||
|
||||
function csrf_verify(): void
|
||||
{
|
||||
$token = $_POST['_csrf'] ?? '';
|
||||
if (!hash_equals(csrf_token(), $token)) {
|
||||
http_response_code(403);
|
||||
die('Invalid CSRF token.');
|
||||
}
|
||||
}
|
||||
|
||||
function e(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function old(string $key, string $default = ''): string
|
||||
{
|
||||
return e($_SESSION['old'][$key] ?? $default);
|
||||
}
|
||||
|
||||
function save_old(array $fields): void
|
||||
{
|
||||
$_SESSION['old'] = array_map('strval', array_intersect_key($_POST, array_flip($fields)));
|
||||
}
|
||||
|
||||
function error(string $key): string
|
||||
{
|
||||
$errors = $_SESSION['errors'] ?? [];
|
||||
unset($_SESSION['errors']);
|
||||
return $errors[$key] ?? '';
|
||||
}
|
||||
|
||||
function has_errors(): bool
|
||||
{
|
||||
return !empty($_SESSION['errors']);
|
||||
}
|
||||
|
||||
function validation_error(string $field, string $message): void
|
||||
{
|
||||
$_SESSION['errors'][$field] = $message;
|
||||
}
|
||||
|
||||
function is_valid_ip(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
||||
}
|
||||
|
||||
function view(string $name, array $data = []): void
|
||||
{
|
||||
extract($data);
|
||||
$flash = get_flash();
|
||||
require __DIR__ . '/../views/layout/head.php';
|
||||
require __DIR__ . '/../views/layout/header.php';
|
||||
require __DIR__ . '/../views/' . $name . '.php';
|
||||
require __DIR__ . '/../views/layout/footer.php';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php $pageTitle = '404 — NixPanel'; ?>
|
||||
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4 text-center">
|
||||
<div>
|
||||
<p class="text-8xl font-extrabold text-brand-600 mb-4">404</p>
|
||||
<h1 class="text-2xl font-bold text-white mb-3">Page not found</h1>
|
||||
<p class="text-gray-400 mb-8">The page you're looking for doesn't exist.</p>
|
||||
<a href="/" class="btn-primary px-8 py-3">Go home</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php $pageTitle = 'Dashboard — NixPanel'; ?>
|
||||
<?php
|
||||
$planLabels = ['free' => 'Free', 'solo' => 'Solo', 'host' => 'Host', 'agency' => 'Agency'];
|
||||
?>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-2xl font-bold text-white mb-8">Dashboard</h1>
|
||||
|
||||
<?php if (isset($_GET['checkout']) && $_GET['checkout'] === 'success'): ?>
|
||||
<div class="bg-emerald-900/30 border border-emerald-500/30 text-emerald-300 rounded-xl px-5 py-4 mb-6 text-sm">
|
||||
🎉 Subscription activated! Your license has been provisioned.
|
||||
</div>
|
||||
<?php endif ?>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="font-semibold text-white mb-4">Account</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Name</p>
|
||||
<p class="text-white"><?= e($customer['full_name']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Email</p>
|
||||
<p class="text-white"><?= e($customer['email']) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription -->
|
||||
<?php if ($subscription): ?>
|
||||
<div class="card mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold text-white">Subscription</h2>
|
||||
<span class="text-xs font-medium px-2.5 py-1 rounded-full <?= $subscription['status'] === 'active' ? 'bg-emerald-900/40 text-emerald-400' : 'bg-yellow-900/40 text-yellow-400' ?>">
|
||||
<?= e($subscription['status']) ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-3 gap-4 text-sm mb-6">
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Plan</p>
|
||||
<p class="text-white font-medium"><?= e($planLabels[$subscription['plan_key']] ?? $subscription['plan_key']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Billing</p>
|
||||
<p class="text-white capitalize"><?= e($subscription['billing_interval']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Renews</p>
|
||||
<p class="text-white"><?= date('M j, Y', strtotime($subscription['current_period_end'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($subscription['cancel_at_period_end']): ?>
|
||||
<p class="text-yellow-400 text-sm mb-4">
|
||||
⚠ Cancels on <?= date('M j, Y', strtotime($subscription['current_period_end'])) ?>
|
||||
</p>
|
||||
<?php endif ?>
|
||||
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<form method="POST" action="/billing/portal">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="btn-secondary text-sm">Manage billing</button>
|
||||
</form>
|
||||
<?php if (!$subscription['cancel_at_period_end']): ?>
|
||||
<form method="POST" action="/billing/cancel"
|
||||
onsubmit="return confirm('Cancel at end of billing period?')">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-900/20 text-sm transition-colors">
|
||||
Cancel plan
|
||||
</button>
|
||||
</form>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card mb-6 text-center py-10">
|
||||
<p class="text-gray-400 mb-4">No active subscription.</p>
|
||||
<a href="/pricing" class="btn-primary px-6 py-2.5">View plans</a>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
|
||||
<!-- License -->
|
||||
<?php if ($license): ?>
|
||||
<div class="card mb-6">
|
||||
<h2 class="font-semibold text-white mb-4">License</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-4 text-sm mb-6">
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">License ID</p>
|
||||
<p class="text-white font-mono text-xs truncate"><?= e($license['id']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Server IP</p>
|
||||
<p class="text-white font-mono"><?= e($license['server_ip'] ?? '—') ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Status</p>
|
||||
<p class="text-emerald-400 capitalize"><?= e($license['status'] ?? 'active') ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 mb-0.5">Last verified</p>
|
||||
<p class="text-white"><?= $license['last_verified'] ? date('M j, Y g:ia', strtotime($license['last_verified'])) : 'Never' ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-800 pt-4">
|
||||
<h3 class="text-sm font-medium text-white mb-3">Change server IP</h3>
|
||||
<form method="POST" action="/license/change-ip" class="flex gap-3 items-start">
|
||||
<?= csrf_field() ?>
|
||||
<input type="text" name="server_ip" placeholder="New IPv4 address (e.g. 1.2.3.4)"
|
||||
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
|
||||
class="input flex-1 text-sm">
|
||||
<button type="submit" class="btn-secondary text-sm flex-shrink-0">Update IP</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php $pageTitle = 'Forgot Password — NixPanel'; ?>
|
||||
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Reset your password</h1>
|
||||
<p class="text-gray-400 mt-2">We'll send a reset link to your email</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST" action="/forgot-password" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Email address</label>
|
||||
<input type="email" name="email" required autofocus
|
||||
class="input" placeholder="[email protected]">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full justify-center py-3">Send reset link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 mt-6">
|
||||
<a href="/login" class="text-brand-400 hover:underline">Back to sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php $pageTitle = 'NixPanel — Modern Web Hosting Control Panel'; ?>
|
||||
<?php
|
||||
$features = [
|
||||
['icon' => '⚡', 'title' => 'Lightning Fast', 'body' => 'Built for performance. Nginx support, PHP-FPM pools, and optimized defaults out of the box.'],
|
||||
['icon' => '🔒', 'title' => 'Security First', 'body' => "Hardened defaults, Let's Encrypt SSL automation, Fail2ban, and two-factor auth."],
|
||||
['icon' => '🌐', 'title' => 'Full DNS Control', 'body' => 'Manage BIND9 zones and records directly from the panel. No third-party DNS required.'],
|
||||
['icon' => '📊', 'title' => 'Complete Control', 'body' => 'Manage email, databases, cron jobs, FTP accounts, and subdomains in one clean dashboard.'],
|
||||
['icon' => '🔄', 'title' => 'Easy Migration', 'body' => 'Moving from cPanel? Import your accounts with a single command using our migration tool.'],
|
||||
['icon' => '💳', 'title' => 'Simple Licensing', 'body' => 'Tied to your server IP. No per-user fees, no surprises. Upgrade or cancel anytime.'],
|
||||
];
|
||||
?>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pt-24 pb-20 text-center">
|
||||
<div class="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 class="text-5xl sm:text-6xl font-extrabold text-white leading-tight tracking-tight">
|
||||
The hosting control panel<br>
|
||||
<span class="text-brand-500">you actually want to use.</span>
|
||||
</h1>
|
||||
<p class="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 sites, and control everything from a clean, fast dashboard.
|
||||
</p>
|
||||
<div class="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/register" class="btn-primary text-base px-7 py-3">Get started free</a>
|
||||
<a href="/install" class="btn-outline text-base px-7 py-3">Install guide</a>
|
||||
<a href="/pricing" class="btn-secondary text-base px-7 py-3">View pricing</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<h2 class="text-3xl font-bold text-white text-center mb-12">Everything you need to run a server</h2>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<?php foreach ($features as $f): ?>
|
||||
<div class="card">
|
||||
<div class="text-3xl mb-3"><?= $f['icon'] ?></div>
|
||||
<h3 class="font-semibold text-white mb-2"><?= e($f['title']) ?></h3>
|
||||
<p class="text-sm text-gray-400"><?= e($f['body']) ?></p>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div class="bg-brand-900/20 border border-brand-500/20 rounded-2xl p-12 text-center">
|
||||
<h2 class="text-3xl font-bold text-white mb-4">Ready to take control?</h2>
|
||||
<p class="text-gray-400 mb-8">Start free. Upgrade when you need more.</p>
|
||||
<a href="/register" class="btn-primary text-base px-8 py-3">Create your account</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php $pageTitle = 'Install NixPanel — Server Installation Guide'; ?>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
|
||||
<div class="mb-12">
|
||||
<div class="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 class="text-4xl font-extrabold text-white mb-4">Install NixPanel</h1>
|
||||
<p class="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>
|
||||
|
||||
<?php
|
||||
$requirements = [
|
||||
'Ubuntu 20.04 / 22.04 / 24.04', 'Debian 11 / 12',
|
||||
'AlmaLinux 8 / 9', 'Rocky Linux 8 / 9', 'RHEL 8 / 9',
|
||||
];
|
||||
$minReqs = [
|
||||
'Root access (sudo)', 'x86_64 or aarch64 architecture',
|
||||
'1 GB RAM (2 GB+ recommended)', '10 GB disk space',
|
||||
'Fresh / minimal OS install', 'Internet access from the server',
|
||||
];
|
||||
?>
|
||||
|
||||
<!-- Requirements -->
|
||||
<div class="mb-12 grid sm:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-white font-semibold mb-4">Supported OS</h2>
|
||||
<ul class="space-y-2">
|
||||
<?php foreach ($requirements as $os): ?>
|
||||
<li class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span class="text-emerald-500">✓</span> <?= e($os) ?>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-white font-semibold mb-4">Minimum Requirements</h2>
|
||||
<ul class="space-y-2">
|
||||
<?php foreach ($minReqs as $req): ?>
|
||||
<li class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span class="text-emerald-500">✓</span> <?= e($req) ?>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick install -->
|
||||
<div class="mb-14">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Quick Install</h2>
|
||||
<p class="text-gray-400 text-sm mb-1">Run this single command as root on a fresh server.</p>
|
||||
<?php
|
||||
$oneLiner = 'curl -fsSL https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/scripts/install.sh | sudo bash';
|
||||
code_block($oneLiner);
|
||||
?>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<?php foreach (['Auto-detects OS', 'Generates random secrets', 'Configures firewall', 'Creates systemd service'] as $b): ?>
|
||||
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"><?= e($b) ?></span>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500">With custom credentials:</p>
|
||||
<?php code_block("curl -fsSL https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/scripts/install.sh | sudo bash -s -- \\\n --admin-user admin \\\n --admin-pass \"YourSecurePassword\" \\\n --admin-email \"[email protected]\"") ?>
|
||||
</div>
|
||||
|
||||
<!-- After install -->
|
||||
<div class="mb-14 bg-brand-900/20 border border-brand-500/20 rounded-xl p-6">
|
||||
<h2 class="text-white font-semibold text-lg mb-3">After Installation</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
Credentials are printed on screen and saved to
|
||||
<code class="text-brand-400 bg-gray-900 px-1.5 py-0.5 rounded text-xs">/root/.nixpanel_credentials</code>.
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 mb-1 uppercase tracking-wide font-medium">NixServer — Admin Panel</p>
|
||||
<p class="text-white font-mono text-sm">http://<your-ip>:2087</p>
|
||||
</div>
|
||||
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 mb-1 uppercase tracking-wide font-medium">NixClient — User Panel</p>
|
||||
<p class="text-white font-mono text-sm">http://<your-ip>:2083</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual install -->
|
||||
<div class="mb-14">
|
||||
<h2 class="text-2xl font-bold text-white mb-1">Manual Installation</h2>
|
||||
<p class="text-gray-400 text-sm mb-8">Prefer full control? Install system dependencies first (Node.js 22, MariaDB, Nginx, etc.), then follow these steps.</p>
|
||||
|
||||
<?php
|
||||
$steps = [
|
||||
['Clone the repository', "git clone https://github.com/NixReaper/nixpanel-server.git /opt/nixpanel\ncd /opt/nixpanel"],
|
||||
['Configure environment variables', "cp .env.example .env\nnano .env # set your passwords and secrets"],
|
||||
['Install, migrate, and build', "npm install --workspaces --include-workspace-root\ncd server && npx prisma generate && npx prisma migrate deploy\nADMIN_USERNAME=admin ADMIN_PASSWORD=yourpass npx tsx src/db/seed.ts\ncd ..\nnpm run build --workspace=nixserver\nnpm run build --workspace=nixclient"],
|
||||
['Create the systemd service', "cp scripts/nixpanel.service /etc/systemd/system/nixpanel.service\nsystemctl daemon-reload\nsystemctl enable --now nixpanel"],
|
||||
];
|
||||
foreach ($steps as $i => [$title, $cmd]):
|
||||
?>
|
||||
<div class="flex gap-5 mb-8">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-bold"><?= $i + 1 ?></div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-white font-semibold text-lg mb-2"><?= e($title) ?></h3>
|
||||
<?php code_block($cmd) ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
|
||||
<!-- What gets installed -->
|
||||
<?php
|
||||
$software = [
|
||||
['Node.js 22 LTS', 'Application runtime'],
|
||||
['MariaDB', 'Panel database'],
|
||||
['Nginx', 'Web server & reverse proxy'],
|
||||
['PHP 8.3 + FPM', 'PHP runtime with common extensions'],
|
||||
['Postfix + Dovecot', 'SMTP & IMAP mail server'],
|
||||
['vsftpd', 'FTP server'],
|
||||
['BIND9', 'DNS server'],
|
||||
['Certbot', "Let's Encrypt SSL automation"],
|
||||
['Fail2ban', 'Brute-force protection'],
|
||||
['UFW / firewalld', 'Firewall (auto-configured)'],
|
||||
];
|
||||
?>
|
||||
<div class="mb-14">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">What Gets Installed</h2>
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b border-gray-800">
|
||||
<th class="text-left text-gray-500 font-medium px-5 py-3">Package</th>
|
||||
<th class="text-left text-gray-500 font-medium px-5 py-3">Purpose</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($software as $i => [$pkg, $desc]): ?>
|
||||
<tr class="border-b border-gray-800 last:border-0 <?= $i % 2 !== 0 ? 'bg-gray-900/30' : '' ?>">
|
||||
<td class="px-5 py-3 text-white font-mono text-xs"><?= e($pkg) ?></td>
|
||||
<td class="px-5 py-3 text-gray-400"><?= e($desc) ?></td>
|
||||
</tr>
|
||||
<?php endforeach ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ports -->
|
||||
<?php
|
||||
$ports = [
|
||||
['2087', 'NixServer', 'Admin panel (NixServer)'],
|
||||
['2083', 'NixClient', 'User panel (NixClient)'],
|
||||
['80', 'HTTP', 'Web traffic + ACME challenges'],
|
||||
['443', 'HTTPS', 'SSL web traffic'],
|
||||
['21', 'FTP', 'FTP control'],
|
||||
['25', 'SMTP', 'Inbound mail'],
|
||||
['587', 'Submission', 'Outbound mail (authenticated)'],
|
||||
['993', 'IMAPS', 'Secure IMAP'],
|
||||
['53', 'DNS', 'DNS resolution (TCP + UDP)'],
|
||||
];
|
||||
?>
|
||||
<div class="mb-14">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Firewall Ports</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">Auto-configured via UFW (Debian/Ubuntu) or firewalld (RHEL/AlmaLinux/Rocky).</p>
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b border-gray-800">
|
||||
<th class="text-left text-gray-500 font-medium px-5 py-3">Port</th>
|
||||
<th class="text-left text-gray-500 font-medium px-5 py-3">Name</th>
|
||||
<th class="text-left text-gray-500 font-medium px-5 py-3">Description</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($ports as $i => [$port, $name, $desc]): ?>
|
||||
<tr class="border-b border-gray-800 last:border-0 <?= $i % 2 !== 0 ? 'bg-gray-900/30' : '' ?>">
|
||||
<td class="px-5 py-3 font-mono text-brand-400 text-xs"><?= e($port) ?></td>
|
||||
<td class="px-5 py-3 text-white"><?= e($name) ?></td>
|
||||
<td class="px-5 py-3 text-gray-400"><?= e($desc) ?></td>
|
||||
</tr>
|
||||
<?php endforeach ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updating -->
|
||||
<div class="mb-14">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Updating NixPanel</h2>
|
||||
<?php code_block('sudo bash /opt/nixpanel/scripts/update.sh') ?>
|
||||
</div>
|
||||
|
||||
<!-- Service management -->
|
||||
<div class="mb-14">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Managing the Service</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<?php
|
||||
$cmds = [
|
||||
'Check status' => 'systemctl status nixpanel',
|
||||
'View live logs'=> 'journalctl -u nixpanel -f',
|
||||
'Restart' => 'systemctl restart nixpanel',
|
||||
'Stop' => 'systemctl stop nixpanel',
|
||||
];
|
||||
foreach ($cmds as $label => $cmd):
|
||||
?>
|
||||
<div class="card py-3">
|
||||
<p class="text-xs text-gray-500 mb-1"><?= e($label) ?></p>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<code class="text-sm text-gray-300 font-mono"><?= e($cmd) ?></code>
|
||||
<button onclick="navigator.clipboard.writeText('<?= e($cmd) ?>')" class="text-xs px-2.5 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 flex-shrink-0 transition-colors">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Uninstalling</h2>
|
||||
<p class="text-gray-400 text-sm mb-1">Removes the service, nginx config, install directory, and database. Does not remove Nginx, PHP, or other system packages.</p>
|
||||
<?php code_block('sudo bash /opt/nixpanel/scripts/uninstall.sh') ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php
|
||||
function code_block(string $code): void {
|
||||
echo '<div class="relative mt-3 rounded-lg bg-gray-950 border border-gray-800 overflow-hidden">';
|
||||
echo '<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900/60">';
|
||||
echo '<span class="text-xs text-gray-500 font-mono">bash</span>';
|
||||
echo '<button onclick="navigator.clipboard.writeText(this.dataset.code)" data-code="' . htmlspecialchars($code, ENT_QUOTES) . '" class="text-xs px-2.5 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors">Copy</button>';
|
||||
echo '</div>';
|
||||
echo '<pre class="px-4 py-4 text-sm text-gray-300 font-mono overflow-x-auto whitespace-pre leading-relaxed">' . htmlspecialchars($code, ENT_QUOTES) . '</pre>';
|
||||
echo '</div>';
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,16 @@
|
||||
</main>
|
||||
<footer class="border-t border-gray-800 bg-gray-950 py-10 mt-16">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-500">
|
||||
<span>© <?= date('Y') ?> NixPanel. All rights reserved.</span>
|
||||
<div class="flex gap-6">
|
||||
<a href="/pricing" class="hover:text-gray-300 transition-colors">Pricing</a>
|
||||
<a href="/install" class="hover:text-gray-300 transition-colors">Install</a>
|
||||
<a href="https://docs.nixpanel.io" target="_blank" rel="noreferrer" class="hover:text-gray-300 transition-colors">Docs</a>
|
||||
<a href="/login" class="hover:text-gray-300 transition-colors">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-gray-950">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($pageTitle ?? 'NixPanel — Modern Web Hosting Control Panel') ?></title>
|
||||
<meta name="description" content="<?= e($pageDesc ?? 'NixPanel is a modern, open-source cPanel alternative. Manage your Linux server with NixServer and NixClient.') ?>">
|
||||
<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">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
900: '#1e3a5f',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
@layer base {
|
||||
body { @apply bg-gray-950 text-gray-100 font-sans antialiased; }
|
||||
.btn-primary { @apply inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-semibold text-sm transition-colors; }
|
||||
.btn-secondary { @apply inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-transparent hover:bg-gray-800 text-gray-300 border border-gray-700 font-semibold text-sm transition-colors; }
|
||||
.btn-outline { @apply inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-transparent hover:bg-brand-900/30 text-brand-400 border border-brand-500/50 font-semibold text-sm transition-colors; }
|
||||
.input { @apply w-full bg-gray-900 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-brand-500 transition-colors; }
|
||||
.card { @apply bg-gray-900/50 border border-gray-800 rounded-xl p-6; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div id="flash" class="fixed top-4 right-4 z-50 max-w-sm w-full">
|
||||
<div class="flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg
|
||||
<?= $flash['type'] === 'success' ? 'bg-emerald-900/60 border-emerald-500/30 text-emerald-300' : 'bg-red-900/60 border-red-500/30 text-red-300' ?>">
|
||||
<span class="text-sm flex-1"><?= e($flash['message']) ?></span>
|
||||
<button onclick="document.getElementById('flash').remove()" class="opacity-60 hover:opacity-100 text-lg leading-none">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>setTimeout(() => { const f = document.getElementById('flash'); if (f) f.remove(); }, 5000);</script>
|
||||
<?php endif ?>
|
||||
@@ -0,0 +1,25 @@
|
||||
<header class="border-b border-gray-800 bg-gray-950/80 backdrop-blur-sm sticky top-0 z-40">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold text-white">Nix<span class="text-brand-500">Panel</span></span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6 text-sm text-gray-400">
|
||||
<a href="/" class="hover:text-white transition-colors">Home</a>
|
||||
<a href="/pricing" class="hover:text-white transition-colors">Pricing</a>
|
||||
<a href="/install" class="hover:text-white transition-colors">Install</a>
|
||||
<a href="https://docs.nixpanel.io" target="_blank" rel="noreferrer" class="hover:text-white transition-colors">Docs</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<?php if (\NixPanel\Auth::isLoggedIn()): ?>
|
||||
<a href="/dashboard" class="text-sm text-gray-300 hover:text-white transition-colors">Dashboard</a>
|
||||
<a href="/logout" class="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</a>
|
||||
<?php else: ?>
|
||||
<a href="/login" class="text-sm text-gray-300 hover:text-white transition-colors">Sign in</a>
|
||||
<a href="/register" class="text-sm px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-semibold transition-colors">Get started</a>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1">
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php $pageTitle = 'Sign In — NixPanel'; ?>
|
||||
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Welcome back</h1>
|
||||
<p class="text-gray-400 mt-2">Sign in to your NixPanel account</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST" action="/login" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Email address</label>
|
||||
<input type="email" name="email" value="<?= old('email') ?>" required autofocus
|
||||
class="input" placeholder="[email protected]">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Password</label>
|
||||
<input type="password" name="password" required
|
||||
class="input" placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<a href="/forgot-password" class="text-sm text-brand-400 hover:underline">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full justify-center py-3">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 mt-6">
|
||||
Don't have an account? <a href="/register" class="text-brand-400 hover:underline">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php $pageTitle = 'Pricing — NixPanel'; ?>
|
||||
<?php
|
||||
$plans = [
|
||||
['key' => 'free', 'name' => 'Free', 'monthly' => 0, 'annual' => 0, 'highlighted' => false,
|
||||
'limits' => ['Domains' => '1', 'Accounts' => '1', 'Emails' => '5', 'Databases' => '2', 'Disk' => '5 GB'],
|
||||
'features' => ['Community support', "Let's Encrypt SSL"]],
|
||||
|
||||
['key' => 'solo', 'name' => 'Solo', 'monthly' => 9.99, 'annual' => 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']],
|
||||
|
||||
['key' => 'host', 'name' => 'Host', 'monthly' => 29.99, 'annual' => 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', 'NixServer reseller panel']],
|
||||
|
||||
['key' => 'agency', 'name' => 'Agency', 'monthly' => 79.99, 'annual' => 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', 'NixServer reseller panel', 'White-label ready']],
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-bold text-white mb-4">Simple, transparent pricing</h1>
|
||||
<p class="text-gray-400">Tied to your server IP. No per-user fees. Cancel anytime.</p>
|
||||
|
||||
<div class="inline-flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 mt-8">
|
||||
<button id="btn-monthly" onclick="setInterval('monthly')"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-brand-600 text-white">
|
||||
Monthly
|
||||
</button>
|
||||
<button id="btn-annual" onclick="setInterval('annual')"
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-400 hover:text-white">
|
||||
Annual
|
||||
<span class="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 class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<?php foreach ($plans as $plan): ?>
|
||||
<div class="flex flex-col rounded-xl border p-6 <?= $plan['highlighted'] ? 'bg-brand-900/20 border-brand-500/40' : 'bg-gray-900/50 border-gray-800' ?>">
|
||||
<?php if ($plan['highlighted']): ?>
|
||||
<div class="text-xs font-semibold text-brand-400 bg-brand-500/10 border border-brand-500/20 rounded-full px-3 py-1 w-fit mb-4">Most popular</div>
|
||||
<?php endif ?>
|
||||
|
||||
<h3 class="text-white font-bold text-xl mb-1"><?= e($plan['name']) ?></h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<span class="price-monthly <?= $plan['monthly'] === 0 ? '' : 'hidden' ?>" data-plan="<?= $plan['key'] ?>">
|
||||
<span class="text-4xl font-extrabold text-white">$0</span>
|
||||
<span class="text-gray-400 text-sm">/mo</span>
|
||||
</span>
|
||||
<?php if ($plan['monthly'] > 0): ?>
|
||||
<span class="price-monthly" data-plan="<?= $plan['key'] ?>">
|
||||
<span class="text-4xl font-extrabold text-white">$<?= number_format($plan['monthly'], 2) ?></span>
|
||||
<span class="text-gray-400 text-sm">/mo</span>
|
||||
</span>
|
||||
<span class="price-annual hidden" data-plan="<?= $plan['key'] ?>">
|
||||
<span class="text-4xl font-extrabold text-white">$<?= number_format($plan['annual'] / 12, 2) ?></span>
|
||||
<span class="text-gray-400 text-sm">/mo</span>
|
||||
<div class="text-xs text-gray-500 mt-0.5">$<?= number_format($plan['annual'], 2) ?>/yr</div>
|
||||
</span>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-1.5 mb-5 text-sm text-gray-400">
|
||||
<?php foreach ($plan['limits'] as $label => $value): ?>
|
||||
<li class="flex justify-between">
|
||||
<span><?= e($label) ?></span>
|
||||
<span class="text-white font-medium"><?= e($value) ?></span>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
</ul>
|
||||
|
||||
<ul class="space-y-2 mb-6 text-sm text-gray-400 flex-1">
|
||||
<?php foreach ($plan['features'] as $feat): ?>
|
||||
<li class="flex items-center gap-2"><span class="text-emerald-500 flex-shrink-0">✓</span><?= e($feat) ?></li>
|
||||
<?php endforeach ?>
|
||||
</ul>
|
||||
|
||||
<?php if ($plan['key'] === 'free'): ?>
|
||||
<a href="/register" class="<?= $plan['highlighted'] ? 'btn-primary' : 'btn-secondary' ?> w-full justify-center">Get started free</a>
|
||||
<?php else: ?>
|
||||
<?php if (\NixPanel\Auth::isLoggedIn()): ?>
|
||||
<form method="POST" action="/checkout">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="plan_key" value="<?= e($plan['key']) ?>">
|
||||
<input type="hidden" name="interval" id="interval-<?= $plan['key'] ?>" value="monthly">
|
||||
<div class="mb-3">
|
||||
<input type="text" name="server_ip" placeholder="Your server IP (e.g. 1.2.3.4)"
|
||||
class="input text-xs" 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>
|
||||
</div>
|
||||
<button type="submit" class="<?= $plan['highlighted'] ? 'btn-primary' : 'btn-secondary' ?> w-full justify-center">
|
||||
Get started
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<a href="/register" class="<?= $plan['highlighted'] ? 'btn-primary' : 'btn-secondary' ?> w-full justify-center">Get started</a>
|
||||
<?php endif ?>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 mt-10">
|
||||
All plans include 1 server IP. Need multiple servers?
|
||||
<a href="mailto:[email protected]" class="text-brand-400 hover:underline">Contact us.</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setInterval(val) {
|
||||
document.querySelectorAll('.price-monthly').forEach(el => el.classList.toggle('hidden', val !== 'monthly'));
|
||||
document.querySelectorAll('.price-annual').forEach(el => el.classList.toggle('hidden', val !== 'annual'));
|
||||
document.querySelectorAll('[id^="interval-"]').forEach(el => el.value = val);
|
||||
document.getElementById('btn-monthly').className = 'px-4 py-2 rounded-md text-sm font-medium transition-colors ' + (val === 'monthly' ? 'bg-brand-600 text-white' : 'text-gray-400 hover:text-white');
|
||||
document.getElementById('btn-annual').className = 'px-4 py-2 rounded-md text-sm font-medium transition-colors ' + (val === 'annual' ? 'bg-brand-600 text-white' : 'text-gray-400 hover:text-white');
|
||||
}
|
||||
// Fix: hide the extra free $0 span since we output it twice to avoid if/else issues
|
||||
document.querySelectorAll('.price-monthly[data-plan="free"]').forEach((el, i) => { if (i > 0) el.remove(); });
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php $pageTitle = 'Create Account — NixPanel'; ?>
|
||||
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Create your account</h1>
|
||||
<p class="text-gray-400 mt-2">Get started with NixPanel for free</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST" action="/register" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Full name</label>
|
||||
<input type="text" name="full_name" value="<?= old('full_name') ?>" required
|
||||
class="input" placeholder="Jane Smith">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Email address</label>
|
||||
<input type="email" name="email" value="<?= old('email') ?>" required
|
||||
class="input" placeholder="[email protected]">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Password</label>
|
||||
<input type="password" name="password" required minlength="8"
|
||||
class="input" placeholder="Min 8 characters">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full justify-center py-3">Create account</button>
|
||||
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
By creating an account you agree to our
|
||||
<a href="/terms" class="text-brand-400 hover:underline">Terms of Service</a>.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 mt-6">
|
||||
Already have an account? <a href="/login" class="text-brand-400 hover:underline">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php $pageTitle = 'Set New Password — NixPanel'; ?>
|
||||
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Set new password</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST" action="/reset-password" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="token" value="<?= e($token) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">New password</label>
|
||||
<input type="password" name="password" required minlength="8"
|
||||
class="input" placeholder="Min 8 characters" autofocus>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Confirm password</label>
|
||||
<input type="password" name="password_confirm" required minlength="8"
|
||||
class="input" placeholder="Repeat password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full justify-center py-3">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php $pageTitle = 'Verify Email — NixPanel'; ?>
|
||||
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md text-center">
|
||||
<?php if ($verified): ?>
|
||||
<div class="text-5xl mb-6">✅</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-3">Email verified!</h1>
|
||||
<p class="text-gray-400 mb-8">Your account is active. You can now sign in.</p>
|
||||
<a href="/login" class="btn-primary px-8 py-3">Sign in</a>
|
||||
<?php else: ?>
|
||||
<div class="text-5xl mb-6">❌</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-3">Invalid link</h1>
|
||||
<p class="text-gray-400 mb-8">This verification link is invalid or has already been used.</p>
|
||||
<a href="/register" class="btn-secondary px-8 py-3">Create a new account</a>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,3 +0,0 @@
|
||||
# Vite exposes vars prefixed with VITE_ to the browser
|
||||
VITE_API_URL=https://nixpanel.io/api/v1
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -1,16 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100 font-sans antialiased;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,181 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
@@ -1,27 +0,0 @@
|
||||
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' }
|
||||
)
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
/** @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: [],
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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