feat(licenses): implement license management system with email notifications

- Added database schema for licenses, including types and statuses.
- Implemented license creation, validation, and activation functionalities.
- Integrated email templates for sending license keys and resend requests.
- Updated package dependencies and configuration for PostgreSQL integration.
- Introduced migration and truncation scripts for database management.
- Enhanced API endpoints for license operations with error handling and validation.
This commit is contained in:
Mauricio Siu
2025-03-19 00:36:35 -06:00
parent 9d047164ee
commit 4b6db35f16
19 changed files with 5071 additions and 82 deletions

View File

@@ -0,0 +1,15 @@
import type { Config } from "drizzle-kit";
import "dotenv/config";
const connectionString = process.env.DATABASE_URL!;
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
verbose: true,
strict: true,
} satisfies Config;

View File

@@ -0,0 +1,21 @@
CREATE TYPE "public"."billing_type" AS ENUM('monthly', 'annual');--> statement-breakpoint
CREATE TYPE "public"."license_status" AS ENUM('active', 'expired', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."license_type" AS ENUM('basic', 'premium', 'business');--> statement-breakpoint
CREATE TABLE "licenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"customer_id" text NOT NULL,
"product_id" text NOT NULL,
"license_key" text NOT NULL,
"status" "license_status" DEFAULT 'active' NOT NULL,
"type" "license_type" NOT NULL,
"billing_type" "billing_type" NOT NULL,
"server_ip" text,
"activated_at" timestamp,
"last_verified_at" timestamp,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"metadata" text,
"email" text NOT NULL,
CONSTRAINT "licenses_license_key_unique" UNIQUE("license_key")
);

View File

@@ -0,0 +1,164 @@
{
"id": "41745f43-6627-49f6-afa3-ab192559b5a7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.licenses": {
"name": "licenses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"product_id": {
"name": "product_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"license_key": {
"name": "license_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "license_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"type": {
"name": "type",
"type": "license_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"billing_type": {
"name": "billing_type",
"type": "billing_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"server_ip": {
"name": "server_ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"activated_at": {
"name": "activated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_verified_at": {
"name": "last_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"licenses_license_key_unique": {
"name": "licenses_license_key_unique",
"nullsNotDistinct": false,
"columns": [
"license_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.billing_type": {
"name": "billing_type",
"schema": "public",
"values": [
"monthly",
"annual"
]
},
"public.license_status": {
"name": "license_status",
"schema": "public",
"values": [
"active",
"expired",
"cancelled"
]
},
"public.license_type": {
"name": "license_type",
"schema": "public",
"values": [
"basic",
"premium",
"business"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1742364501431,
"tag": "0000_furry_nico_minoru",
"breakpoints": true
}
]
}

View File

@@ -6,27 +6,41 @@
"dev": "PORT=4000 tsx watch src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"generate": "drizzle-kit generate",
"drop": "drizzle-kit drop",
"migrate": "tsx src/migrate.ts",
"truncate": "tsx src/truncate.ts",
"reset:all": "tsx src/truncate.ts && tsx src/migrate.ts",
"studio": "drizzle-kit studio"
},
"dependencies": {
"@react-email/components": "^0.0.21",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",
"@react-email/render": "^1.0.5",
"@types/pg": "^8.11.11",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.39.1",
"hono": "^4.5.8",
"nodemailer": "6.9.14",
"pg": "^8.14.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"@hono/zod-validator": "0.3.0",
"zod": "^3.23.4",
"postgres": "3.4.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.12.1",
"hono": "^4.5.8",
"dotenv": "^16.3.1",
"stripe": "17.2.0"
"stripe": "17.2.0",
"zod": "^3.23.4"
},
"devDependencies": {
"typescript": "^5.4.2",
"@types/node": "^20.11.17",
"@types/nodemailer": "^6.4.16",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
"drizzle-kit": "^0.30.4",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
},
"packageManager": "pnpm@9.5.0"
}

9
apps/licenses/src/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });

View File

@@ -1,39 +1,276 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import "dotenv/config";
import { cors } from "hono/cors";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
import { logger } from "./logger.js";
import { deployJobSchema } from "./schema.js";
import { logger } from "./logger";
import Stripe from "stripe";
const app = new Hono();
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import { LicenseEmail } from "../templates/emails/license-email";
import { ResendLicenseEmail } from "../templates/emails/resend-license-email";
import {
createLicense,
validateLicense,
activateLicense,
} from "./utils/license";
import { db } from "./db";
import { eq } from "drizzle-orm";
import { licenses } from "./schema";
import "dotenv/config";
app.post("/deploy", zValidator("json", deployJobSchema), (c) => {
const data = c.req.valid("json");
return c.json(
{
message: "Deployment Added",
},
200,
);
const app = new Hono();
app.use("/*", cors());
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
// Stripe webhook
const transporter = createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const validateSchema = z.object({
licenseKey: z.string(),
serverIp: z.string(),
});
const resendSchema = z.object({
licenseKey: z.string(),
});
// Endpoint para validar una licencia
app.post("/validate", zValidator("json", validateSchema), async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const result = await validateLicense(licenseKey, serverIp);
return c.json(result);
} catch (error) {
logger.error("Error validating license:", error);
return c.json({ isValid: false, error: "Error validating license" }, 500);
}
});
// Endpoint para activar una licencia
app.post("/activate", zValidator("json", validateSchema), async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await activateLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error activating license:", error);
if (error instanceof Error) {
return c.json({ success: false, error: error.message }, 400);
}
return c.json({ success: false, error: "Unknown error occurred" }, 400);
}
});
// Endpoint para reenviar una licencia por email
app.post("/resend-license", zValidator("json", resendSchema), async (c) => {
const { licenseKey } = c.req.valid("json");
try {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
return c.json({ success: false, error: "License not found" }, 404);
}
// Generar el email
const emailHtml = await render(
ResendLicenseEmail({
customerName: license.customerId,
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${license.type}`,
expirationDate: new Date(license.expiresAt),
requestDate: new Date(),
}),
);
// Enviar el email
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: license.email,
subject: "Your Dokploy License Key",
html: emailHtml,
});
return c.json({ success: true });
} catch (error) {
logger.error("Error resending license:", error);
return c.json({ success: false, error: "Error resending license" }, 500);
}
});
// Webhook de Stripe
app.post("/stripe/webhook", async (c) => {
const sig = c.req.header("stripe-signature");
const body = await c.req.json();
const event = stripe.webhooks.constructEvent(
body,
c.req.header("stripe-signature"),
process.env.STRIPE_WEBHOOK_SECRET,
);
let event: Stripe.Event;
return c.json({ status: "ok" });
try {
event = stripe.webhooks.constructEvent(
JSON.stringify(body),
sig!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
logger.error("Webhook signature verification failed:", err);
return c.json({ error: "Webhook signature verification failed" }, 400);
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
// Obtener el customer
const customerResponse = await stripe.customers.retrieve(
session.customer as string,
);
if (customerResponse.deleted) {
throw new Error("Customer was deleted");
}
// Obtener el precio y determinar el tipo de licencia y facturación
const lineItems = await stripe.checkout.sessions.listLineItems(
session.id,
);
const priceId = lineItems.data[0].price?.id;
const { type, billingType } = getLicenseTypeFromPriceId(priceId!);
// Crear la licencia
const license = await createLicense({
customerId: customerResponse.id,
productId: session.id,
type,
billingType,
email: session.customer_details?.email!,
});
// Enviar el email con la licencia
const features = getLicenseFeatures(type);
const emailHtml = await render(
LicenseEmail({
customerName: customerResponse.name || "Customer",
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${type}`,
expirationDate: new Date(license.expiresAt),
features: features,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: license.email,
subject: "Your Dokploy License Key",
html: emailHtml,
});
break;
}
// Puedes agregar más casos según necesites
}
return c.json({ received: true });
} catch (error) {
logger.error("Error processing webhook:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 500);
}
return c.json({ error: "Unknown error occurred" }, 500);
}
});
app.get("/health", async (c) => {
return c.json({ status: "ok" });
});
// Función auxiliar para obtener las características según el tipo de licencia
function getLicenseFeatures(type: string): string[] {
const baseFeatures = [
"Unlimited deployments",
"Basic monitoring",
"Email support",
];
const port = Number.parseInt(process.env.PORT || "3000");
logger.info("Starting Deployments Server ✅", port);
serve({ fetch: app.fetch, port });
const premiumFeatures = [
...baseFeatures,
"Priority support",
"Advanced monitoring",
"Custom domains",
"Team collaboration",
];
const businessFeatures = [
...premiumFeatures,
"24/7 support",
"Custom integrations",
"SLA guarantees",
"Dedicated account manager",
];
switch (type) {
case "basic":
return baseFeatures;
case "premium":
return premiumFeatures;
case "business":
return businessFeatures;
default:
return baseFeatures;
}
}
// Función auxiliar para determinar el tipo de licencia según el price ID
function getLicenseTypeFromPriceId(priceId: string): {
type: "basic" | "premium" | "business";
billingType: "monthly" | "annual";
} {
const priceMap = {
[process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID!]: {
type: "basic",
billingType: "monthly",
},
[process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID!]: {
type: "basic",
billingType: "annual",
},
[process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID!]: {
type: "premium",
billingType: "monthly",
},
[process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID!]: {
type: "premium",
billingType: "annual",
},
[process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID!]: {
type: "business",
billingType: "monthly",
},
[process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID!]: {
type: "business",
billingType: "annual",
},
} as const;
return priceMap[priceId] || { type: "basic", billingType: "monthly" };
}
const port = process.env.PORT || 4000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port: Number(port),
});

View File

@@ -0,0 +1,22 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import "dotenv/config";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql, { schema });
await migrate(db, { migrationsFolder: "drizzle" })
.then(() => {
console.log("Migration complete");
sql.end();
})
.catch((error) => {
console.error("Migration failed", error);
process.exit(1);
})
.finally(() => {
sql.end();
});

View File

@@ -1,34 +1,37 @@
import { z } from "zod";
import { sql } from "drizzle-orm";
import { pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application"),
serverId: z.string().min(1),
}),
z.object({
composeId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("compose"),
serverId: z.string().min(1),
}),
z.object({
applicationId: z.string(),
previewDeploymentId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),
export const licenseStatusEnum = pgEnum("license_status", [
"active",
"expired",
"cancelled",
]);
export type DeployJob = z.infer<typeof deployJobSchema>;
export const licenseTypeEnum = pgEnum("license_type", [
"basic",
"premium",
"business",
]);
export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
export const licenses = pgTable("licenses", {
id: uuid("id").defaultRandom().primaryKey(),
customerId: text("customer_id").notNull(),
productId: text("product_id").notNull(),
licenseKey: text("license_key").notNull().unique(),
status: licenseStatusEnum("status").notNull().default("active"),
type: licenseTypeEnum("type").notNull(),
billingType: billingTypeEnum("billing_type").notNull(),
serverIp: text("server_ip"),
activatedAt: timestamp("activated_at"),
lastVerifiedAt: timestamp("last_verified_at"),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
metadata: text("metadata"),
email: text("email").notNull(),
});
export type License = typeof licenses.$inferSelect;
export type NewLicense = typeof licenses.$inferInsert;

View File

@@ -0,0 +1,24 @@
import { sql } from "drizzle-orm";
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
import { drizzle } from "drizzle-orm/postgres-js";
import "dotenv/config";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const db = drizzle(pg);
const clearDb = async (): Promise<void> => {
try {
const tablesQuery = sql<string>`DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP schema drizzle CASCADE;`;
const tables = await db.execute(tablesQuery);
console.log(tables);
await pg.end();
} catch (error) {
console.error("Error cleaning database", error);
} finally {
}
};
clearDb();

View File

@@ -0,0 +1,119 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
import { licenses } from "../schema";
import { eq } from "drizzle-orm";
export const generateLicenseKey = () => {
return randomBytes(32).toString("hex");
};
export const createLicense = async ({
customerId,
productId,
type,
billingType,
email,
}: {
customerId: string;
productId: string;
type: "basic" | "premium" | "business";
billingType: "monthly" | "annual";
email: string;
}) => {
const licenseKey = generateLicenseKey();
const expiresAt = new Date();
expiresAt.setMonth(
expiresAt.getMonth() + (billingType === "annual" ? 12 : 1),
);
const license = await db
.insert(licenses)
.values({
customerId,
productId,
licenseKey,
type,
billingType,
expiresAt,
email,
})
.returning();
return license[0];
};
export const validateLicense = async (
licenseKey: string,
serverIp?: string,
) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
return { isValid: false, error: "License not found" };
}
if (license.status !== "active") {
return { isValid: false, error: "License is not active" };
}
if (new Date() > license.expiresAt) {
await db
.update(licenses)
.set({ status: "expired" })
.where(eq(licenses.id, license.id));
return { isValid: false, error: "License has expired" };
}
if (license.serverIp && serverIp && license.serverIp !== serverIp) {
return { isValid: false, error: "Invalid server IP" };
}
// Update last verified timestamp
await db
.update(licenses)
.set({ lastVerifiedAt: new Date() })
.where(eq(licenses.id, license.id));
return { isValid: true, license };
};
export const activateLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
if (license.status !== "active") {
throw new Error("License is not active");
}
if (new Date() > license.expiresAt) {
await db
.update(licenses)
.set({ status: "expired" })
.where(eq(licenses.id, license.id));
throw new Error("License has expired");
}
if (license.serverIp && license.serverIp !== serverIp) {
throw new Error("License is already activated on a different server");
}
// Activate the license with the server IP
const updatedLicense = await db
.update(licenses)
.set({
serverIp,
activatedAt: new Date(),
lastVerifiedAt: new Date(),
})
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};

View File

@@ -0,0 +1,303 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
Button,
} from "@react-email/components";
import * as React from "react";
interface LicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
expirationDate: Date;
features: string[];
}
const baseUrl = "https://dokploy.com";
export const LicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
expirationDate = new Date(),
features = ["Feature 1", "Feature 2", "Feature 3"],
}: LicenseEmailProps): React.ReactElement => {
const formattedDate = expirationDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Html>
<Head />
<Preview>Your Dokploy License Key is Here! 🚀</Preview>
<Body style={main}>
<Container style={container}>
<Section style={heroSection}>
<Img
src={`${baseUrl}/og.png`}
width="180"
height="auto"
alt="Dokploy"
style={{ borderRadius: "10px", margin: "0 auto" }}
/>
<Heading style={heroTitle}>
Welcome to the Future of Deployment
</Heading>
</Section>
<Section style={mainContent}>
<Text style={greeting}>Hi {customerName},</Text>
<Text style={text}>
Thank you for choosing {productName}! We're excited to have you on
board. Your premium license key is ready to unlock all the
powerful features:
</Text>
<Section style={licenseContainer}>
<Text style={licenseLabel}>Your License Key</Text>
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={validitySection}>
<Text style={validityText}>
🗓️ Next billing date: <strong>{formattedDate}</strong>
</Text>
</Section>
<Section style={featuresContainer}>
<Heading as="h2" style={h2}>
🎉 Your Premium Features
</Heading>
<div style={featureGrid}>
{features.map((feature, index) => (
<Text key={index} style={featureItem}>
<span style={checkmark}>✓</span> {feature}
</Text>
))}
</div>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Getting Started
</Heading>
<div style={stepsContainer}>
<Text style={steps}>
1. Go to your Dokploy dashboard
<br />
2. Navigate to Settings → License
<br />
3. Enter your license key
<br />
4. Click "Activate License"
</Text>
</div>
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
Activate Your License
</Button>
</Section>
<Hr style={hr} />
<Section style={supportSection}>
<Text style={supportText}>
Need help? Our support team is ready to assist you.
<br />
<Link style={link} href="mailto:support@dokploy.com">
support@dokploy.com
</Link>
</Text>
</Section>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
};
const heroSection = {
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 20px",
textAlign: "center" as const,
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const heroTitle = {
color: "#1a1a1a",
fontSize: "28px",
fontWeight: "800",
lineHeight: "1.3",
margin: "20px 0 0",
padding: "0",
};
const mainContent = {
backgroundColor: "#ffffff",
borderRadius: "8px",
marginTop: "24px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const greeting = {
fontSize: "20px",
lineHeight: "1.3",
fontWeight: "600",
color: "#1a1a1a",
margin: "0 0 20px",
};
const text = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.6",
margin: "0 0 24px",
};
const licenseContainer = {
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
borderRadius: "12px",
padding: "24px",
margin: "32px 0",
textAlign: "center" as const,
};
const licenseLabel = {
color: "#ffffff",
fontSize: "14px",
textTransform: "uppercase" as const,
letterSpacing: "1px",
margin: "0 0 12px",
};
const licenseKeyStyle = {
fontFamily: "monospace",
fontSize: "24px",
color: "#ffffff",
margin: "0",
wordBreak: "break-all" as const,
fontWeight: "600",
};
const validitySection = {
textAlign: "center" as const,
margin: "24px 0",
};
const validityText = {
color: "#4a5568",
fontSize: "16px",
};
const featuresContainer = {
margin: "40px 0",
};
const featureGrid = {
display: "grid",
gridTemplateColumns: "1fr",
gap: "12px",
};
const featureItem = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.5",
margin: "0",
display: "flex",
alignItems: "center",
};
const checkmark = {
color: "#2563eb",
fontWeight: "bold",
marginRight: "12px",
fontSize: "18px",
};
const h2 = {
color: "#1a1a1a",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 20px",
padding: "0",
};
const activationSection = {
backgroundColor: "#f8fafc",
borderRadius: "8px",
padding: "24px",
margin: "32px 0",
};
const stepsContainer = {
margin: "20px 0",
};
const steps = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.8",
margin: "0",
};
const ctaButton = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
margin: "20px 0 0",
};
const hr = {
borderColor: "#e2e8f0",
margin: "40px 0",
};
const supportSection = {
textAlign: "center" as const,
};
const supportText = {
color: "#64748b",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
};
const link = {
color: "#2563eb",
textDecoration: "none",
fontWeight: "500",
};
export default LicenseEmail;

View File

@@ -0,0 +1,292 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
Button,
} from "@react-email/components";
import * as React from "react";
interface ResendLicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
expirationDate: Date;
requestDate?: Date;
}
const baseUrl = "https://dokploy.com";
export const ResendLicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
expirationDate = new Date(),
requestDate = new Date(),
}: ResendLicenseEmailProps): React.ReactElement => {
const formattedDate = expirationDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<Html>
<Head />
<Preview>Your Requested Dokploy License Key 🔑</Preview>
<Body style={main}>
<Container style={container}>
<Section style={heroSection}>
<Img
src={`${baseUrl}/og.png`}
width="180"
height="auto"
alt="Dokploy"
style={{ borderRadius: "10px", margin: "0 auto" }}
/>
<Heading style={heroTitle}>Here's Your License Key</Heading>
</Section>
<Section style={mainContent}>
<Text style={greeting}>Hi {customerName},</Text>
<Text style={text}>
As requested on {formattedRequestDate}, here is your {productName}{" "}
license key. This is the same active license key associated with
your account:
</Text>
<Section style={licenseContainer}>
<Text style={licenseLabel}>Your Active License Key</Text>
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={validitySection}>
<Text style={validityText}>
🗓️ Valid until: <strong>{formattedDate}</strong>
</Text>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Quick Activation Guide
</Heading>
<div style={stepsContainer}>
<Text style={steps}>
1. Go to your Dokploy dashboard
<br />
2. Navigate to Settings → License
<br />
3. Enter your license key above
<br />
4. Click "Activate License"
</Text>
</div>
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
Go to Dashboard
</Button>
</Section>
<Hr style={hr} />
<Section style={securitySection}>
<Text style={securityText}>
🔒 For security: If you didn't request this license key, please
contact our support team immediately.
</Text>
</Section>
<Section style={supportSection}>
<Text style={supportText}>
Need help? Our support team is ready to assist you.
<br />
<Link style={link} href="mailto:support@dokploy.com">
support@dokploy.com
</Link>
</Text>
</Section>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
};
const heroSection = {
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 20px",
textAlign: "center" as const,
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const heroTitle = {
color: "#1a1a1a",
fontSize: "28px",
fontWeight: "800",
lineHeight: "1.3",
margin: "20px 0 0",
padding: "0",
};
const mainContent = {
backgroundColor: "#ffffff",
borderRadius: "8px",
marginTop: "24px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const greeting = {
fontSize: "20px",
lineHeight: "1.3",
fontWeight: "600",
color: "#1a1a1a",
margin: "0 0 20px",
};
const text = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.6",
margin: "0 0 24px",
};
const licenseContainer = {
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
borderRadius: "12px",
padding: "24px",
margin: "32px 0",
textAlign: "center" as const,
};
const licenseLabel = {
color: "#ffffff",
fontSize: "14px",
textTransform: "uppercase" as const,
letterSpacing: "1px",
margin: "0 0 12px",
};
const licenseKeyStyle = {
fontFamily: "monospace",
fontSize: "24px",
color: "#ffffff",
margin: "0",
wordBreak: "break-all" as const,
fontWeight: "600",
};
const validitySection = {
textAlign: "center" as const,
margin: "24px 0",
};
const validityText = {
color: "#4a5568",
fontSize: "16px",
};
const h2 = {
color: "#1a1a1a",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 20px",
padding: "0",
};
const activationSection = {
backgroundColor: "#f8fafc",
borderRadius: "8px",
padding: "24px",
margin: "32px 0",
};
const stepsContainer = {
margin: "20px 0",
};
const steps = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.8",
margin: "0",
};
const ctaButton = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
margin: "20px 0 0",
};
const hr = {
borderColor: "#e2e8f0",
margin: "40px 0",
};
const securitySection = {
backgroundColor: "#fff5f5",
borderRadius: "8px",
padding: "16px",
margin: "0 0 32px 0",
};
const securityText = {
color: "#e53e3e",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
textAlign: "center" as const,
};
const supportSection = {
textAlign: "center" as const,
};
const supportText = {
color: "#64748b",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
};
const link = {
color: "#2563eb",
textDecoration: "none",
fontWeight: "500",
};
export default ResendLicenseEmail;

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

3495
apps/licenses/templates/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "react-email-starter",
"version": "0.1.10",
"private": true,
"scripts": {
"build": "email build",
"dev": "email dev",
"export": "email export"
},
"dependencies": {
"@react-email/components": "0.0.34",
"react-dom": "18.3.1",
"react": "18.3.1"
},
"devDependencies": {
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
"react-email": "3.0.7"
}
}

View File

@@ -0,0 +1,27 @@
# React Email Starter
A live preview right in your browser so you don't need to keep sending real emails during development.
## Getting Started
First, install the dependencies:
```sh
npm install
# or
yarn
```
Then, run the development server:
```sh
npm run dev
# or
yarn dev
```
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
## License
MIT License

View File

@@ -2,7 +2,9 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
@@ -14,5 +16,6 @@
"@dokploy/server/*": ["../../packages/server/src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}