commit 72f5cc1060868e917bb5991ac328822735f305fa Author: NixReaper Date: Sun Apr 5 11:16:42 2026 -0500 feat: initial website commit + installation guide page - Full nixpanel-website monorepo: web (React/Vite) + api (Fastify) - Add /install page with one-liner, manual steps, requirements, software table, firewall ports, service management, and update/uninstall - Add Install link to header nav and footer - Add 'Install guide' CTA button to home page hero - Add 'outline' variant to Button component - Add .gitignore and .gitattributes Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f6a525d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec4d0f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.local +.DS_Store diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..9fb4eb1 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,44 @@ +# Server +NODE_ENV=production +PORT=4000 +HOST=0.0.0.0 + +# CORS +CORS_ORIGIN=https://nixpanel.io + +# Database (MariaDB / MySQL) +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=nixpanel_web +DB_PASSWORD=your_secure_password_here +DB_NAME=nixpanel_website + +# JWT — generate with: openssl rand -hex 64 +JWT_SECRET=changeme_use_openssl_rand_hex_64 +JWT_EXPIRES_IN=7d + +# Stripe +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Stripe Price IDs (create in Stripe Dashboard) +STRIPE_PRICE_SOLO_MONTHLY=price_... +STRIPE_PRICE_SOLO_ANNUAL=price_... +STRIPE_PRICE_HOST_MONTHLY=price_... +STRIPE_PRICE_HOST_ANNUAL=price_... +STRIPE_PRICE_AGENCY_MONTHLY=price_... +STRIPE_PRICE_AGENCY_ANNUAL=price_... + +# Internal link to the licensing server +LICENSING_SERVER_URL=http://localhost:3000 +LICENSING_SERVER_API_KEY=your_internal_api_key_here + +# Email (SMTP) +MAIL_HOST=smtp.mailgun.org +MAIL_PORT=587 +MAIL_USER=postmaster@nixpanel.io +MAIL_PASSWORD=your_smtp_password +MAIL_FROM=noreply@nixpanel.io + +# Public URL (used in emails and redirects) +APP_URL=https://nixpanel.io diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..8f041bf --- /dev/null +++ b/api/package.json @@ -0,0 +1,35 @@ +{ + "name": "nixpanel-website-api", + "version": "1.0.0", + "description": "NixPanel.io website API — customer auth, Stripe billing, license management", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "migrate": "ts-node src/db/migrate.ts" + }, + "dependencies": { + "fastify": "^4.28.1", + "@fastify/cors": "^9.0.1", + "@fastify/helmet": "^11.1.1", + "@fastify/rate-limit": "^9.1.0", + "mysql2": "^3.9.8", + "stripe": "^16.2.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "dotenv": "^16.4.5", + "uuid": "^9.0.1", + "nodemailer": "^6.9.14" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.6", + "@types/nodemailer": "^6.4.15", + "@types/node": "^20.14.9", + "@types/uuid": "^9.0.8", + "typescript": "^5.5.3", + "ts-node-dev": "^2.0.0", + "ts-node": "^10.9.2" + } +} diff --git a/api/src/config.ts b/api/src/config.ts new file mode 100644 index 0000000..7721dd4 --- /dev/null +++ b/api/src/config.ts @@ -0,0 +1,60 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +function require(key: string): string { + const val = process.env[key]; + if (!val) throw new Error(`Missing required environment variable: ${key}`); + return val; +} + +export const config = { + env: process.env.NODE_ENV || 'production', + port: parseInt(process.env.PORT || '4000', 10), + host: process.env.HOST || '0.0.0.0', + + db: { + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT || '3306', 10), + user: require('DB_USER'), + password: require('DB_PASSWORD'), + name: require('DB_NAME'), + }, + + jwt: { + secret: require('JWT_SECRET'), + expiresIn: process.env.JWT_EXPIRES_IN || '7d', + }, + + stripe: { + secretKey: require('STRIPE_SECRET_KEY'), + webhookSecret: require('STRIPE_WEBHOOK_SECRET'), + plans: { + // Stripe Price IDs — set after creating products in Stripe dashboard + solo_monthly: process.env.STRIPE_PRICE_SOLO_MONTHLY || '', + solo_annual: process.env.STRIPE_PRICE_SOLO_ANNUAL || '', + host_monthly: process.env.STRIPE_PRICE_HOST_MONTHLY || '', + host_annual: process.env.STRIPE_PRICE_HOST_ANNUAL || '', + agency_monthly: process.env.STRIPE_PRICE_AGENCY_MONTHLY || '', + agency_annual: process.env.STRIPE_PRICE_AGENCY_ANNUAL || '', + }, + }, + + licensingServer: { + url: require('LICENSING_SERVER_URL'), + apiKey: require('LICENSING_SERVER_API_KEY'), + }, + + mail: { + host: process.env.MAIL_HOST || 'smtp.mailgun.org', + port: parseInt(process.env.MAIL_PORT || '587', 10), + user: process.env.MAIL_USER || '', + password: process.env.MAIL_PASSWORD || '', + from: process.env.MAIL_FROM || '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 new file mode 100644 index 0000000..b8e76a6 --- /dev/null +++ b/api/src/db/index.ts @@ -0,0 +1,35 @@ +import mysql from 'mysql2/promise'; +import { config } from '../config'; + +let pool: mysql.Pool; + +export function getPool(): mysql.Pool { + if (!pool) { + pool = mysql.createPool({ + host: config.db.host, + port: config.db.port, + user: config.db.user, + password: config.db.password, + database: config.db.name, + waitForConnections: true, + connectionLimit: 20, + queueLimit: 0, + timezone: 'Z', + }); + } + return pool; +} + +export async function query( + 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 new file mode 100644 index 0000000..864dba9 --- /dev/null +++ b/api/src/db/migrate.ts @@ -0,0 +1,33 @@ +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/db/migrations/001_customers.sql b/api/src/db/migrations/001_customers.sql new file mode 100644 index 0000000..a8270d5 --- /dev/null +++ b/api/src/db/migrations/001_customers.sql @@ -0,0 +1,55 @@ +-- NixPanel Website — Customer Schema +-- MariaDB / MySQL compatible + +CREATE TABLE IF NOT EXISTS customers ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255) NOT NULL, + is_verified TINYINT(1) NOT NULL DEFAULT 0, + verify_token VARCHAR(128) NULL, + reset_token VARCHAR(128) NULL, + reset_token_expires DATETIME NULL, + stripe_customer_id VARCHAR(100) NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_email (email), + INDEX idx_stripe_customer (stripe_customer_id) +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + customer_id VARCHAR(36) NOT NULL, + license_id VARCHAR(36) NOT NULL, + stripe_subscription_id VARCHAR(100) NOT NULL UNIQUE, + stripe_price_id VARCHAR(100) NOT NULL, + plan_key ENUM('free','solo','host','agency') NOT NULL DEFAULT 'free', + billing_interval ENUM('monthly','annual') NOT NULL DEFAULT 'monthly', + status ENUM('active','past_due','canceled','incomplete','trialing') NOT NULL DEFAULT 'active', + server_ip VARCHAR(45) NOT NULL, + current_period_start DATETIME NOT NULL, + current_period_end DATETIME NOT NULL, + cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0, + canceled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, + INDEX idx_customer_id (customer_id), + INDEX idx_license_id (license_id), + INDEX idx_stripe_sub (stripe_subscription_id), + INDEX idx_server_ip (server_ip) +); + +CREATE TABLE IF NOT EXISTS ip_change_requests ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + customer_id VARCHAR(36) NOT NULL, + subscription_id VARCHAR(36) NOT NULL, + old_ip VARCHAR(45) NOT NULL, + new_ip VARCHAR(45) NOT NULL, + status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending', + requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME NULL, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + INDEX idx_customer_id (customer_id) +); diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..7a7e0ff --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,61 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import rateLimit from '@fastify/rate-limit'; +import { config } from './config'; +import { testConnection } from './db'; +import authRoutes from './routes/auth'; +import billingRoutes from './routes/billing'; +import licenseRoutes from './routes/license'; + +const app = Fastify({ + logger: { + level: config.env === 'production' ? 'warn' : 'info', + transport: config.env !== 'production' ? { target: 'pino-pretty' } : undefined, + }, + trustProxy: true, +}); + +// Add rawBody support for Stripe webhooks +app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { + (req as any).rawBody = body; + try { + done(null, JSON.parse(body.toString())); + } catch (err) { + done(err as Error, undefined); + } +}); + +async function bootstrap(): Promise { + 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 new file mode 100644 index 0000000..2de3d39 --- /dev/null +++ b/api/src/middleware/auth.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { verifyJwt } from '../services/authService'; +import { getCustomerById } from '../services/authService'; + +declare module 'fastify' { + interface FastifyRequest { + customer?: { id: string; email: string }; + } +} + +export async function requireCustomerAuth( + request: FastifyRequest, + reply: FastifyReply +): Promise { + 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 new file mode 100644 index 0000000..c79eb4d --- /dev/null +++ b/api/src/routes/auth.ts @@ -0,0 +1,108 @@ +import type { FastifyInstance } from 'fastify'; +import { requireCustomerAuth } from '../middleware/auth'; +import { + registerCustomer, + loginCustomer, + verifyEmail, + getCustomerById, + initiatePasswordReset, + resetPassword, +} from '../services/authService'; +import { + sendVerificationEmail, + sendPasswordResetEmail, +} from '../services/mailService'; + +export default async function authRoutes(app: FastifyInstance): Promise { + // 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 new file mode 100644 index 0000000..b5dbece --- /dev/null +++ b/api/src/routes/billing.ts @@ -0,0 +1,237 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { requireCustomerAuth } from '../middleware/auth'; +import { getCustomerById } from '../services/authService'; +import { + getOrCreateStripeCustomer, + createCheckoutSession, + createBillingPortalSession, + cancelSubscription, + changeSubscriptionPlan, + constructWebhookEvent, + PLAN_PRICES, +} from '../services/stripeService'; +import { createLicense, revokeLicense } from '../services/licensingService'; +import { sendLicenseConfirmationEmail } from '../services/mailService'; +import { query } from '../db'; +import { config } from '../config'; +import crypto from 'crypto'; + +// Plan key → licensing server plan ID mapping +const PLAN_ID_MAP: Record = { + 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 new file mode 100644 index 0000000..c1fd7a7 --- /dev/null +++ b/api/src/routes/license.ts @@ -0,0 +1,85 @@ +import type { FastifyInstance } from 'fastify'; +import { requireCustomerAuth } from '../middleware/auth'; +import { getLicense, updateServerIp } from '../services/licensingService'; +import { query } from '../db'; + +export default async function licenseRoutes(app: FastifyInstance): Promise { + 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 new file mode 100644 index 0000000..e41a0e3 --- /dev/null +++ b/api/src/services/authService.ts @@ -0,0 +1,112 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { query } from '../db'; +import { config } from '../config'; + +export interface Customer { + id: string; + email: string; + full_name: string; + is_verified: boolean; + stripe_customer_id: string | null; + created_at: Date; +} + +export async function getCustomerByEmail(email: string): Promise { + 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 new file mode 100644 index 0000000..79095b9 --- /dev/null +++ b/api/src/services/licensingService.ts @@ -0,0 +1,52 @@ +/** + * HTTP bridge to the nixpanel-licensing server. + * The website API calls this internally — never exposes licensing server directly. + */ +import { config } from '../config'; + +async function licensingRequest( + 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 new file mode 100644 index 0000000..cc3ac75 --- /dev/null +++ b/api/src/services/mailService.ts @@ -0,0 +1,62 @@ +import nodemailer from 'nodemailer'; +import { config } from '../config'; + +const transporter = nodemailer.createTransport({ + host: config.mail.host, + port: config.mail.port, + auth: { user: config.mail.user, pass: config.mail.password }, +}); + +export async function sendVerificationEmail(email: string, token: string): Promise { + 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 new file mode 100644 index 0000000..cf4721c --- /dev/null +++ b/api/src/services/stripeService.ts @@ -0,0 +1,110 @@ +import Stripe from 'stripe'; +import { config } from '../config'; +import { query } from '../db'; + +const stripe = new Stripe(config.stripe.secretKey, { apiVersion: '2024-06-20' }); + +// Annual plans are 20% off (pay 10 months, get 12) +export const PLAN_PRICES: Record = { + 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 new file mode 100644 index 0000000..17af22d --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..8be46b3 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +# Vite exposes vars prefixed with VITE_ to the browser +VITE_API_URL=https://nixpanel.io/api/v1 +VITE_STRIPE_PUBLISHABLE_KEY=pk_live_... diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..fe2adbd --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + NixPanel — Hosting Control Panel + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..7678947 --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "nixpanel-website-web", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.24.1", + "axios": "^1.7.2", + "zustand": "^4.5.4", + "@stripe/stripe-js": "^4.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.3", + "vite": "^5.3.4", + "tailwindcss": "^3.4.6", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..3af02bd --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,39 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import Layout from '@/components/layout/Layout'; +import Home from '@/pages/Home'; +import Pricing from '@/pages/Pricing'; +import Login from '@/pages/Login'; +import Register from '@/pages/Register'; +import Dashboard from '@/pages/Dashboard'; +import VerifyEmail from '@/pages/VerifyEmail'; +import ForgotPassword from '@/pages/ForgotPassword'; +import ResetPassword from '@/pages/ResetPassword'; +import Install from '@/pages/Install'; +import { useAuthStore } from '@/store/auth'; + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore(s => s.token); + return token ? <>{children} : ; +} + +export default function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + ); +} diff --git a/web/src/components/PricingCard.tsx b/web/src/components/PricingCard.tsx new file mode 100644 index 0000000..0e56a82 --- /dev/null +++ b/web/src/components/PricingCard.tsx @@ -0,0 +1,91 @@ +import { useNavigate } from 'react-router-dom'; +import Button from './ui/Button'; + +interface PricingCardProps { + name: string; + planKey: string; + monthlyPrice: number; + annualPrice: number; + interval: 'monthly' | 'annual'; + features: string[]; + limits: { + domains: string; + accounts: string; + emails: string; + databases: string; + disk: string; + }; + highlighted?: boolean; +} + +export default function PricingCard({ + name, planKey, monthlyPrice, annualPrice, interval, + features, limits, highlighted = false, +}: PricingCardProps) { + const navigate = useNavigate(); + const isFree = monthlyPrice === 0; + const price = interval === 'annual' ? annualPrice : monthlyPrice; + const perMonth = interval === 'annual' ? (annualPrice / 12).toFixed(2) : monthlyPrice.toFixed(2); + + return ( +
+ {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 new file mode 100644 index 0000000..a405754 --- /dev/null +++ b/web/src/components/layout/Footer.tsx @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..98d5f55 --- /dev/null +++ b/web/src/components/layout/Header.tsx @@ -0,0 +1,58 @@ +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth'; + +export default function Header() { + const { customer, logout } = useAuthStore(); + const navigate = useNavigate(); + + function handleLogout() { + logout(); + navigate('/'); + } + + return ( +
+
+ + + NixPanel + + + + + +
+ {customer ? ( + <> + + Dashboard + + + + ) : ( + <> + + Sign in + + + Get started + + + )} +
+
+
+ ); +} diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx new file mode 100644 index 0000000..0c2ea40 --- /dev/null +++ b/web/src/components/layout/Layout.tsx @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..4883a71 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..a07ad5f --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..dcc6bae --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,9 @@ +@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 new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..807e39f --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { billingApi, licenseApi } from '@/services/api'; +import { useAuthStore } from '@/store/auth'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; + +const PLAN_LABELS: Record = { + 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 new file mode 100644 index 0000000..e230c7b --- /dev/null +++ b/web/src/pages/ForgotPassword.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { authApi } from '@/services/api'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; + +export default function ForgotPassword() { + const [email, setEmail] = useState(''); + const [sent, setSent] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + await authApi.forgotPassword(email).catch(() => {}); + setSent(true); + setLoading(false); + } + + if (sent) return ( +
+
+
📬
+

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 new file mode 100644 index 0000000..69b0a62 --- /dev/null +++ b/web/src/pages/Home.tsx @@ -0,0 +1,70 @@ +import { Link } from 'react-router-dom'; +import Button from '@/components/ui/Button'; + +const features = [ + { icon: '⚡', title: 'Lightning Fast', body: 'Built for performance. Nginx + Apache support out of the box.' }, + { icon: '🔒', title: 'Security First', body: 'Hardened defaults, Let\'s Encrypt SSL, and two-factor auth.' }, + { icon: '🌐', title: 'WordPress Ready', body: 'One-click WordPress installs with WP-CLI and auto-updates.' }, + { icon: '📊', title: 'Full Control', body: 'Manage DNS, email, databases, and cron jobs in one place.' }, + { icon: '🔄', title: 'Easy Migration', body: 'Import from cPanel with a single command.' }, + { icon: '💳', title: 'Simple Licensing', body: 'Tied to your server IP. No per-user fees, no surprises.' }, +]; + +export default function Home() { + return ( +
+ {/* 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 new file mode 100644 index 0000000..fd65fe4 --- /dev/null +++ b/web/src/pages/Install.tsx @@ -0,0 +1,324 @@ +import { useState } from 'react'; + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const copy = () => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( + + ); +} + +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 new file mode 100644 index 0000000..581c2e0 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { authApi } from '@/services/api'; +import { useAuthStore } from '@/store/auth'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; + +export default function Login() { + const navigate = useNavigate(); + const setAuth = useAuthStore(s => s.setAuth); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const { data } = await authApi.login(email, password); + const me = await authApi.me(); + setAuth(data.token, me.data); + navigate('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.error || 'Login failed. Please try again.'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

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 new file mode 100644 index 0000000..1097096 --- /dev/null +++ b/web/src/pages/Pricing.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import PricingCard from '@/components/PricingCard'; + +const plans = [ + { + name: 'Free', planKey: 'free', + monthlyPrice: 0, annualPrice: 0, + highlighted: false, + limits: { domains: '1', accounts: '1', emails: '5', databases: '2', disk: '5 GB' }, + features: ['Community support', 'Let\'s Encrypt SSL'], + }, + { + name: 'Solo', planKey: 'solo', + monthlyPrice: 9.99, annualPrice: 95.90, + highlighted: false, + limits: { domains: '5', accounts: '5', emails: '50', databases: '10', disk: '50 GB' }, + features: ['Email support', 'Let\'s Encrypt SSL', 'WP one-click install'], + }, + { + name: 'Host', planKey: 'host', + monthlyPrice: 29.99, annualPrice: 287.90, + highlighted: true, + limits: { domains: '50', accounts: '50', emails: '500', databases: '100', disk: '250 GB' }, + features: ['Priority support', 'Let\'s Encrypt SSL', 'WP one-click install', 'WHM reseller panel'], + }, + { + name: 'Agency', planKey: 'agency', + monthlyPrice: 79.99, annualPrice: 767.90, + highlighted: false, + limits: { domains: 'Unlimited', accounts: 'Unlimited', emails: 'Unlimited', databases: 'Unlimited', disk: 'Unlimited' }, + features: ['Priority support', 'Let\'s Encrypt SSL', 'WP one-click install', 'WHM reseller panel', 'White-label ready'], + }, +]; + +export default function Pricing() { + const [interval, setInterval] = useState<'monthly' | 'annual'>('monthly'); + + return ( +
+
+

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 new file mode 100644 index 0000000..e0adf72 --- /dev/null +++ b/web/src/pages/Register.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { authApi } from '@/services/api'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; + +export default function Register() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const plan = params.get('plan') || ''; + + const [fullName, setFullName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await authApi.register({ email, password, fullName }); + setSuccess(true); + } catch (err: any) { + setError(err.response?.data?.message || err.response?.data?.error || 'Registration failed.'); + } finally { + setLoading(false); + } + } + + if (success) { + return ( +
+
+
📬
+

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 new file mode 100644 index 0000000..fe4637e --- /dev/null +++ b/web/src/pages/ResetPassword.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { authApi } from '@/services/api'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; + +export default function ResetPassword() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const token = params.get('token'); + if (!token) { setError('Invalid reset link.'); return; } + setLoading(true); + try { + await authApi.resetPassword(token, password); + navigate('/login'); + } catch (err: any) { + setError(err.response?.data?.error || 'Failed to reset password.'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

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 new file mode 100644 index 0000000..76e84f4 --- /dev/null +++ b/web/src/pages/VerifyEmail.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams, Link } from 'react-router-dom'; +import axios from 'axios'; + +export default function VerifyEmail() { + const [params] = useSearchParams(); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + + useEffect(() => { + const token = params.get('token'); + if (!token) { setStatus('error'); return; } + axios.get(`/api/v1/auth/verify-email?token=${token}`) + .then(() => setStatus('success')) + .catch(() => setStatus('error')); + }, []); + + return ( +
+
+ {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 new file mode 100644 index 0000000..b98c0d5 --- /dev/null +++ b/web/src/services/api.ts @@ -0,0 +1,63 @@ +import axios from 'axios'; +import { useAuthStore } from '@/store/auth'; + +const api = axios.create({ baseURL: '/api/v1' }); + +api.interceptors.request.use(config => { + const token = useAuthStore.getState().token; + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +api.interceptors.response.use( + r => r, + err => { + if (err.response?.status === 401) { + useAuthStore.getState().logout(); + window.location.href = '/login'; + } + return Promise.reject(err); + } +); + +// Auth +export const authApi = { + register: (data: { email: string; password: string; fullName: string }) => + api.post('/auth/register', data), + login: (email: string, password: string) => + api.post<{ token: string }>('/auth/login', { email, password }), + me: () => + api.get('/auth/me'), + forgotPassword: (email: string) => + api.post('/auth/forgot-password', { email }), + resetPassword: (token: string, password: string) => + api.post('/auth/reset-password', { token, password }), +}; + +// Billing +export const billingApi = { + plans: () => + api.get('/billing/plans'), + checkout: (planKey: string, interval: 'monthly' | 'annual', serverIp: string) => + api.post<{ url: string }>('/billing/checkout', { planKey, interval, serverIp }), + portal: () => + api.post<{ url: string }>('/billing/portal'), + cancel: () => + api.post('/billing/cancel'), + changePlan: (planKey: string, interval: 'monthly' | 'annual') => + api.post('/billing/change-plan', { planKey, interval }), + subscription: () => + api.get('/billing/subscription'), +}; + +// License +export const licenseApi = { + get: () => + api.get('/license'), + changeIp: (newIp: string) => + api.post('/license/change-ip', { newIp }), + ipHistory: () => + api.get('/license/ip-history'), +}; + +export default api; diff --git a/web/src/store/auth.ts b/web/src/store/auth.ts new file mode 100644 index 0000000..6c6149c --- /dev/null +++ b/web/src/store/auth.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface Customer { + id: string; + email: string; + full_name: string; +} + +interface AuthState { + token: string | null; + customer: Customer | null; + setAuth: (token: string, customer: Customer) => void; + logout: () => void; +} + +export const useAuthStore = create()( + 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 new file mode 100644 index 0000000..2a8435a --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,22 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + brand: { + 50: '#eff6ff', + 100: '#dbeafe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 900: '#1e3a5f', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4ffafe1 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "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 new file mode 100644 index 0000000..444078c --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@': path.resolve(__dirname, './src') }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:4000', + changeOrigin: true, + }, + }, + }, +});