Skip to content

Commit 38265fd

Browse files
authored
Merge pull request #3600 from Dokploy/feat/introduce-license-key-pay
Feat/introduce license key pay
2 parents 1e7522d + ca2efc5 commit 38265fd

44 files changed

Lines changed: 12678 additions & 1059 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"zod": "^3.25.32"
2424
},
2525
"devDependencies": {
26-
"@types/node": "^20.17.51",
26+
"@types/node": "^20.16.0",
2727
"@types/react": "^18.2.37",
2828
"@types/react-dom": "^18.2.15",
2929
"tsx": "^4.16.2",

apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ type MockCreateServiceOptions = {
1313

1414
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
1515
vi.hoisted(() => {
16-
const inspect = vi.fn<[], Promise<never>>();
16+
const inspect = vi.fn<() => Promise<never>>();
1717
const getService = vi.fn(() => ({ inspect }));
18-
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
19-
async () => undefined,
20-
);
18+
const createService = vi.fn<
19+
(opts: MockCreateServiceOptions) => Promise<void>
20+
>(async () => undefined);
2121
const getRemoteDocker = vi.fn(async () => ({
2222
getService,
2323
createService,
@@ -80,7 +80,9 @@ describe("mechanizeDockerContainer", () => {
8080
await mechanizeDockerContainer(application);
8181

8282
expect(createServiceMock).toHaveBeenCalledTimes(1);
83-
const call = createServiceMock.mock.calls[0];
83+
const call = createServiceMock.mock.calls[0] as
84+
| [MockCreateServiceOptions]
85+
| undefined;
8486
if (!call) {
8587
throw new Error("createServiceMock should have been called once");
8688
}
@@ -97,7 +99,9 @@ describe("mechanizeDockerContainer", () => {
9799
await mechanizeDockerContainer(application);
98100

99101
expect(createServiceMock).toHaveBeenCalledTimes(1);
100-
const call = createServiceMock.mock.calls[0];
102+
const call = createServiceMock.mock.calls[0] as
103+
| [MockCreateServiceOptions]
104+
| undefined;
101105
if (!call) {
102106
throw new Error("createServiceMock should have been called once");
103107
}

apps/dokploy/__test__/setup.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { vi } from "vitest";
2+
3+
/**
4+
* Mock the DB module so tests that import from @dokploy/server (barrel)
5+
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
6+
* Without this, loading the server barrel pulls in lib/auth and db, which
7+
* connect to localhost:5432 and cause ECONNREFUSED.
8+
*/
9+
vi.mock("@dokploy/server/db", () => {
10+
const chain = () => chain;
11+
chain.set = () => chain;
12+
chain.where = () => chain;
13+
chain.values = () => chain;
14+
chain.returning = () => Promise.resolve([{}]);
15+
chain.then = undefined;
16+
17+
const tableMock = {
18+
findFirst: vi.fn(() => Promise.resolve(undefined)),
19+
findMany: vi.fn(() => Promise.resolve([])),
20+
insert: vi.fn(() => Promise.resolve([{}])),
21+
update: vi.fn(() => chain),
22+
delete: vi.fn(() => chain),
23+
};
24+
const createQueryMock = () => tableMock;
25+
26+
return {
27+
db: {
28+
select: vi.fn(() => chain),
29+
insert: vi.fn(() => ({
30+
values: () => ({ returning: () => Promise.resolve([{}]) }),
31+
})),
32+
update: vi.fn(() => chain),
33+
delete: vi.fn(() => chain),
34+
query: new Proxy({} as Record<string, typeof tableMock>, {
35+
get: () => tableMock,
36+
}),
37+
},
38+
dbUrl: "postgres://mock:mock@localhost:5432/mock",
39+
};
40+
});

apps/dokploy/__test__/vitest.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ export default defineConfig({
77
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
88
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
99
pool: "forks",
10+
setupFiles: [path.resolve(__dirname, "setup.ts")],
1011
},
1112
define: {
1213
"process.env": {
1314
NODE: "test",
15+
GITHUB_CLIENT_ID: "test",
16+
GITHUB_CLIENT_SECRET: "test",
17+
GOOGLE_CLIENT_ID: "test",
18+
GOOGLE_CLIENT_SECRET: "test",
1419
},
1520
},
1621
plugins: [

apps/dokploy/components/layouts/side.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
Forward,
1919
GalleryVerticalEnd,
2020
GitBranch,
21+
Key,
2122
KeyRound,
2223
Loader2,
24+
LogIn,
2325
type LucideIcon,
2426
Package,
2527
PieChart,
@@ -396,6 +398,24 @@ const MENU: Menu = {
396398
// Only enabled for admins in cloud environments
397399
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
398400
},
401+
{
402+
isSingle: true,
403+
title: "License",
404+
url: "/dashboard/settings/license",
405+
icon: Key,
406+
// Only enabled for admins in non-cloud environments
407+
isEnabled: ({ auth }) =>
408+
!!(auth?.role === "owner" || auth?.role === "admin"),
409+
},
410+
{
411+
isSingle: true,
412+
title: "SSO",
413+
url: "/dashboard/settings/sso",
414+
icon: LogIn,
415+
// Enabled for admins in both cloud and self-hosted (enterprise)
416+
isEnabled: ({ auth }) =>
417+
!!(auth?.role === "owner" || auth?.role === "admin"),
418+
},
399419
],
400420

401421
help: [
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { toast } from "sonner";
5+
import { authClient } from "@/lib/auth-client";
6+
import { Button } from "@/components/ui/button";
7+
8+
export function SignInWithGithub() {
9+
const [isLoading, setIsLoading] = useState(false);
10+
11+
const handleClick = async () => {
12+
setIsLoading(true);
13+
try {
14+
const { error } = await authClient.signIn.social({
15+
provider: "github",
16+
});
17+
if (error) {
18+
toast.error(error.message);
19+
return;
20+
}
21+
} catch (err) {
22+
toast.error("An error occurred while signing in with GitHub", {
23+
description: err instanceof Error ? err.message : "Unknown error",
24+
});
25+
} finally {
26+
setIsLoading(false);
27+
}
28+
};
29+
30+
return (
31+
<Button
32+
variant="outline"
33+
type="button"
34+
className="w-full mb-4"
35+
onClick={handleClick}
36+
isLoading={isLoading}
37+
>
38+
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
39+
<path
40+
fill="currentColor"
41+
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
42+
/>
43+
</svg>
44+
Sign in with GitHub
45+
</Button>
46+
);
47+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { toast } from "sonner";
5+
import { authClient } from "@/lib/auth-client";
6+
import { Button } from "@/components/ui/button";
7+
8+
export function SignInWithGoogle() {
9+
const [isLoading, setIsLoading] = useState(false);
10+
11+
const handleClick = async () => {
12+
setIsLoading(true);
13+
try {
14+
const { error } = await authClient.signIn.social({
15+
provider: "google",
16+
});
17+
if (error) {
18+
toast.error(error.message);
19+
return;
20+
}
21+
} catch (err) {
22+
toast.error("An error occurred while signing in with Google", {
23+
description: err instanceof Error ? err.message : "Unknown error",
24+
});
25+
} finally {
26+
setIsLoading(false);
27+
}
28+
};
29+
30+
return (
31+
<Button
32+
variant="outline"
33+
type="button"
34+
className="w-full mb-4"
35+
onClick={handleClick}
36+
isLoading={isLoading}
37+
>
38+
<svg viewBox="0 0 24 24" className="mr-2 size-4">
39+
<path
40+
fill="currentColor"
41+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
42+
/>
43+
<path
44+
fill="currentColor"
45+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
46+
/>
47+
<path
48+
fill="currentColor"
49+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
50+
/>
51+
<path
52+
fill="currentColor"
53+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
54+
/>
55+
</svg>
56+
Sign in with Google
57+
</Button>
58+
);
59+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client";
2+
3+
import { Loader2, Lock } from "lucide-react";
4+
import Link from "next/link";
5+
import { Button } from "@/components/ui/button";
6+
import {
7+
Card,
8+
CardContent,
9+
CardDescription,
10+
CardHeader,
11+
CardTitle,
12+
} from "@/components/ui/card";
13+
import { api } from "@/utils/api";
14+
15+
interface EnterpriseFeatureLockedProps {
16+
/** Optional title override */
17+
title?: string;
18+
/** Optional description override */
19+
description?: string;
20+
/** Optional custom CTA label */
21+
ctaLabel?: string;
22+
/** Optional CTA href (default: /dashboard/settings/license) */
23+
ctaHref?: string;
24+
/** Compact variant (less padding, smaller icon) */
25+
compact?: boolean;
26+
}
27+
28+
/**
29+
* Displays a locked state for enterprise features when the user has no valid license.
30+
* Use standalone or via EnterpriseFeatureGate.
31+
*/
32+
export function EnterpriseFeatureLocked({
33+
title = "Enterprise feature",
34+
description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.",
35+
ctaLabel = "Go to License",
36+
ctaHref = "/dashboard/settings/license",
37+
compact = false,
38+
}: EnterpriseFeatureLockedProps) {
39+
return (
40+
<Card className="border-dashed bg-transparent">
41+
<CardHeader className={compact ? "pb-2" : undefined}>
42+
<div className="flex flex-col items-center gap-3 text-center">
43+
<div
44+
className={
45+
compact
46+
? "rounded-full bg-muted p-3"
47+
: "rounded-full bg-muted p-4"
48+
}
49+
>
50+
<Lock
51+
className={
52+
compact
53+
? "size-6 text-muted-foreground"
54+
: "size-8 text-muted-foreground"
55+
}
56+
/>
57+
</div>
58+
<div className="space-y-1">
59+
<CardTitle className="text-lg">{title}</CardTitle>
60+
<CardDescription className="max-w-sm mx-auto">
61+
{description}
62+
</CardDescription>
63+
</div>
64+
</div>
65+
</CardHeader>
66+
<CardContent className={compact ? "pt-0" : undefined}>
67+
<div className="flex justify-center">
68+
<Button asChild variant="secondary" size={compact ? "sm" : "default"}>
69+
<Link href={ctaHref}>{ctaLabel}</Link>
70+
</Button>
71+
</div>
72+
</CardContent>
73+
</Card>
74+
);
75+
}
76+
77+
interface EnterpriseFeatureGateProps {
78+
children: React.ReactNode;
79+
/** Props for the locked state when license is invalid */
80+
lockedProps?: Omit<EnterpriseFeatureLockedProps, "compact">;
81+
/** Show loading spinner while checking license */
82+
fallback?: React.ReactNode;
83+
}
84+
85+
/**
86+
* Renders children only when the instance has a valid enterprise license.
87+
* Otherwise shows EnterpriseFeatureLocked.
88+
*/
89+
export function EnterpriseFeatureGate({
90+
children,
91+
lockedProps,
92+
fallback,
93+
}: EnterpriseFeatureGateProps) {
94+
const { data: haveValidLicense, isLoading } =
95+
api.licenseKey.haveValidLicenseKey.useQuery();
96+
97+
if (isLoading) {
98+
if (fallback) return <>{fallback}</>;
99+
return (
100+
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
101+
<Loader2 className="size-6 text-muted-foreground animate-spin" />
102+
<span className="text-sm text-muted-foreground">
103+
Checking license...
104+
</span>
105+
</div>
106+
);
107+
}
108+
109+
if (!haveValidLicense) {
110+
return <EnterpriseFeatureLocked {...lockedProps} />;
111+
}
112+
113+
return <>{children}</>;
114+
}

0 commit comments

Comments
 (0)