mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(licenses): implement license deactivation and validation features
This commit is contained in:
@@ -18,6 +18,10 @@ export const EnablePaidFeatures = () => {
|
|||||||
const { data, refetch } = api.user.get.useQuery();
|
const { data, refetch } = api.user.get.useQuery();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { mutateAsync: saveLicense } = api.user.saveLicense.useMutation();
|
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 { mutateAsync: update } = api.user.update.useMutation();
|
||||||
const [licenseKey, setLicenseKey] = useState("");
|
const [licenseKey, setLicenseKey] = useState("");
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ export const EnablePaidFeatures = () => {
|
|||||||
}
|
}
|
||||||
}, [data?.user?.enablePaidFeatures]);
|
}, [data?.user?.enablePaidFeatures]);
|
||||||
|
|
||||||
const handleValidateLicense = async () => {
|
const handleSaveLicense = async () => {
|
||||||
if (!licenseKey) {
|
if (!licenseKey) {
|
||||||
toast.error("Please enter a license key");
|
toast.error("Please enter a license key");
|
||||||
return;
|
return;
|
||||||
@@ -47,6 +51,30 @@ export const EnablePaidFeatures = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
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 (
|
return (
|
||||||
@@ -103,17 +131,55 @@ export const EnablePaidFeatures = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Enter your license key"
|
placeholder="Enter your license key"
|
||||||
value={licenseKey}
|
value={licenseKey}
|
||||||
|
disabled={data?.user?.licenseKey !== null}
|
||||||
onChange={(e) => setLicenseKey(e.target.value)}
|
onChange={(e) => setLicenseKey(e.target.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!data?.user?.licenseKey ? (
|
||||||
onClick={handleValidateLicense}
|
<Button
|
||||||
variant="secondary"
|
onClick={handleSaveLicense}
|
||||||
disabled={isLoading}
|
variant="secondary"
|
||||||
>
|
disabled={isLoading}
|
||||||
{isLoading ? "Validating..." : "Validate"}
|
>
|
||||||
</Button>
|
{isLoading ? "Saving..." : "Save License"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleValidateLicense}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Validating..." : "Validate"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
deactivateLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLicenseKey("");
|
||||||
|
toast.success("License removed successfully");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error removing license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
validateLicense,
|
validateLicense,
|
||||||
activateLicense,
|
activateLicense,
|
||||||
|
deactivateLicense,
|
||||||
} from "@/server/utils/validate-license";
|
} from "@/server/utils/validate-license";
|
||||||
const apiCreateApiKey = z.object({
|
const apiCreateApiKey = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -151,23 +152,63 @@ export const userRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const owner = await findUserById(ctx.user.ownerId);
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
const result = await validateLicense(
|
|
||||||
|
const result = await activateLicense(
|
||||||
input.licenseKey,
|
input.licenseKey,
|
||||||
owner?.serverIp || "",
|
owner?.serverIp || "",
|
||||||
);
|
);
|
||||||
if (!result.isValid) {
|
|
||||||
|
if (!result.success) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "BAD_REQUEST",
|
||||||
message: result.error,
|
message: result.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await activateLicense(input.licenseKey, owner?.serverIp || "");
|
|
||||||
|
|
||||||
await updateUser(ctx.user.id, {
|
await updateUser(ctx.user.id, {
|
||||||
licenseKey: input.licenseKey,
|
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;
|
return result;
|
||||||
}),
|
}),
|
||||||
getUserByToken: publicProcedure
|
getUserByToken: publicProcedure
|
||||||
|
|||||||
@@ -29,3 +29,19 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
|
|
||||||
return data;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { activateLicense, validateLicense } from "../utils/license";
|
import {
|
||||||
|
activateLicense,
|
||||||
|
deactivateLicense,
|
||||||
|
validateLicense,
|
||||||
|
cleanLicense,
|
||||||
|
} from "../utils/license";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
import { users } from "../schema";
|
import { users, licenses } from "../schema";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { transporter } from "../email";
|
import { transporter } from "../email";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { stripe } from "../stripe";
|
import { stripe } from "../stripe";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
|
import { getLicenseTypeFromPriceId } from "../utils";
|
||||||
const validateSchema = z.object({
|
const validateSchema = z.object({
|
||||||
licenseKey: z.string(),
|
licenseKey: z.string(),
|
||||||
serverIp: z.string(),
|
serverIp: z.string(),
|
||||||
@@ -28,7 +34,7 @@ licenseRouter.post(
|
|||||||
return c.json(result);
|
return c.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error validating license:", { 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) => {
|
// router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
||||||
// const { licenseKey } = c.req.valid("json");
|
// const { licenseKey } = c.req.valid("json");
|
||||||
|
|
||||||
@@ -178,6 +221,7 @@ licenseRouter.get(
|
|||||||
with: {
|
with: {
|
||||||
licenses: true,
|
licenses: true,
|
||||||
},
|
},
|
||||||
|
orderBy: desc(licenses.createdAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -202,11 +246,16 @@ licenseRouter.get(
|
|||||||
(suscription) => suscription.id === license.stripeSubscriptionId,
|
(suscription) => suscription.id === license.stripeSubscriptionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { type } = getLicenseTypeFromPriceId(
|
||||||
|
suscription?.items.data[0].price.id || "",
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
license: license,
|
license: license,
|
||||||
stripeSuscription: {
|
stripeSuscription: {
|
||||||
quantity: suscription?.items.data[0].quantity,
|
quantity: suscription?.items.data[0].quantity,
|
||||||
billingType: suscription?.items.data[0].price.recurring?.interval,
|
billingType: suscription?.items.data[0].price.recurring?.interval,
|
||||||
|
type: type,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!license) {
|
if (!license) {
|
||||||
return { isValid: false, error: "License not found" };
|
return { success: false, error: "License not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const suscription = await stripe.subscriptions.retrieve(
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
@@ -80,7 +80,7 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
|
|
||||||
if (currentServerQuantity >= serversQuantity) {
|
if (currentServerQuantity >= serversQuantity) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
"You have reached the maximum number of servers, please upgrade your license to add more servers",
|
"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") {
|
if (suscription.status !== "active") {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
success: false,
|
||||||
error: `License is ${getLicenseStatus(suscription)}`,
|
error: `License is ${getLicenseStatus(suscription)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.serverIps && !license.serverIps.includes(serverIp)) {
|
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
|
await db
|
||||||
@@ -102,7 +106,7 @@ export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
.set({ lastVerifiedAt: new Date() })
|
.set({ lastVerifiedAt: new Date() })
|
||||||
.where(eq(licenses.id, license.id));
|
.where(eq(licenses.id, license.id));
|
||||||
|
|
||||||
return { isValid: true, license };
|
return { success: true, license };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
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?.includes(serverIp)) {
|
||||||
|
return license;
|
||||||
if (license.serverIps && !license.serverIps.includes(serverIp)) {
|
|
||||||
throw new Error("License is already activated on a different server");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate the license with the server IP
|
// Activate the license with the server IP
|
||||||
@@ -150,6 +152,45 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
return updatedLicense[0];
|
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) => {
|
export const getLicenseStatus = async (license: Stripe.Subscription) => {
|
||||||
if (license.status === "active") {
|
if (license.status === "active") {
|
||||||
return "active";
|
return "active";
|
||||||
|
|||||||
Reference in New Issue
Block a user