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" }),

37
pnpm-lock.yaml generated
View File

@@ -36,6 +36,25 @@ importers:
specifier: 4.16.2
version: 4.16.2
apps/api:
dependencies:
'@hono/node-server':
specifier: ^1.12.1
version: 1.12.1
dotenv:
specifier: ^16.3.1
version: 16.4.5
hono:
specifier: ^4.5.8
version: 4.5.8
devDependencies:
'@types/node':
specifier: ^20.11.17
version: 20.14.10
tsx:
specifier: ^4.7.1
version: 4.16.2
apps/docs:
dependencies:
fumadocs-core:
@@ -1662,6 +1681,10 @@ packages:
peerDependencies:
tailwindcss: ^3.0
'@hono/node-server@1.12.1':
resolution: {integrity: sha512-C9l+08O8xtXB7Ppmy8DjBFH1hYji7JKzsU32Yt1poIIbdPp6S7aOI8IldDHD9YFJ55lv2c21ovNrmxatlHfhAg==}
engines: {node: '>=18.14.1'}
'@hookform/resolvers@3.9.0':
resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==}
peerDependencies:
@@ -5664,6 +5687,10 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hono@4.5.8:
resolution: {integrity: sha512-pqpSlcdqGkpTTRpLYU1PnCz52gVr0zVR9H5GzMyJWuKQLLEBQxh96q45QizJ2PPX8NATtz2mu31/PKW/Jt+90Q==}
engines: {node: '>=16.0.0'}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -9670,6 +9697,8 @@ snapshots:
dependencies:
tailwindcss: 3.4.7
'@hono/node-server@1.12.1': {}
'@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.2.0))':
dependencies:
react-hook-form: 7.52.1(react@18.2.0)
@@ -14053,7 +14082,7 @@ snapshots:
eslint: 8.45.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0)
eslint-plugin-jsx-a11y: 6.9.0(eslint@8.45.0)
eslint-plugin-react: 7.35.0(eslint@8.45.0)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.45.0)
@@ -14077,7 +14106,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.45.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.5
is-core-module: 2.15.0
@@ -14099,7 +14128,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
@@ -14806,6 +14835,8 @@ snapshots:
highlight.js@10.7.3: {}
hono@4.5.8: {}
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0

View File

@@ -2,3 +2,4 @@ packages:
- "apps/dokploy"
- "apps/docs"
- "apps/website"
- "apps/api"