feat(licenses): enhance license validation and loading state management

- Added loading state management in the EnablePaidFeatures component to improve user experience during license validation.
- Updated validateLicense function to return detailed error messages for better feedback.
- Modified user API to return validation results instead of a boolean, enhancing error handling.
- Removed unused SQL files and updated package.json scripts for better development workflow.
This commit is contained in:
Mauricio Siu
2025-03-23 12:29:55 -06:00
parent 9e30525569
commit c8e6df4c29
9 changed files with 28 additions and 224 deletions

View File

@@ -16,6 +16,7 @@ import { useState, useEffect } from "react";
export const EnablePaidFeatures = () => { export const EnablePaidFeatures = () => {
const { data, refetch } = api.user.get.useQuery(); const { data, refetch } = api.user.get.useQuery();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: validateLicense } = const { mutateAsync: validateLicense } =
api.user.validateLicense.useMutation(); api.user.validateLicense.useMutation();
const { mutateAsync: update } = api.user.update.useMutation(); const { mutateAsync: update } = api.user.update.useMutation();
@@ -32,6 +33,7 @@ export const EnablePaidFeatures = () => {
toast.error("Please enter a license key"); toast.error("Please enter a license key");
return; return;
} }
setIsLoading(true);
await validateLicense({ await validateLicense({
licenseKey, licenseKey,
}) })
@@ -40,7 +42,12 @@ export const EnablePaidFeatures = () => {
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Error validating license"); toast.error("Error validating license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
}); });
}; };
@@ -102,8 +109,12 @@ export const EnablePaidFeatures = () => {
className="w-full" className="w-full"
/> />
</div> </div>
<Button onClick={handleValidateLicense} variant="secondary"> <Button
Validate onClick={handleValidateLicense}
variant="secondary"
disabled={isLoading}
>
{isLoading ? "Validating..." : "Validate"}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -148,21 +148,21 @@ 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 isValid = await validateLicense( const result = await validateLicense(
input.licenseKey, input.licenseKey,
owner?.serverIp || "", owner?.serverIp || "",
); );
if (!isValid) { if (!result.isValid) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid license key", message: result.error,
}); });
} }
await updateUser(ctx.user.id, { await updateUser(ctx.user.id, {
licenseKey: input.licenseKey, licenseKey: input.licenseKey,
}); });
return isValid; return result;
}), }),
getUserByToken: publicProcedure getUserByToken: publicProcedure
.input(apiFindOneToken) .input(apiFindOneToken)

View File

@@ -1,9 +1,6 @@
const licensesUrl = process.env.LICENSES_URL || "http://localhost:4002"; const licensesUrl = process.env.LICENSES_URL || "http://localhost:4002";
export const validateLicense = async ( export const validateLicense = async (licenseKey: string, serverIp: string) => {
licenseKey: string,
serverIp: string,
): Promise<boolean> => {
const response = await fetch(`${licensesUrl}/api/validate`, { const response = await fetch(`${licensesUrl}/api/validate`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -17,5 +14,5 @@ export const validateLicense = async (
console.log("Validation errors:", data.error.issues); console.log("Validation errors:", data.error.issues);
} }
return response.ok; return data;
}; };

View File

@@ -1,22 +0,0 @@
CREATE TYPE "public"."billing_type" AS ENUM('monthly', 'annual');--> statement-breakpoint
CREATE TYPE "public"."license_status" AS ENUM('active', 'expired', 'cancelled', 'payment_pending');--> 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,
"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,
"stripeCustomerId" text NOT NULL,
"stripeSubscriptionId" text 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

@@ -1,171 +0,0 @@
{
"id": "5a996744-b11f-4f1a-b4b0-91f6bf5c2bed",
"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()"
},
"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
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stripeSubscriptionId": {
"name": "stripeSubscriptionId",
"type": "text",
"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",
"payment_pending"
]
},
"public.license_type": {
"name": "license_type",
"schema": "public",
"values": [
"basic",
"premium",
"business"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

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

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"generate": "drizzle-kit generate", "generate": "drizzle-kit generate",
"drop": "drizzle-kit drop", "drop": "drizzle-kit drop",
"push": "drizzle-kit push",
"migrate": "tsx ./migrate.ts", "migrate": "tsx ./migrate.ts",
"truncate": "tsx ./truncate.ts", "truncate": "tsx ./truncate.ts",
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts", "reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",

View File

@@ -58,13 +58,12 @@ router.get("/health", async (c) => {
router.post("/validate", zValidator("json", validateSchema), async (c) => { router.post("/validate", zValidator("json", validateSchema), async (c) => {
const { licenseKey, serverIp } = c.req.valid("json"); const { licenseKey, serverIp } = c.req.valid("json");
console.log("Validating license", licenseKey, serverIp);
try { try {
const result = await validateLicense(licenseKey, serverIp); const result = await validateLicense(licenseKey, serverIp);
console.log("Result", result);
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({ isValid: false, error: "Error validating license" }, 500);
} }
}); });
@@ -125,12 +124,7 @@ router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
customerName: license.email, customerName: license.email,
}), }),
); );
// await transporter.sendMail({
// from: fromAddress,
// to: toAddresses.join(", "),
// subject,
// html: htmlContent,
// });
await transporter.sendMail({ await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS, from: process.env.SMTP_FROM_ADDRESS,
to: license.email, to: license.email,
@@ -199,7 +193,7 @@ router.post("/stripe/webhook", async (c) => {
billingType, billingType,
email: session.customer_details?.email!, email: session.customer_details?.email!,
stripeCustomerId: customerResponse.id, stripeCustomerId: customerResponse.id,
stripeSubscriptionId: session.id, stripeSubscriptionId: session.subscription as string,
}); });
console.log("License created", license); console.log("License created", license);

View File

@@ -60,6 +60,8 @@ export const validateLicense = async (
license.stripeSubscriptionId, license.stripeSubscriptionId,
); );
console.log("Suscription", suscription);
if (suscription.status !== "active") { if (suscription.status !== "active") {
return { return {
isValid: false, isValid: false,