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 [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 = () => {
<Input
placeholder="Enter your license key"
value={licenseKey}
disabled={data?.user?.licenseKey !== null}
onChange={(e) => setLicenseKey(e.target.value)}
className="w-full"
/>
</div>
<Button
onClick={handleValidateLicense}
variant="secondary"
disabled={isLoading}
>
{isLoading ? "Validating..." : "Validate"}
</Button>
{!data?.user?.licenseKey ? (
<Button
onClick={handleSaveLicense}
variant="secondary"
disabled={isLoading}
>
{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>

View File

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

View File

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

View File

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

View File

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