From 1acd3304629bceaba5cc9ef7934ed2a1b2852c23 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:40:54 -0600 Subject: [PATCH] feat(licenses): implement license deactivation and validation features --- .../settings/enable-paid-features.tsx | 82 +++++++++++++++++-- apps/dokploy/server/api/routers/user.ts | 51 ++++++++++-- apps/dokploy/server/utils/validate-license.ts | 16 ++++ apps/licenses/src/api/license.ts | 57 ++++++++++++- apps/licenses/src/utils/license.ts | 59 +++++++++++-- 5 files changed, 239 insertions(+), 26 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/enable-paid-features.tsx b/apps/dokploy/components/dashboard/settings/enable-paid-features.tsx index c2c6ec2ac..1761d14b5 100644 --- a/apps/dokploy/components/dashboard/settings/enable-paid-features.tsx +++ b/apps/dokploy/components/dashboard/settings/enable-paid-features.tsx @@ -18,6 +18,10 @@ export const EnablePaidFeatures = () => { const { data, refetch } = api.user.get.useQuery(); const [isLoading, setIsLoading] = useState(false); const { mutateAsync: saveLicense } = api.user.saveLicense.useMutation(); + const { mutateAsync: deactivateLicense } = + api.user.deactivateLicense.useMutation(); + const { mutateAsync: validateLicense } = + api.user.validateLicense.useMutation(); const { mutateAsync: update } = api.user.update.useMutation(); const [licenseKey, setLicenseKey] = useState(""); @@ -27,7 +31,7 @@ export const EnablePaidFeatures = () => { } }, [data?.user?.enablePaidFeatures]); - const handleValidateLicense = async () => { + const handleSaveLicense = async () => { if (!licenseKey) { toast.error("Please enter a license key"); return; @@ -47,6 +51,30 @@ export const EnablePaidFeatures = () => { .finally(() => { setIsLoading(false); }); + + refetch(); + }; + + const handleValidateLicense = async () => { + if (!licenseKey) { + toast.error("Please enter a license key"); + return; + } + setIsLoading(true); + await validateLicense({ + licenseKey, + }) + .then(() => { + toast.success("License validated successfully"); + }) + .catch((e) => { + toast.error("Error validating license", { + description: e.message, + }); + }) + .finally(() => { + setIsLoading(false); + }); }; return ( @@ -103,17 +131,55 @@ export const EnablePaidFeatures = () => { setLicenseKey(e.target.value)} className="w-full" /> - + {!data?.user?.licenseKey ? ( + + ) : ( +
+ + +
+ )} )} diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 2676333d9..ec76f9948 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -32,6 +32,7 @@ import { import { validateLicense, activateLicense, + deactivateLicense, } from "@/server/utils/validate-license"; const apiCreateApiKey = z.object({ name: z.string().min(1), @@ -151,23 +152,63 @@ export const userRouter = createTRPCRouter({ ) .mutation(async ({ input, ctx }) => { const owner = await findUserById(ctx.user.ownerId); - const result = await validateLicense( + + const result = await activateLicense( input.licenseKey, owner?.serverIp || "", ); - if (!result.isValid) { + + if (!result.success) { throw new TRPCError({ - code: "UNAUTHORIZED", + code: "BAD_REQUEST", message: result.error, }); } - await activateLicense(input.licenseKey, owner?.serverIp || ""); - await updateUser(ctx.user.id, { licenseKey: input.licenseKey, }); + return result; + }), + deactivateLicense: adminProcedure + .input(z.object({ licenseKey: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const owner = await findUserById(ctx.user.ownerId); + const result = await deactivateLicense( + input.licenseKey, + owner?.serverIp || "", + ); + + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: result.error, + }); + } + + await updateUser(ctx.user.id, { + licenseKey: null, + }); + + return result; + }), + validateLicense: adminProcedure + .input(z.object({ licenseKey: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const owner = await findUserById(ctx.user.ownerId); + const result = await validateLicense( + input.licenseKey, + owner?.serverIp || "", + ); + + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: result.error, + }); + } + return result; }), getUserByToken: publicProcedure diff --git a/apps/dokploy/server/utils/validate-license.ts b/apps/dokploy/server/utils/validate-license.ts index 6f7a07b72..5475e8a0a 100644 --- a/apps/dokploy/server/utils/validate-license.ts +++ b/apps/dokploy/server/utils/validate-license.ts @@ -29,3 +29,19 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => { return data; }; + +export const deactivateLicense = async ( + licenseKey: string, + serverIp: string, +) => { + const response = await fetch(`${licensesUrl}/api/license/deactivate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ licenseKey, serverIp }), + }); + const data = await response.json(); + + return data; +}; diff --git a/apps/licenses/src/api/license.ts b/apps/licenses/src/api/license.ts index 30013de5a..b12399908 100644 --- a/apps/licenses/src/api/license.ts +++ b/apps/licenses/src/api/license.ts @@ -1,15 +1,21 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; -import { activateLicense, validateLicense } from "../utils/license"; +import { + activateLicense, + deactivateLicense, + validateLicense, + cleanLicense, +} from "../utils/license"; import { logger } from "../logger"; -import { eq } from "drizzle-orm"; -import { users } from "../schema"; +import { eq, desc } from "drizzle-orm"; +import { users, licenses } from "../schema"; import { db } from "../db"; import { transporter } from "../email"; import { nanoid } from "nanoid"; import { stripe } from "../stripe"; import type Stripe from "stripe"; +import { getLicenseTypeFromPriceId } from "../utils"; const validateSchema = z.object({ licenseKey: z.string(), serverIp: z.string(), @@ -28,7 +34,7 @@ licenseRouter.post( return c.json(result); } catch (error) { logger.error("Error validating license:", { error }); - return c.json({ isValid: false, error: "Error validating license" }, 500); + return c.json({ success: false, error: "Error validating license" }, 500); } }, ); @@ -52,6 +58,43 @@ licenseRouter.post( }, ); +licenseRouter.post( + "/deactivate", + zValidator("json", validateSchema), + async (c) => { + const { licenseKey, serverIp } = c.req.valid("json"); + + try { + const license = await deactivateLicense(licenseKey, serverIp); + return c.json({ success: true, license }); + } catch (error) { + logger.error("Error deactivating license:", error); + return c.json( + { success: false, error: "Error deactivating license" }, + 500, + ); + } + }, +); + +licenseRouter.post( + "/remove-server", + zValidator( + "json", + z.object({ licenseKey: z.string().min(1), serverIp: z.string().min(1) }), + ), + async (c) => { + const { licenseKey, serverIp } = c.req.valid("json"); + + try { + const license = await cleanLicense(licenseKey, serverIp); + return c.json({ success: true, license }); + } catch (error) { + logger.error("Error cleaning license:", error); + return c.json({ success: false, error: "Error cleaning license" }, 500); + } + }, +); // router.post("/resend-license", zValidator("json", resendSchema), async (c) => { // const { licenseKey } = c.req.valid("json"); @@ -178,6 +221,7 @@ licenseRouter.get( with: { licenses: true, }, + orderBy: desc(licenses.createdAt), }); if (!user) { @@ -202,11 +246,16 @@ licenseRouter.get( (suscription) => suscription.id === license.stripeSubscriptionId, ); + const { type } = getLicenseTypeFromPriceId( + suscription?.items.data[0].price.id || "", + ); + return { license: license, stripeSuscription: { quantity: suscription?.items.data[0].quantity, billingType: suscription?.items.data[0].price.recurring?.interval, + type: type, }, }; }); diff --git a/apps/licenses/src/utils/license.ts b/apps/licenses/src/utils/license.ts index ba97577e0..b92efbe56 100644 --- a/apps/licenses/src/utils/license.ts +++ b/apps/licenses/src/utils/license.ts @@ -69,7 +69,7 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => { }); if (!license) { - return { isValid: false, error: "License not found" }; + return { success: false, error: "License not found" }; } const suscription = await stripe.subscriptions.retrieve( @@ -80,7 +80,7 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => { if (currentServerQuantity >= serversQuantity) { return { - isValid: false, + success: false, error: "You have reached the maximum number of servers, please upgrade your license to add more servers", }; @@ -88,13 +88,17 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => { if (suscription.status !== "active") { return { - isValid: false, + success: false, error: `License is ${getLicenseStatus(suscription)}`, }; } if (license.serverIps && !license.serverIps.includes(serverIp)) { - return { isValid: false, error: "Invalid server IP" }; + return { + success: false, + error: + "This server is not authorized to use this license, please remove the current license from the UI, and activate a new one", + }; } await db @@ -102,7 +106,7 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => { .set({ lastVerifiedAt: new Date() }) .where(eq(licenses.id, license.id)); - return { isValid: true, license }; + return { success: true, license }; }; export const activateLicense = async (licenseKey: string, serverIp: string) => { @@ -130,10 +134,8 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => { ); } - console.log("License", license.serverIps?.includes(serverIp)); - - if (license.serverIps && !license.serverIps.includes(serverIp)) { - throw new Error("License is already activated on a different server"); + if (license.serverIps?.includes(serverIp)) { + return license; } // Activate the license with the server IP @@ -150,6 +152,45 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => { return updatedLicense[0]; }; +export const deactivateLicense = 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"); + } + + const updatedLicense = await db + .update(licenses) + .set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) }) + .where(eq(licenses.id, license.id)) + .returning(); + + return updatedLicense[0]; +}; + +export const cleanLicense = 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"); + } + + const updatedLicense = await db + .update(licenses) + .set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) }) + .where(eq(licenses.id, license.id)) + .returning(); + + return updatedLicense[0]; +}; + export const getLicenseStatus = async (license: Stripe.Subscription) => { if (license.status === "active") { return "active";