feat: initial website commit + installation guide page
- Full nixpanel-website monorepo: web (React/Vite) + api (Fastify) - Add /install page with one-liner, manual steps, requirements, software table, firewall ports, service management, and update/uninstall - Add Install link to header nav and footer - Add 'Install guide' CTA button to home page hero - Add 'outline' variant to Button component - Add .gitignore and .gitattributes Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { query } from '../db';
|
||||
import { config } from '../config';
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_verified: boolean;
|
||||
stripe_customer_id: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export async function getCustomerByEmail(email: string): Promise<any | null> {
|
||||
const rows = await query<any[]>('SELECT * FROM customers WHERE email = ?', [email]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getCustomerById(id: string): Promise<Customer | null> {
|
||||
const rows = await query<any[]>(
|
||||
'SELECT id, email, full_name, is_verified, stripe_customer_id, created_at FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function registerCustomer(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
}): Promise<{ customer: Customer; verifyToken: string }> {
|
||||
const existing = await getCustomerByEmail(data.email);
|
||||
if (existing) throw new Error('Email already registered');
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const hash = await bcrypt.hash(data.password, 12);
|
||||
const verifyToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
await query(
|
||||
`INSERT INTO customers (id, email, password_hash, full_name, verify_token)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, data.email.toLowerCase().trim(), hash, data.fullName.trim(), verifyToken]
|
||||
);
|
||||
|
||||
const customer = await getCustomerById(id) as Customer;
|
||||
return { customer, verifyToken };
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string): Promise<boolean> {
|
||||
const rows = await query<any[]>(
|
||||
'SELECT id FROM customers WHERE verify_token = ?',
|
||||
[token]
|
||||
);
|
||||
if (!rows.length) return false;
|
||||
await query(
|
||||
'UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?',
|
||||
[rows[0].id]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loginCustomer(email: string, password: string): Promise<string> {
|
||||
const customer = await getCustomerByEmail(email.toLowerCase().trim());
|
||||
if (!customer) throw new Error('Invalid credentials');
|
||||
if (!customer.is_verified) throw new Error('Please verify your email before logging in');
|
||||
|
||||
const valid = await bcrypt.compare(password, customer.password_hash);
|
||||
if (!valid) throw new Error('Invalid credentials');
|
||||
|
||||
return issueJwt(customer.id, customer.email);
|
||||
}
|
||||
|
||||
export function issueJwt(id: string, email: string): string {
|
||||
return jwt.sign({ sub: id, email }, config.jwt.secret, {
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): { sub: string; email: string } {
|
||||
return jwt.verify(token, config.jwt.secret) as any;
|
||||
}
|
||||
|
||||
export async function initiatePasswordReset(email: string): Promise<string | null> {
|
||||
const customer = await getCustomerByEmail(email.toLowerCase().trim());
|
||||
if (!customer) return null; // don't reveal whether email exists
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + 1000 * 60 * 60); // 1 hour
|
||||
|
||||
await query(
|
||||
'UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
|
||||
[token, expires, customer.id]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, newPassword: string): Promise<boolean> {
|
||||
const rows = await query<any[]>(
|
||||
'SELECT * FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()',
|
||||
[token]
|
||||
);
|
||||
if (!rows.length) return false;
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await query(
|
||||
'UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
|
||||
[hash, rows[0].id]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* HTTP bridge to the nixpanel-licensing server.
|
||||
* The website API calls this internally — never exposes licensing server directly.
|
||||
*/
|
||||
import { config } from '../config';
|
||||
|
||||
async function licensingRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${config.licensingServer.url}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.licensingServer.apiKey}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error((err as any).error || `Licensing server error: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as T;
|
||||
}
|
||||
|
||||
export async function createLicense(data: {
|
||||
planId: string;
|
||||
ownerEmail: string;
|
||||
ownerName: string;
|
||||
serverIp: string;
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
return licensingRequest('POST', '/api/v1/licenses', data);
|
||||
}
|
||||
|
||||
export async function getLicense(licenseId: string): Promise<any> {
|
||||
return licensingRequest('GET', `/api/v1/licenses/${licenseId}`);
|
||||
}
|
||||
|
||||
export async function revokeLicense(licenseId: string): Promise<void> {
|
||||
await licensingRequest('POST', `/api/v1/licenses/${licenseId}/revoke`);
|
||||
}
|
||||
|
||||
export async function updateServerIp(licenseId: string, serverIp: string): Promise<void> {
|
||||
await licensingRequest('POST', `/api/v1/licenses/${licenseId}/rebind`, { serverIp });
|
||||
}
|
||||
|
||||
export async function getPlans(): Promise<any[]> {
|
||||
return licensingRequest('GET', '/api/v1/admin/plans');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
auth: { user: config.mail.user, pass: config.mail.password },
|
||||
});
|
||||
|
||||
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
|
||||
const url = `${config.appUrl}/verify-email?token=${token}`;
|
||||
await transporter.sendMail({
|
||||
from: config.mail.from,
|
||||
to: email,
|
||||
subject: 'Verify your NixPanel account',
|
||||
html: `
|
||||
<h2>Welcome to NixPanel</h2>
|
||||
<p>Click the link below to verify your email address:</p>
|
||||
<a href="${url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string, token: string): Promise<void> {
|
||||
const url = `${config.appUrl}/reset-password?token=${token}`;
|
||||
await transporter.sendMail({
|
||||
from: config.mail.from,
|
||||
to: email,
|
||||
subject: 'Reset your NixPanel password',
|
||||
html: `
|
||||
<h2>Password Reset</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
|
||||
<a href="${url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
|
||||
Reset Password
|
||||
</a>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLicenseConfirmationEmail(email: string, data: {
|
||||
plan: string;
|
||||
serverIp: string;
|
||||
interval: string;
|
||||
}): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: config.mail.from,
|
||||
to: email,
|
||||
subject: `Your NixPanel ${data.plan} license is active`,
|
||||
html: `
|
||||
<h2>License Activated</h2>
|
||||
<p>Your <strong>${data.plan}</strong> plan (${data.interval}) is now active for server IP <strong>${data.serverIp}</strong>.</p>
|
||||
<p>Log in to your dashboard to manage your license:</p>
|
||||
<a href="${config.appUrl}/dashboard" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
|
||||
Go to Dashboard
|
||||
</a>
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Stripe from 'stripe';
|
||||
import { config } from '../config';
|
||||
import { query } from '../db';
|
||||
|
||||
const stripe = new Stripe(config.stripe.secretKey, { apiVersion: '2024-06-20' });
|
||||
|
||||
// Annual plans are 20% off (pay 10 months, get 12)
|
||||
export const PLAN_PRICES: Record<string, { monthly: number; annual: number; label: string }> = {
|
||||
free: { monthly: 0, annual: 0, label: 'Free' },
|
||||
solo: { monthly: 9.99, annual: 95.90, label: 'Solo' },
|
||||
host: { monthly: 29.99, annual: 287.90, label: 'Host' },
|
||||
agency: { monthly: 79.99, annual: 767.90, label: 'Agency' },
|
||||
};
|
||||
|
||||
export async function getOrCreateStripeCustomer(data: {
|
||||
customerId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
stripeCustomerId?: string | null;
|
||||
}): Promise<string> {
|
||||
if (data.stripeCustomerId) return data.stripeCustomerId;
|
||||
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
metadata: { nixpanel_customer_id: data.customerId },
|
||||
});
|
||||
|
||||
await query(
|
||||
'UPDATE customers SET stripe_customer_id = ? WHERE id = ?',
|
||||
[stripeCustomer.id, data.customerId]
|
||||
);
|
||||
|
||||
return stripeCustomer.id;
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(data: {
|
||||
stripeCustomerId: string;
|
||||
priceId: string;
|
||||
planKey: string;
|
||||
interval: 'monthly' | 'annual';
|
||||
serverIp: string;
|
||||
customerId: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}): Promise<string> {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: data.stripeCustomerId,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: data.priceId, quantity: 1 }],
|
||||
success_url: data.successUrl,
|
||||
cancel_url: data.cancelUrl,
|
||||
metadata: {
|
||||
nixpanel_customer_id: data.customerId,
|
||||
plan_key: data.planKey,
|
||||
billing_interval: data.interval,
|
||||
server_ip: data.serverIp,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
nixpanel_customer_id: data.customerId,
|
||||
plan_key: data.planKey,
|
||||
server_ip: data.serverIp,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return session.url!;
|
||||
}
|
||||
|
||||
export async function createBillingPortalSession(
|
||||
stripeCustomerId: string,
|
||||
returnUrl: string
|
||||
): Promise<string> {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
return session.url;
|
||||
}
|
||||
|
||||
export async function cancelSubscription(stripeSubscriptionId: string): Promise<void> {
|
||||
await stripe.subscriptions.update(stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
await query(
|
||||
'UPDATE subscriptions SET cancel_at_period_end = 1 WHERE stripe_subscription_id = ?',
|
||||
[stripeSubscriptionId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function changeSubscriptionPlan(data: {
|
||||
stripeSubscriptionId: string;
|
||||
newPriceId: string;
|
||||
}): Promise<void> {
|
||||
const sub = await stripe.subscriptions.retrieve(data.stripeSubscriptionId);
|
||||
await stripe.subscriptions.update(data.stripeSubscriptionId, {
|
||||
items: [{ id: sub.items.data[0].id, price: data.newPriceId }],
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
}
|
||||
|
||||
export async function constructWebhookEvent(
|
||||
payload: Buffer,
|
||||
signature: string
|
||||
): Promise<Stripe.Event> {
|
||||
return stripe.webhooks.constructEvent(payload, signature, config.stripe.webhookSecret);
|
||||
}
|
||||
|
||||
export { stripe };
|
||||
Reference in New Issue
Block a user