feat: add api to handle license api keys

This commit is contained in:
Mauricio Siu
2024-08-25 20:10:20 -06:00
parent 14e8e14b7d
commit e0a9eb0366
19 changed files with 3500 additions and 8 deletions

2
apps/api/.env.example Normal file
View File

@@ -0,0 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""

28
apps/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

8
apps/api/README.md Normal file
View File

@@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

15
apps/api/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "my-app",
"scripts": {
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.12.1",
"hono": "^4.5.8",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
}
}

66
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,66 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { config } from "dotenv";
import { validateLemonSqueezyLicense } from "./utils";
import { cors } from "hono/cors";
config();
const app = new Hono();
app.use(
"/*",
cors({
origin: ["http://localhost:3000", "http://localhost:3001"], // Ajusta esto a los orígenes de tu aplicación Next.js
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
maxAge: 600,
credentials: true,
}),
);
export const LEMON_SQUEEZY_API_KEY = process.env.LEMON_SQUEEZY_API_KEY;
export const LEMON_SQUEEZY_STORE_ID = process.env.LEMON_SQUEEZY_STORE_ID;
app.get("/v1/health", (c) => {
return c.text("Hello Hono!");
});
app.post("/v1/validate-license", async (c) => {
const { licenseKey } = await c.req.json();
if (!licenseKey) {
return c.json({ error: "License key is required" }, 400);
}
try {
const licenseValidation = await validateLemonSqueezyLicense(licenseKey);
if (licenseValidation.valid) {
return c.json({
valid: true,
message: "License is valid",
metadata: licenseValidation.meta,
});
}
return c.json(
{
valid: false,
message: licenseValidation.error || "Invalid license",
},
400,
);
} catch (error) {
console.error("Error during license validation:", error);
return c.json({ error: "Internal server error" }, 500);
}
});
const port = 4000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port,
});

16
apps/api/src/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface LemonSqueezyLicenseResponse {
valid: boolean;
error?: string;
meta?: {
store_id: string;
order_id: number;
order_item_id: number;
product_id: number;
product_name: string;
variant_id: number;
variant_name: string;
customer_id: number;
customer_name: string;
customer_email: string;
};
}

28
apps/api/src/utils.ts Normal file
View File

@@ -0,0 +1,28 @@
import { LEMON_SQUEEZY_API_KEY, LEMON_SQUEEZY_STORE_ID } from ".";
import type { LemonSqueezyLicenseResponse } from "./types";
export const validateLemonSqueezyLicense = async (
licenseKey: string,
): Promise<LemonSqueezyLicenseResponse> => {
try {
const response = await fetch(
"https://api.lemonsqueezy.com/v1/licenses/validate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": LEMON_SQUEEZY_API_KEY as string,
},
body: JSON.stringify({
license_key: licenseKey,
store_id: LEMON_SQUEEZY_STORE_ID as string,
}),
},
);
return response.json();
} catch (error) {
console.error("Error validating license:", error);
return { valid: false, error: "Error validating license" };
}
};

14
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
}
}

View File

@@ -77,6 +77,12 @@ export const SettingsLayout = ({ children }: Props) => {
icon: Bell,
href: "/dashboard/settings/notifications",
},
{
title: "License",
label: "",
icon: KeyIcon,
href: "/dashboard/settings/license",
},
]
: []),
...(user?.canAccessToSSHKeys
@@ -102,6 +108,7 @@ import {
Activity,
Bell,
Database,
KeyIcon,
KeyRound,
type LucideIcon,
Route,

View File

@@ -0,0 +1 @@
ALTER TABLE "admin" ADD COLUMN "licenseKey" text;

File diff suppressed because it is too large Load Diff

View File

@@ -239,6 +239,13 @@
"when": 1724555040199,
"tag": "0033_sweet_black_bird",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1724631497207,
"tag": "0034_silent_slayback",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,145 @@
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
Form,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { validateRequest } from "@/server/auth/auth";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useEffect, type ReactElement } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const schema = z.object({
licenseKey: z.string().min(1, {
message: "License key is required",
}),
});
type Schema = z.infer<typeof schema>;
export default function License() {
const { data } = api.admin.one.useQuery();
const { mutateAsync, isLoading } = api.license.setLicense.useMutation();
const form = useForm<Schema>({
defaultValues: {
licenseKey: data?.licenseKey || "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (data?.licenseKey) {
form.reset({
licenseKey: data.licenseKey,
});
}
}, [data]);
const onSubmit = async (data: Schema) => {
await mutateAsync(data.licenseKey)
.then(async () => {
toast.success("License Key Saved");
})
.catch(() => {
toast.error("Error to save the license key");
});
};
return (
<>
<div className="w-full">
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="text-xl">License</CardTitle>
<CardDescription className="flex flex-row gap-2">
Set your license key to unlock the features
<Link
target="_blank"
href="https://dokploy.com/pricing"
className="text-primary text-md"
>
See pricing
</Link>
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="licenseKey"
render={({ field }) => {
return (
<FormItem>
<FormLabel>License Key</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={"Enter your license key"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</>
);
}
License.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user || user.rol === "user") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@@ -25,6 +25,7 @@ import { securityRouter } from "./routers/security";
import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key";
import { userRouter } from "./routers/user";
import { licenseRouter } from "./routers/license";
/**
* This is the primary router for your server.
@@ -58,6 +59,7 @@ export const appRouter = createTRPCRouter({
cluster: clusterRouter,
notification: notificationRouter,
sshKey: sshRouter,
license: licenseRouter,
});
// export type definition of API

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import { findAdmin, updateAdmin } from "../services/admin";
import { adminProcedure, createTRPCRouter } from "../trpc";
import { TRPCError } from "@trpc/server";
export const licenseRouter = createTRPCRouter({
setLicense: adminProcedure.input(z.string()).mutation(async ({ input }) => {
const admin = await findAdmin();
try {
const result = await fetch("http://127.0.0.1:4000/v1/validate-license", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
licenseKey: input,
}),
});
const data = await result.json();
if (!data.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "License is invalid",
});
}
if (data.valid) {
return await updateAdmin(admin.authId, {
licenseKey: input,
});
}
} catch (err) {
return await updateAdmin(admin.authId, {
licenseKey: "",
});
}
}),
});

View File

@@ -53,11 +53,6 @@ import { canAccessToTraefikFiles } from "../services/user";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { parseRawConfig, processLogs } from "@/server/utils/access-log/utils";
import { logRotationManager } from "@/server/utils/access-log/handler";
import { observable } from "@trpc/server/observable";
import type { LogEntry } from "@/server/utils/access-log/types";
import EventEmitter from "node:events";
const ee = new EventEmitter();
export const settingsRouter = createTRPCRouter({
reloadServer: adminProcedure.mutation(async () => {

View File

@@ -28,6 +28,7 @@ export const admins = pgTable("admin", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
licenseKey: text("licenseKey"),
authId: text("authId")
.notNull()
.references(() => auth.id, { onDelete: "cascade" }),