diff --git a/api/.env.example b/.env.example similarity index 55% rename from api/.env.example rename to .env.example index 9fb4eb1..95620ed 100644 --- a/api/.env.example +++ b/.env.example @@ -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=postmaster@nixpanel.io MAIL_PASSWORD=your_smtp_password MAIL_FROM=noreply@nixpanel.io +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 diff --git a/.gitignore b/.gitignore index ec4d0f6..3011ab1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -node_modules/ -dist/ +vendor/ .env *.local .DS_Store diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..415e176 --- /dev/null +++ b/.htaccess @@ -0,0 +1,2 @@ +RewriteEngine On +RewriteRule ^(.*)$ public/$1 [L] diff --git a/api/.gitignore b/api/.gitignore deleted file mode 100644 index aa0926a..0000000 --- a/api/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -.env -*.log diff --git a/api/package.json b/api/package.json deleted file mode 100644 index 8f041bf..0000000 --- a/api/package.json +++ /dev/null @@ -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" - } -} diff --git a/api/src/config.ts b/api/src/config.ts deleted file mode 100644 index 7721dd4..0000000 --- a/api/src/config.ts +++ /dev/null @@ -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 || 'noreply@nixpanel.io', - }, - - appUrl: process.env.APP_URL || 'https://nixpanel.io', - - cors: { - origin: process.env.CORS_ORIGIN || 'https://nixpanel.io', - }, -}; diff --git a/api/src/db/index.ts b/api/src/db/index.ts deleted file mode 100644 index b8e76a6..0000000 --- a/api/src/db/index.ts +++ /dev/null @@ -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( - sql: string, - params?: unknown[] -): Promise { - const [rows] = await getPool().execute(sql, params); - return rows as T; -} - -export async function testConnection(): Promise { - const conn = await getPool().getConnection(); - await conn.ping(); - conn.release(); -} diff --git a/api/src/db/migrate.ts b/api/src/db/migrate.ts deleted file mode 100644 index 864dba9..0000000 --- a/api/src/db/migrate.ts +++ /dev/null @@ -1,33 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { getPool, testConnection } from './index'; - -async function migrate(): Promise { - 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); -}); diff --git a/api/src/index.ts b/api/src/index.ts deleted file mode 100644 index 7a7e0ff..0000000 --- a/api/src/index.ts +++ /dev/null @@ -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 { - 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); -}); diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts deleted file mode 100644 index 2de3d39..0000000 --- a/api/src/middleware/auth.ts +++ /dev/null @@ -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 { - 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' }); - } -} diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts deleted file mode 100644 index c79eb4d..0000000 --- a/api/src/routes/auth.ts +++ /dev/null @@ -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 { - // 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.' }); - }); -} diff --git a/api/src/routes/billing.ts b/api/src/routes/billing.ts deleted file mode 100644 index b5dbece..0000000 --- a/api/src/routes/billing.ts +++ /dev/null @@ -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 = { - 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 { - // 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( - "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( - "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( - '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( - '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 }); - }); -} diff --git a/api/src/routes/license.ts b/api/src/routes/license.ts deleted file mode 100644 index c1fd7a7..0000000 --- a/api/src/routes/license.ts +++ /dev/null @@ -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 { - app.addHook('preHandler', requireCustomerAuth); - - // GET /license — current license details - app.get('/', async (request, reply) => { - const subs = await query( - "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( - "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( - "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( - `SELECT * FROM ip_change_requests WHERE customer_id = ? ORDER BY requested_at DESC`, - [request.customer!.id] - ); - return reply.send(history); - }); -} diff --git a/api/src/services/authService.ts b/api/src/services/authService.ts deleted file mode 100644 index e41a0e3..0000000 --- a/api/src/services/authService.ts +++ /dev/null @@ -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 { - const rows = await query('SELECT * FROM customers WHERE email = ?', [email]); - return rows[0] || null; -} - -export async function getCustomerById(id: string): Promise { - const rows = await query( - '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 { - const rows = await query( - '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 { - 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 { - 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 { - const rows = await query( - '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; -} diff --git a/api/src/services/licensingService.ts b/api/src/services/licensingService.ts deleted file mode 100644 index 79095b9..0000000 --- a/api/src/services/licensingService.ts +++ /dev/null @@ -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( - method: string, - path: string, - body?: unknown -): Promise { - 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 { - return licensingRequest('GET', `/api/v1/licenses/${licenseId}`); -} - -export async function revokeLicense(licenseId: string): Promise { - await licensingRequest('POST', `/api/v1/licenses/${licenseId}/revoke`); -} - -export async function updateServerIp(licenseId: string, serverIp: string): Promise { - await licensingRequest('POST', `/api/v1/licenses/${licenseId}/rebind`, { serverIp }); -} - -export async function getPlans(): Promise { - return licensingRequest('GET', '/api/v1/admin/plans'); -} diff --git a/api/src/services/mailService.ts b/api/src/services/mailService.ts deleted file mode 100644 index cc3ac75..0000000 --- a/api/src/services/mailService.ts +++ /dev/null @@ -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 { - const url = `${config.appUrl}/verify-email?token=${token}`; - await transporter.sendMail({ - from: config.mail.from, - to: email, - subject: 'Verify your NixPanel account', - html: ` -

Welcome to NixPanel

-

Click the link below to verify your email address:

- - Verify Email - -

This link expires in 24 hours.

- `, - }); -} - -export async function sendPasswordResetEmail(email: string, token: string): Promise { - const url = `${config.appUrl}/reset-password?token=${token}`; - await transporter.sendMail({ - from: config.mail.from, - to: email, - subject: 'Reset your NixPanel password', - html: ` -

Password Reset

-

Click the link below to reset your password. This link expires in 1 hour.

- - Reset Password - -

If you didn't request this, you can safely ignore this email.

- `, - }); -} - -export async function sendLicenseConfirmationEmail(email: string, data: { - plan: string; - serverIp: string; - interval: string; -}): Promise { - await transporter.sendMail({ - from: config.mail.from, - to: email, - subject: `Your NixPanel ${data.plan} license is active`, - html: ` -

License Activated

-

Your ${data.plan} plan (${data.interval}) is now active for server IP ${data.serverIp}.

-

Log in to your dashboard to manage your license:

- - Go to Dashboard - - `, - }); -} diff --git a/api/src/services/stripeService.ts b/api/src/services/stripeService.ts deleted file mode 100644 index cf4721c..0000000 --- a/api/src/services/stripeService.ts +++ /dev/null @@ -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 = { - 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 { - 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 { - 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 { - const session = await stripe.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: returnUrl, - }); - return session.url; -} - -export async function cancelSubscription(stripeSubscriptionId: string): Promise { - 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 { - 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 { - return stripe.webhooks.constructEvent(payload, signature, config.stripe.webhookSecret); -} - -export { stripe }; diff --git a/api/tsconfig.json b/api/tsconfig.json deleted file mode 100644 index 17af22d..0000000 --- a/api/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..99682a5 --- /dev/null +++ b/composer.json @@ -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 + } +} diff --git a/api/src/db/migrations/001_customers.sql b/migrations/001_schema.sql similarity index 89% rename from api/src/db/migrations/001_customers.sql rename to migrations/001_schema.sql index a8270d5..3a69d9b 100644 --- a/api/src/db/migrations/001_customers.sql +++ b/migrations/001_schema.sql @@ -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; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..63aed5b --- /dev/null +++ b/public/.htaccess @@ -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] diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..4146f02 --- /dev/null +++ b/public/index.php @@ -0,0 +1,412 @@ +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; + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..ea66f23 --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,134 @@ +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; + } +} diff --git a/src/DB.php b/src/DB.php new file mode 100644 index 0000000..d2813ae --- /dev/null +++ b/src/DB.php @@ -0,0 +1,67 @@ + 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(); + } +} diff --git a/src/Mailer.php b/src/Mailer.php new file mode 100644 index 0000000..8cd8e8d --- /dev/null +++ b/src/Mailer.php @@ -0,0 +1,77 @@ +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', " +

Thanks for signing up! Click the button below to verify your email address.

+

+ + Verify Email + +

+

Or copy this link:
{$url}

+

This link expires in 24 hours.

+ "); + $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', " +

We received a request to reset your password. Click the button below.

+

+ + Reset Password + +

+

This link expires in 1 hour. If you didn't request a reset, ignore this email.

+ "); + $mail->send(); + } + + private static function template(string $title, string $body): string + { + return " + + +
+

+ NixPanel +

+

{$title}

+ {$body} +
+

© " . date('Y') . " NixPanel. All rights reserved.

+ "; + } +} diff --git a/src/StripeClient.php b/src/StripeClient.php new file mode 100644 index 0000000..9bf4b19 --- /dev/null +++ b/src/StripeClient.php @@ -0,0 +1,115 @@ + ['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, + ]); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..e802fd6 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,88 @@ + $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 ''; +} + +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'; +} diff --git a/views/404.php b/views/404.php new file mode 100644 index 0000000..3278c80 --- /dev/null +++ b/views/404.php @@ -0,0 +1,10 @@ + + +
+
+

404

+

Page not found

+

The page you're looking for doesn't exist.

+ Go home +
+
diff --git a/views/dashboard.php b/views/dashboard.php new file mode 100644 index 0000000..a0a0c1f --- /dev/null +++ b/views/dashboard.php @@ -0,0 +1,121 @@ + + 'Free', 'solo' => 'Solo', 'host' => 'Host', 'agency' => 'Agency']; +?> + +
+

Dashboard

+ + +
+ 🎉 Subscription activated! Your license has been provisioned. +
+ + + +
+

Account

+
+
+

Name

+

+
+
+

Email

+

+
+
+
+ + + +
+
+

Subscription

+ + + +
+ +
+
+

Plan

+

+
+
+

Billing

+

+
+
+

Renews

+

+
+
+ + +

+ ⚠ Cancels on +

+ + +
+
+ + +
+ +
+ + +
+ +
+
+ +
+

No active subscription.

+ View plans +
+ + + + +
+

License

+
+
+

License ID

+

+
+
+

Server IP

+

+
+
+

Status

+

+
+
+

Last verified

+

+
+
+ +
+

Change server IP

+
+ + + +
+
+
+ +
diff --git a/views/forgot-password.php b/views/forgot-password.php new file mode 100644 index 0000000..9a275b9 --- /dev/null +++ b/views/forgot-password.php @@ -0,0 +1,26 @@ + + +
+
+
+

Reset your password

+

We'll send a reset link to your email

+
+ +
+
+ +
+ + +
+ +
+
+ +

+ Back to sign in +

+
+
diff --git a/views/home.php b/views/home.php new file mode 100644 index 0000000..cd961d7 --- /dev/null +++ b/views/home.php @@ -0,0 +1,51 @@ + + '⚡', '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.'], +]; +?> + +
+
+ Now in public beta +
+

+ The hosting control panel
+ you actually want to use. +

+

+ NixPanel is a modern, open-source alternative to cPanel. Manage your Linux server, + host sites, and control everything from a clean, fast dashboard. +

+ +
+ +
+

Everything you need to run a server

+
+ +
+
+

+

+
+ +
+
+ +
+
+

Ready to take control?

+

Start free. Upgrade when you need more.

+ Create your account +
+
diff --git a/views/install.php b/views/install.php new file mode 100644 index 0000000..9a2f0b4 --- /dev/null +++ b/views/install.php @@ -0,0 +1,232 @@ + + +
+ +
+
+ Installation Guide +
+

Install NixPanel

+

+ Get your NixServer admin panel and NixClient user panel running on any supported Linux server in under five minutes. +

+
+ + + + +
+
+

Supported OS

+
    + +
  • + ✓ +
  • + +
+
+
+

Minimum Requirements

+
    + +
  • + ✓ +
  • + +
+
+
+ + +
+

Quick Install

+

Run this single command as root on a fresh server.

+ +
+ + + +
+

With custom credentials:

+ +
+ + +
+

After Installation

+

+ Credentials are printed on screen and saved to + /root/.nixpanel_credentials. +

+
+
+

NixServer — Admin Panel

+

http://<your-ip>:2087

+
+
+

NixClient — User Panel

+

http://<your-ip>:2083

+
+
+
+ + +
+

Manual Installation

+

Prefer full control? Install system dependencies first (Node.js 22, MariaDB, Nginx, etc.), then follow these steps.

+ + [$title, $cmd]): + ?> +
+
+
+

+ +
+
+ +
+ + + +
+

What Gets Installed

+
+ + + + + + + [$pkg, $desc]): ?> + + + + + + +
PackagePurpose
+
+
+ + + +
+

Firewall Ports

+

Auto-configured via UFW (Debian/Ubuntu) or firewalld (RHEL/AlmaLinux/Rocky).

+
+ + + + + + + + [$port, $name, $desc]): ?> + + + + + + + +
PortNameDescription
+
+
+ + +
+

Updating NixPanel

+ +
+ + +
+

Managing the Service

+
+ 'systemctl status nixpanel', + 'View live logs'=> 'journalctl -u nixpanel -f', + 'Restart' => 'systemctl restart nixpanel', + 'Stop' => 'systemctl stop nixpanel', + ]; + foreach ($cmds as $label => $cmd): + ?> +
+

+
+ + +
+
+ +
+
+ + +
+

Uninstalling

+

Removes the service, nginx config, install directory, and database. Does not remove Nginx, PHP, or other system packages.

+ +
+ +
+ +'; + echo '
'; + echo 'bash'; + echo ''; + echo '
'; + echo '
' . htmlspecialchars($code, ENT_QUOTES) . '
'; + echo ''; +} +?> diff --git a/views/layout/footer.php b/views/layout/footer.php new file mode 100644 index 0000000..7bc9956 --- /dev/null +++ b/views/layout/footer.php @@ -0,0 +1,16 @@ + + + + diff --git a/views/layout/head.php b/views/layout/head.php new file mode 100644 index 0000000..ef2df05 --- /dev/null +++ b/views/layout/head.php @@ -0,0 +1,56 @@ + + + + + + <?= e($pageTitle ?? 'NixPanel — Modern Web Hosting Control Panel') ?> + + + + + + + + + + + +
+
+ + +
+
+ + diff --git a/views/layout/header.php b/views/layout/header.php new file mode 100644 index 0000000..38b8603 --- /dev/null +++ b/views/layout/header.php @@ -0,0 +1,25 @@ +
+ +
+
diff --git a/views/login.php b/views/login.php new file mode 100644 index 0000000..16154c8 --- /dev/null +++ b/views/login.php @@ -0,0 +1,38 @@ + + +
+
+
+

Welcome back

+

Sign in to your NixPanel account

+
+ +
+
+ + +
+ + +
+ +
+ + +
+ + + + +
+
+ +

+ Don't have an account? Create one +

+
+
diff --git a/views/pricing.php b/views/pricing.php new file mode 100644 index 0000000..627b8b0 --- /dev/null +++ b/views/pricing.php @@ -0,0 +1,123 @@ + + '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']], +]; +?> + +
+
+

Simple, transparent pricing

+

Tied to your server IP. No per-user fees. Cancel anytime.

+ +
+ + +
+
+ +
+ +
+ +
Most popular
+ + +

+ +
+ + $0 + /mo + + 0): ?> + + $ + /mo + + + +
+ +
    + $value): ?> +
  • + + +
  • + +
+ +
    + +
  • ✓
  • + +
+ + + Get started free + + +
+ + + +
+ +
+ +
+ + Get started + + +
+ +
+ +

+ All plans include 1 server IP. Need multiple servers? + Contact us. +

+
+ + diff --git a/views/register.php b/views/register.php new file mode 100644 index 0000000..6d8959c --- /dev/null +++ b/views/register.php @@ -0,0 +1,45 @@ + + +
+
+
+

Create your account

+

Get started with NixPanel for free

+
+ +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +

+ By creating an account you agree to our + Terms of Service. +

+
+
+ +

+ Already have an account? Sign in +

+
+
diff --git a/views/reset-password.php b/views/reset-password.php new file mode 100644 index 0000000..37a3926 --- /dev/null +++ b/views/reset-password.php @@ -0,0 +1,30 @@ + + +
+
+
+

Set new password

+
+ +
+
+ + + +
+ + +
+ +
+ + +
+ + +
+
+
+
diff --git a/views/verify-email.php b/views/verify-email.php new file mode 100644 index 0000000..3c5d776 --- /dev/null +++ b/views/verify-email.php @@ -0,0 +1,17 @@ + + +
+
+ +
✅
+

Email verified!

+

Your account is active. You can now sign in.

+ Sign in + +
❌
+

Invalid link

+

This verification link is invalid or has already been used.

+ Create a new account + +
+
diff --git a/web/.env.example b/web/.env.example deleted file mode 100644 index 8be46b3..0000000 --- a/web/.env.example +++ /dev/null @@ -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_... diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index aa0926a..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -.env -*.log diff --git a/web/index.html b/web/index.html deleted file mode 100644 index fe2adbd..0000000 --- a/web/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - NixPanel — Hosting Control Panel - - -
- - - diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 7678947..0000000 --- a/web/package.json +++ /dev/null @@ -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" - } -} diff --git a/web/postcss.config.js b/web/postcss.config.js deleted file mode 100644 index 2aa7205..0000000 --- a/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index 3af02bd..0000000 --- a/web/src/App.tsx +++ /dev/null @@ -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} : ; -} - -export default function App() { - return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - - - ); -} diff --git a/web/src/components/PricingCard.tsx b/web/src/components/PricingCard.tsx deleted file mode 100644 index 0e56a82..0000000 --- a/web/src/components/PricingCard.tsx +++ /dev/null @@ -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 ( -
- {highlighted && ( - - Most Popular - - )} - -
-

{name}

-
- {isFree ? ( - Free - ) : ( - <> - ${perMonth} - /mo - - )} -
- {!isFree && interval === 'annual' && ( -

- Billed ${price.toFixed(2)}/yr — 2 months free -

- )} -
- -
    -
  • {limits.domains} domains
  • -
  • {limits.accounts} accounts
  • -
  • {limits.emails} email accounts
  • -
  • {limits.databases} databases
  • -
  • {limits.disk} disk space
  • - {features.map(f => ( -
  • {f}
  • - ))} -
- - -
- ); -} - -function Check() { - return ( - - - - ); -} diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx deleted file mode 100644 index a405754..0000000 --- a/web/src/components/layout/Footer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Link } from 'react-router-dom'; - -export default function Footer() { - return ( -
-
-
- - © {new Date().getFullYear()} NixPanel. All rights reserved. - -
- Pricing - Install - Docs - Login -
-
-
-
- ); -} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx deleted file mode 100644 index 98d5f55..0000000 --- a/web/src/components/layout/Header.tsx +++ /dev/null @@ -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 ( -
-
- - - NixPanel - - - - - -
- {customer ? ( - <> - - Dashboard - - - - ) : ( - <> - - Sign in - - - Get started - - - )} -
-
-
- ); -} diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx deleted file mode 100644 index 0c2ea40..0000000 --- a/web/src/components/layout/Layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Outlet } from 'react-router-dom'; -import Header from './Header'; -import Footer from './Footer'; - -export default function Layout() { - return ( -
-
-
- -
-
-
- ); -} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx deleted file mode 100644 index 4883a71..0000000 --- a/web/src/components/ui/Button.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { type ButtonHTMLAttributes } from 'react'; - -interface ButtonProps extends ButtonHTMLAttributes { - 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 ( - - ); -} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx deleted file mode 100644 index a07ad5f..0000000 --- a/web/src/components/ui/Input.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type InputHTMLAttributes, forwardRef } from 'react'; - -interface InputProps extends InputHTMLAttributes { - label?: string; - error?: string; - hint?: string; -} - -const Input = forwardRef( - ({ label, error, hint, className = '', id, ...props }, ref) => { - const inputId = id || label?.toLowerCase().replace(/\s+/g, '-'); - return ( -
- {label && ( - - )} - - {error &&

{error}

} - {hint && !error &&

{hint}

} -
- ); - } -); - -Input.displayName = 'Input'; -export default Input; diff --git a/web/src/index.css b/web/src/index.css deleted file mode 100644 index dcc6bae..0000000 --- a/web/src/index.css +++ /dev/null @@ -1,9 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - body { - @apply bg-gray-950 text-gray-100 font-sans antialiased; - } -} diff --git a/web/src/main.tsx b/web/src/main.tsx deleted file mode 100644 index 2339d59..0000000 --- a/web/src/main.tsx +++ /dev/null @@ -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( - - - -); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx deleted file mode 100644 index 807e39f..0000000 --- a/web/src/pages/Dashboard.tsx +++ /dev/null @@ -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 = { - free: 'Free', solo: 'Solo', host: 'Host', agency: 'Agency', -}; - -export default function Dashboard() { - const customer = useAuthStore(s => s.customer); - const [license, setLicense] = useState(null); - const [sub, setSub] = useState(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 ( -
- Loading... -
- ); - } - - return ( -
-

Dashboard

- - {/* Account info */} -
-

Account

-
-
-

Name

-

{customer?.full_name}

-
-
-

Email

-

{customer?.email}

-
-
-
- - {/* Subscription */} - {sub ? ( -
-
-

Subscription

- - {sub.status} - -
-
-
-

Plan

-

{PLAN_LABELS[sub.plan_key]}

-
-
-

Billing

-

{sub.billing_interval}

-
-
-

Renews

-

{new Date(sub.current_period_end).toLocaleDateString()}

-
-
- {sub.cancel_at_period_end && ( -

- Cancels on {new Date(sub.current_period_end).toLocaleDateString()} -

- )} -
- - {!sub.cancel_at_period_end && ( - - )} -
-
- ) : ( -
-

No active subscription.

- -
- )} - - {/* License */} - {license && ( -
-

License

-
-
-

License ID

-

{license.id}

-
-
-

Server IP

-

{license.server_ip || '—'}

-
-
-

Status

-

{license.status}

-
-
-

Last verified

-

{license.last_verified - ? new Date(license.last_verified).toLocaleString() - : 'Never'}

-
-
- - {/* IP change form */} -
-

Change server IP

-
-
- 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 &&

{ipMsg}

} -
- -
-
-
- )} -
- ); -} diff --git a/web/src/pages/ForgotPassword.tsx b/web/src/pages/ForgotPassword.tsx deleted file mode 100644 index e230c7b..0000000 --- a/web/src/pages/ForgotPassword.tsx +++ /dev/null @@ -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 ( -
-
-
📬
-

Check your inbox

-

- If an account exists for {email}, - a reset link has been sent. -

-
-
- ); - - return ( -
-
-

Reset password

-

Enter your email and we'll send a reset link.

-
- setEmail(e.target.value)} - placeholder="you@example.com" - required - /> - -
-
-
- ); -} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx deleted file mode 100644 index 69b0a62..0000000 --- a/web/src/pages/Home.tsx +++ /dev/null @@ -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 ( -
- {/* Hero */} -
-
- Now in public beta -
-

- The hosting control panel
- you actually want to use. -

-

- NixPanel is a modern, open-source alternative to cPanel. Manage your Linux server, - host WordPress sites, and control everything from a clean, fast dashboard. -

-
- - - - - - - - - -
-
- - {/* Features */} -
-

- Everything you need to run a server -

-
- {features.map(f => ( -
-
{f.icon}
-

{f.title}

-

{f.body}

-
- ))} -
-
- - {/* CTA */} -
-
-

Ready to take control?

-

Start free. Upgrade when you need more.

- - - -
-
-
- ); -} diff --git a/web/src/pages/Install.tsx b/web/src/pages/Install.tsx deleted file mode 100644 index fd65fe4..0000000 --- a/web/src/pages/Install.tsx +++ /dev/null @@ -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 ( - - ); -} - -function CodeBlock({ code, lang = 'bash' }: { code: string; lang?: string }) { - return ( -
-
- {lang} - -
-
-        {code}
-      
-
- ); -} - -function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { - return ( -
-
- {n} -
-
-

{title}

- {children} -
-
- ); -} - -function Badge({ children, color = 'gray' }: { children: React.ReactNode; color?: string }) { - const colors: Record = { - 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 ( - - {children} - - ); -} - -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 ( -
- - {/* Page header */} -
-
- Installation Guide -
-

Install NixPanel

-

- Get your NixServer admin panel and NixClient user panel running on any supported Linux server in under five minutes. -

-
- - {/* Requirements */} -
-
-

Supported OS

-
    - {[ - 'Ubuntu 20.04 / 22.04 / 24.04', - 'Debian 11 / 12', - 'AlmaLinux 8 / 9', - 'Rocky Linux 8 / 9', - 'RHEL 8 / 9', - ].map(os => ( -
  • - ✓ {os} -
  • - ))} -
-
-
-

Minimum Requirements

-
    -
  • ✓ 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
  • -
-
-
- - {/* Quick install */} -
-

Quick Install

-

- Run this single command as root on a fresh server. The installer handles everything automatically. -

- -
- Auto-detects OS - Generates random secrets - Configures firewall - Creates systemd service -
-

- You can also pass custom credentials to avoid the interactive prompt: -

- -
- - {/* After install */} -
-

After Installation

-

- Once complete, the installer prints your credentials and saves them to{' '} - /root/.nixpanel_credentials. -

-
-
-

NixServer — Admin Panel

-

http://<your-ip>:2087

-
-
-

NixClient — User Panel

-

http://<your-ip>:2083

-
-
-
- - {/* Manual install */} -
-

Manual Installation

-

- Prefer full control? Follow the steps below. You'll need to install the system dependencies (Node.js 22, MariaDB, Nginx, etc.) yourself before starting. -

- -
-
-
- - - - - - -

- Copy the example file and fill in your database credentials, JWT secrets, and admin password. -

- -
- - - - - - - - - -
-
-
- - {/* What gets installed */} -
-

What Gets Installed

-
- - - - - - - - - {softwareItems.map((item, i) => ( - - - - - ))} - -
PackagePurpose
{item.name}{item.desc}
-
-
- - {/* Open ports */} -
-

Firewall Ports

-

- The installer automatically opens these ports via UFW (Debian/Ubuntu) or firewalld (RHEL/AlmaLinux/Rocky). -

-
- - - - - - - - - - {ports.map((p, i) => ( - - - - - - ))} - -
PortNameDescription
{p.port}{p.name}{p.desc}
-
-
- - {/* Updating */} -
-

Updating NixPanel

-

- Pull the latest code, rebuild, and restart — all in one command: -

- -
- - {/* Service management */} -
-

Managing the Service

-
- {[ - { 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 }) => ( -
-

{label}

-
- {cmd} - -
-
- ))} -
-
- - {/* Uninstall */} -
-

Uninstalling

-

- Removes the NixPanel service, nginx config, install directory, and database. Does not remove Nginx, PHP, or other system packages. -

- -
- -
- ); -} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx deleted file mode 100644 index 581c2e0..0000000 --- a/web/src/pages/Login.tsx +++ /dev/null @@ -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 ( -
-
-

Sign in

-

- Don't have an account?{' '} - Get started free -

- -
- {error && ( -
- {error} -
- )} - setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - /> - setPassword(e.target.value)} - placeholder="••••••••" - required - autoComplete="current-password" - /> -
- - Forgot password? - -
- -
-
-
- ); -} diff --git a/web/src/pages/Pricing.tsx b/web/src/pages/Pricing.tsx deleted file mode 100644 index 1097096..0000000 --- a/web/src/pages/Pricing.tsx +++ /dev/null @@ -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 ( -
-
-

Simple, transparent pricing

-

Tied to your server IP. No per-user fees. Cancel anytime.

- - {/* Toggle */} -
- - -
-
- -
- {plans.map(plan => ( - - ))} -
- -

- All plans include 1 server IP. Need multiple servers?{' '} - Contact us. -

-
- ); -} diff --git a/web/src/pages/Register.tsx b/web/src/pages/Register.tsx deleted file mode 100644 index e0adf72..0000000 --- a/web/src/pages/Register.tsx +++ /dev/null @@ -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 ( -
-
-
📬
-

Check your email

-

- We sent a verification link to {email}. - Click it to activate your account, then{' '} - sign in. -

-
-
- ); - } - - return ( -
-
-

Create your account

-

- Already have one?{' '} - Sign in -

- - {plan && ( -
- You'll be set up on the {plan} plan after verifying your email. -
- )} - -
- {error && ( -
- {error} -
- )} - setFullName(e.target.value)} - placeholder="Jane Smith" - required - autoComplete="name" - /> - setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - /> - setPassword(e.target.value)} - placeholder="Min. 8 characters" - required - minLength={8} - autoComplete="new-password" - /> - -

- By creating an account you agree to our Terms of Service and Privacy Policy. -

-
-
-
- ); -} diff --git a/web/src/pages/ResetPassword.tsx b/web/src/pages/ResetPassword.tsx deleted file mode 100644 index fe4637e..0000000 --- a/web/src/pages/ResetPassword.tsx +++ /dev/null @@ -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 ( -
-
-

Choose a new password

-

Must be at least 8 characters.

-
- {error && ( -
- {error} -
- )} - setPassword(e.target.value)} - placeholder="Min. 8 characters" - required - minLength={8} - /> - -
-
-
- ); -} diff --git a/web/src/pages/VerifyEmail.tsx b/web/src/pages/VerifyEmail.tsx deleted file mode 100644 index 76e84f4..0000000 --- a/web/src/pages/VerifyEmail.tsx +++ /dev/null @@ -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 ( -
-
- {status === 'loading' &&

Verifying...

} - {status === 'success' && ( - <> -
✅
-

Email verified

-

Your account is active. You can now sign in.

- Go to login - - )} - {status === 'error' && ( - <> -
❌
-

Invalid link

-

This link is expired or invalid. Try registering again.

- Back to register - - )} -
-
- ); -} diff --git a/web/src/services/api.ts b/web/src/services/api.ts deleted file mode 100644 index b98c0d5..0000000 --- a/web/src/services/api.ts +++ /dev/null @@ -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; diff --git a/web/src/store/auth.ts b/web/src/store/auth.ts deleted file mode 100644 index 6c6149c..0000000 --- a/web/src/store/auth.ts +++ /dev/null @@ -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()( - persist( - (set) => ({ - token: null, - customer: null, - setAuth: (token, customer) => set({ token, customer }), - logout: () => set({ token: null, customer: null }), - }), - { name: 'nixpanel-auth' } - ) -); diff --git a/web/tailwind.config.js b/web/tailwind.config.js deleted file mode 100644 index 2a8435a..0000000 --- a/web/tailwind.config.js +++ /dev/null @@ -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: [], -}; diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 4ffafe1..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -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" }] -} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json deleted file mode 100644 index 42872c5..0000000 --- a/web/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/web/vite.config.ts b/web/vite.config.ts deleted file mode 100644 index 444078c..0000000 --- a/web/vite.config.ts +++ /dev/null @@ -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, - }, - }, - }, -});