feat(licenses): implement license deactivation and validation features

This commit is contained in:
Mauricio Siu
2025-03-23 19:40:54 -06:00
parent 4074942dbf
commit 1acd330462
5 changed files with 239 additions and 26 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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,
}, },
}; };
}); });

View File

@@ -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";