Merge pull request #3682 from Dokploy/canary

🚀 Release v0.27.1
This commit is contained in:
Mauricio Siu
2026-02-18 01:54:44 -06:00
committed by GitHub
99 changed files with 19540 additions and 3863 deletions

21
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Dockerfile for DevContainer
FROM node:20.16.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -0,0 +1,53 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

22
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

5
.gitignore vendored
View File

@@ -43,7 +43,4 @@ yarn-error.log*
*.pem
.db
# Development environment
.devcontainer
.db

View File

@@ -20,7 +20,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.32"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore
...actual,
paths: () => ({
APPLICATIONS_PATH: "./__test__/drop/zips/output",
// @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
}),
};
});
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
ulimitsSwarm: null,
};
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths();
beforeAll(async () => {
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) {
console.log(err);
} finally {
}
});

View File

@@ -0,0 +1 @@
/etc/passwd

View File

@@ -275,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "тест.рф" },
"web",
);
// тест.рф in punycode is xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});
test("ASCII domain remains unchanged", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "example.com" },
"web",
);
expect(router.rule).toContain("Host(`example.com`)");
});
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "сайт.ru" },
"web",
);
// сайт in punycode is xn--80aswg
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
expect(router.rule).not.toContain("сайт");
});
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "app.тест.рф" },
"web",
);
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});

View File

@@ -0,0 +1,81 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const BASE = "/base";
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: () => ({
...actual.paths(),
BASE_PATH: BASE,
LOGS_PATH: `${BASE}/logs`,
APPLICATIONS_PATH: `${BASE}/applications`,
}),
};
});
// Import after mock so paths() uses our BASE
const { readValidDirectory } = await import("@dokploy/server");
describe("readValidDirectory (path traversal)", () => {
it("returns true when directory is exactly BASE_PATH", () => {
expect(readValidDirectory(BASE)).toBe(true);
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
});
it("returns true when directory is under BASE_PATH", () => {
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
});
it("returns false for path traversal escaping base (absolute)", () => {
expect(readValidDirectory("/etc/passwd")).toBe(false);
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
expect(readValidDirectory("/tmp/outside")).toBe(false);
});
it("returns false when resolved path escapes base via ..", () => {
// Resolved: /etc/passwd (outside /base)
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
});
it("returns true when .. stays within base", () => {
// e.g. /base/logs/../applications -> /base/applications (still under /base)
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
});
it("accepts serverId for remote base path", () => {
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
expect(readValidDirectory(BASE, "server-1")).toBe(true);
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
});
it("returns false for null/undefined-like paths that resolve outside", () => {
// Paths that might resolve to cwd or root
expect(readValidDirectory(".")).toBe(false);
expect(readValidDirectory("..")).toBe(false);
});
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
expect(readValidDirectory(`${BASE}/`)).toBe(true);
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
});
it("returns false when path looks like base but is a sibling or prefix", () => {
expect(readValidDirectory("/base-evil")).toBe(false);
expect(readValidDirectory("/bas")).toBe(false);
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
});
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "../../server/wss/utils";
describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
expect(isValidTail("1")).toBe(true);
expect(isValidTail("100")).toBe(true);
expect(isValidTail("10000")).toBe(true);
});
it("rejects tail above 10000", () => {
expect(isValidTail("10001")).toBe(false);
expect(isValidTail("99999")).toBe(false);
});
it("rejects non-numeric tail", () => {
expect(isValidTail("")).toBe(false);
expect(isValidTail("abc")).toBe(false);
expect(isValidTail("10a")).toBe(false);
expect(isValidTail("-1")).toBe(false);
});
it("rejects command injection payloads in tail", () => {
expect(isValidTail("10; whoami; #")).toBe(false);
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
expect(isValidTail("$(id)")).toBe(false);
expect(isValidTail("`id`")).toBe(false);
expect(isValidTail("100\nid")).toBe(false);
expect(isValidTail("100 && id")).toBe(false);
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
});
});
describe("isValidSince (docker-container-logs)", () => {
it("accepts 'all'", () => {
expect(isValidSince("all")).toBe(true);
});
it("accepts valid duration format (number + s|m|h|d)", () => {
expect(isValidSince("5s")).toBe(true);
expect(isValidSince("10m")).toBe(true);
expect(isValidSince("1h")).toBe(true);
expect(isValidSince("2d")).toBe(true);
expect(isValidSince("0s")).toBe(true);
expect(isValidSince("999d")).toBe(true);
});
it("rejects invalid duration format", () => {
expect(isValidSince("")).toBe(false);
expect(isValidSince("5")).toBe(false);
expect(isValidSince("s")).toBe(false);
expect(isValidSince("5x")).toBe(false);
expect(isValidSince("5sec")).toBe(false);
expect(isValidSince("5 m")).toBe(false);
});
it("rejects command injection payloads in since", () => {
expect(isValidSince("5s; whoami")).toBe(false);
expect(isValidSince("all; id")).toBe(false);
expect(isValidSince("1m$(id)")).toBe(false);
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
});
});
describe("isValidSearch (docker-container-logs)", () => {
it("accepts empty string", () => {
expect(isValidSearch("")).toBe(true);
});
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
expect(isValidSearch("error")).toBe(true);
expect(isValidSearch("foo bar")).toBe(true);
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
expect(isValidSearch("")).toBe(true);
});
it("rejects strings longer than 500 chars", () => {
expect(isValidSearch("a".repeat(501))).toBe(false);
expect(isValidSearch("a".repeat(500))).toBe(true);
});
it("rejects control characters and non-printable", () => {
expect(isValidSearch("foo\nbar")).toBe(false);
expect(isValidSearch("foo\rbar")).toBe(false);
expect(isValidSearch("\x00")).toBe(false);
expect(isValidSearch("a\x19b")).toBe(false);
});
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
// Double-quoted context (SSH line 99): $ and ` execute
expect(isValidSearch("$(whoami)")).toBe(false);
expect(isValidSearch("`id`")).toBe(false);
expect(isValidSearch("$(id)")).toBe(false);
// Single-quoted context (local line 153): ' breaks out
expect(isValidSearch("'$(whoami)'")).toBe(false);
expect(isValidSearch("error'")).toBe(false);
expect(isValidSearch("'; whoami; #")).toBe(false);
// Other shell-metacharacters
expect(isValidSearch("error; id")).toBe(false);
expect(isValidSearch("a|b")).toBe(false);
expect(isValidSearch('error"')).toBe(false);
expect(isValidSearch("a&b")).toBe(false);
});
});
describe("isValidContainerId (docker-container-logs)", () => {
it("accepts valid hex container IDs", () => {
expect(isValidContainerId("a".repeat(12))).toBe(true);
expect(isValidContainerId("abc123def456")).toBe(true);
expect(isValidContainerId("a".repeat(64))).toBe(true);
});
it("accepts valid container names", () => {
expect(isValidContainerId("my-container")).toBe(true);
expect(isValidContainerId("app_1")).toBe(true);
expect(isValidContainerId("service.name")).toBe(true);
});
it("rejects command injection in container ID", () => {
expect(isValidContainerId("dummy; whoami")).toBe(false);
expect(isValidContainerId("$(id)")).toBe(false);
expect(isValidContainerId("`id`")).toBe(false);
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
});
});

View File

@@ -105,7 +105,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
const modeData =
formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } }
? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
: { Global: {} };
await mutateAsync({

View File

@@ -1,4 +1,4 @@
import { Paintbrush } from "lucide-react";
import { Ban } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -0,0 +1,73 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only
the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async () => {
toast.success("Old deployments cleared successfully");
await utils.deployment.allByType.invalidate({
id,
type: type as "application" | "compose",
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -6,6 +6,7 @@ import {
RefreshCcw,
RocketIcon,
Settings,
Trash2,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -25,6 +26,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -77,6 +79,8 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
const {
@@ -144,6 +148,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
@@ -252,6 +259,8 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
return (
<div
@@ -370,6 +379,33 @@ export const ShowDeployments = ({
View
</Button>
{canDelete && (
<DialogAction
title="Delete Deployment"
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
});
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
>
Delete
<Trash2 className="size-4" />
</Button>
</DialogAction>
)}
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (

View File

@@ -18,6 +18,7 @@ import {
PushoverIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
@@ -164,6 +165,12 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("teams"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -183,6 +190,10 @@ export const notificationsMap = {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
teams: {
icon: <TeamsIcon className="text-muted-foreground" />,
label: "Microsoft Teams",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
@@ -244,6 +255,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
api.notification.testTeamsConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
@@ -278,6 +291,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const teamsMutation = notificationId
? api.notification.updateTeams.useMutation()
: api.notification.createTeams.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
@@ -353,7 +369,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration || undefined,
decoration: notification.discord?.decoration ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -400,7 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
decoration: notification.gotify?.decoration ?? undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
@@ -435,6 +451,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "teams") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
@@ -488,6 +517,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
teams: teamsMutation,
custom: customMutation,
pushover: pushoverMutation,
};
@@ -630,6 +660,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "teams") {
promise = teamsMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
teamsId: notification?.teamsId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
@@ -1465,6 +1509,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "teams" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://xxx.webhook.office.com/webhookb2/..."
{...field}
/>
</FormControl>
<FormDescription>
Incoming Webhook URL from a Teams channel. Add an
Incoming Webhook in your channel settings to get the
URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "pushover" && (
<>
<FormField
@@ -1780,6 +1850,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingTeams ||
isLoadingCustom ||
isLoadingPushover
}
@@ -1841,6 +1912,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "teams") {
await testTeamsConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0

View File

@@ -7,6 +7,7 @@ import {
NtfyIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -37,7 +38,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Resend, Lark.
Telegram, Teams, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -112,6 +113,11 @@ export const ShowNotifications = () => {
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.notificationType === "teams" && (
<div className="flex items-center justify-center rounded-lg">
<TeamsIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -73,7 +72,6 @@ export const ProfileForm = () => {
isError,
error,
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -157,10 +155,10 @@ export const ProfileForm = () => {
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<User className="size-6 text-muted-foreground self-center" />
{t("settings.profile.title")}
Account
</CardTitle>
<CardDescription>
{t("settings.profile.description")}
Change the details of your profile here.
</CardDescription>
</div>
@@ -213,12 +211,9 @@ export const ProfileForm = () => {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
<Input placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -233,7 +228,7 @@ export const ProfileForm = () => {
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
placeholder="Current Password"
{...field}
value={field.value || ""}
/>
@@ -247,13 +242,11 @@ export const ProfileForm = () => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.password")}
</FormLabel>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
placeholder="Password"
{...field}
value={field.value || ""}
/>
@@ -268,9 +261,7 @@ export const ProfileForm = () => {
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.avatar")}
</FormLabel>
<FormLabel>Avatar</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
@@ -454,7 +445,7 @@ export const ProfileForm = () => {
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
{t("settings.common.save")}
Save
</Button>
</div>
</form>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
import { Button } from "@/components/ui/button";
@@ -17,7 +16,6 @@ import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation();
@@ -30,13 +28,11 @@ export const ShowDokployActions = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Button isLoading={isLoading} variant="outline">
{t("settings.server.webServer.server.label")}
Server
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -51,17 +47,17 @@ export const ShowDokployActions = () => {
}}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.reload")}</span>
<span>Reload</span>
</DropdownMenuItem>
<TerminalModal serverId="local">
<span>{t("settings.common.enterTerminal")}</span>
<span>Terminal</span>
</TerminalModal>
<ShowModalLogs appName="dokploy">
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
{t("settings.server.webServer.watchLogs")}
View Logs
</DropdownMenuItem>
</ShowModalLogs>
<GPUSupportModal />
@@ -70,7 +66,7 @@ export const ShowDokployActions = () => {
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
{t("settings.server.webServer.updateServerIp")}
Update Server IP
</DropdownMenuItem>
</UpdateServerIp>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -16,7 +15,6 @@ interface Props {
serverId?: string;
}
export const ShowStorageActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
@@ -64,13 +62,11 @@ export const ShowStorageActions = ({ serverId }: Props) => {
}
variant="outline"
>
{t("settings.server.webServer.storage.label")}
Space
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -87,9 +83,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanUnusedImages")}
</span>
<span>Clean unused images</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -105,9 +99,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
</span>
<span>Clean unused volumes</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -124,9 +116,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanStoppedContainers")}
</span>
<span>Clean stopped containers</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -143,9 +133,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanDockerBuilder")}
</span>
<span>Clean Docker Builder & System</span>
</DropdownMenuItem>
{!serverId && (
<DropdownMenuItem
@@ -160,9 +148,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanMonitoring")}
</span>
<span>Clean Monitoring</span>
</DropdownMenuItem>
)}
@@ -180,7 +166,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
<span>Clean all</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -22,7 +21,6 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
@@ -75,13 +73,11 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
}
variant="outline"
>
{t("settings.server.webServer.traefik.label")}
Traefik
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -100,7 +96,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>{t("settings.server.webServer.reload")}</span>
<span>Reload</span>
</DropdownMenuItem>
<ShowModalLogs
appName="dokploy-traefik"
@@ -111,7 +107,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
{t("settings.server.webServer.watchLogs")}
View Logs
</DropdownMenuItem>
</ShowModalLogs>
<EditTraefikEnv serverId={serverId}>
@@ -119,7 +115,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
<span>Modify Environment</span>
</DropdownMenuItem>
</EditTraefikEnv>
@@ -176,7 +172,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
<span>Additional Port Mappings</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup>

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -63,8 +62,6 @@ interface Props {
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -365,7 +362,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
@@ -379,7 +376,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
placeholder="22"
@@ -409,7 +406,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>

View File

@@ -13,7 +13,6 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -52,7 +51,6 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
const { data, refetch, isLoading } = api.server.all.useQuery();

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -66,7 +65,6 @@ const addServerDomain = z
type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -119,10 +117,10 @@ export const WebDomain = () => {
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex flex-row gap-2">
<GlobeIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.domain.title")}
Server Domain
</CardTitle>
<CardDescription>
{t("settings.server.domain.description")}
Add a domain to your server application.
</CardDescription>
</div>
</CardHeader>
@@ -151,9 +149,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -173,9 +169,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -216,32 +210,20 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
@@ -254,7 +236,7 @@ export const WebDomain = () => {
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}
Save
</Button>
</div>
</form>

View File

@@ -1,5 +1,4 @@
import { ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import {
Card,
CardContent,
@@ -15,7 +14,6 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
@@ -29,18 +27,16 @@ export const WebServer = () => {
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.webServer.title")}
Web Server
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
<CardDescription>Reload or clean the web server.</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
{t("settings.server.webServer.title")}
Web Server
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
Reload or clean the web server.
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -52,8 +51,6 @@ interface Props {
}
const LocalServerConfig = ({ onSave }: Props) => {
const { t } = useTranslation("settings");
const form = useForm<Schema>({
defaultValues: getLocalServerData(),
resolver: zodResolver(Schema),
@@ -77,9 +74,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-row gap-2 items-center">
<Settings className="h-4 w-4" />
<span className="dark:hover:text-white">
{t("settings.terminal.connectionSettings")}
</span>
<span className="dark:hover:text-white">Connection settings</span>
</div>
</div>
</AccordionTrigger>
@@ -96,7 +91,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
{...field}
@@ -124,7 +119,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
@@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
className="ml-auto"
disabled={!form.formState.isDirty}
>
{t("settings.common.save")}
Save
</Button>
</AccordionContent>
</AccordionItem>

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import type React from "react";
import { useEffect, useState } from "react";
@@ -56,7 +55,6 @@ const TraefikPortsSchema = z.object({
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
@@ -84,7 +82,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
successMessage: "Ports updated successfully",
onSuccess: () => {
refetchPorts();
setOpen(false);
@@ -129,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
{t("settings.server.webServer.traefik.managePorts")}
Additional Port Mappings
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
{t(
"settings.server.webServer.traefik.managePortsDescription",
)}
Add or remove additional ports for Traefik
<span className="text-sm text-muted-foreground">
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
configured
@@ -179,9 +175,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.targetPort",
)}
Target Port
</FormLabel>
<FormControl>
<Input
@@ -210,9 +204,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishedPort",
)}
Published Port
</FormLabel>
<FormControl>
<Input

View File

@@ -135,7 +135,9 @@ export const UpdateServer = ({
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion} | {releaseTag}
{dokployVersion}{" "}
{(releaseTag === "canary" || releaseTag === "feature") &&
`(${releaseTag})`}
</span>
</div>
)}

View File

@@ -88,6 +88,35 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const TeamsIcon = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="36"
viewBox="0 0 512 476"
className={cn("size-9", className)}
>
<g>
<rect x="116" y="50" width="280" height="276" rx="64" fill="#6264A7" />
<rect x="236" y="138" width="180" height="224" rx="60" fill="#5059C9" />
<circle cx="122" cy="332" r="80" fill="#B2B4D3" />
<circle cx="370" cy="364" r="64" fill="#A6A7DC" />
<text
x="180"
y="270"
fill="#fff"
font-family="Segoe UI, Arial, sans-serif"
font-size="110"
font-weight="bold"
>
T
</text>
</g>
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg

View File

@@ -630,135 +630,137 @@ function SidebarLogo() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg"
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
Organizations
</DropdownMenuLabel>
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
});
window.location.reload();
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
className="w-full gap-2 p-2"
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (

View File

@@ -10,18 +10,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
@@ -32,7 +23,6 @@ export const UserNav = () => {
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { locale, setLocale } = useLocale();
// const { mutateAsync } = api.auth.logout.useMutation();
return (
@@ -155,39 +145,19 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1.5">
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="w-32">
<Select
onValueChange={setLocale}
defaultValue={locale}
value={locale}
>
<SelectTrigger>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
{Object.values(Languages).map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -2,9 +2,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +28,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
@@ -58,6 +59,7 @@ const oidcProviderSchema = z.object({
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,16 +72,86 @@ const formDefaultValues = {
scopes: [...DEFAULT_SCOPES],
};
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
function parseOidcConfig(oidcConfig: string | null): {
clientId?: string;
clientSecret?: string;
scopes?: string[];
} | null {
if (!oidcConfig) return null;
try {
const parsed = JSON.parse(oidcConfig) as {
clientId?: string;
clientSecret?: string;
scopes?: string[];
};
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
};
} catch {
return null;
}
}
export function RegisterOidcDialog({
providerId,
children,
}: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const baseURL = useUrl();
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const oidc = parseOidcConfig(data.oidcConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
clientId: oidc?.clientId ?? "",
clientSecret: oidc?.clientSecret ?? "",
scopes:
oidc?.scopes && oidc.scopes.length > 0
? oidc.scopes
: [...DEFAULT_SCOPES],
});
}, [data, open, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
@@ -130,7 +202,11 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
},
});
toast.success("OIDC provider registered successfully");
toast.success(
isEdit
? "OIDC provider updated successfully"
: "OIDC provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -146,11 +222,13 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
</DialogTitle>
<DialogDescription>
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
{isEdit
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -162,11 +240,28 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input placeholder="e.g. okta or my-idp" {...field} />
<Input
placeholder="e.g. okta or my-idp"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
{isEdit && " Cannot be changed when editing."}
</FormDescription>
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -341,7 +436,7 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -3,7 +3,12 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import {
type FieldArrayPath,
useFieldArray,
useForm,
useWatch,
} from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +33,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const domainsArraySchema = z
.array(z.string().trim())
@@ -58,6 +64,7 @@ const samlProviderSchema = z.object({
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,24 +77,83 @@ const formDefaultValues: SamlProviderForm = {
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
function parseSamlConfig(samlConfig: string | null): {
entryPoint?: string;
cert?: string;
idpMetadataXml?: string;
} | null {
if (!samlConfig) return null;
try {
const parsed = JSON.parse(samlConfig) as {
entryPoint?: string;
cert?: string;
idpMetadata?: { metadata?: string };
};
return {
entryPoint: parsed.entryPoint,
cert: parsed.cert,
idpMetadataXml: parsed.idpMetadata?.metadata,
};
} catch {
return null;
}
}
export function RegisterSamlDialog({
providerId,
children,
}: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const baseURL = useUrl();
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const saml = parseSamlConfig(data.samlConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
entryPoint: saml?.entryPoint ?? "",
cert: saml?.cert ?? "",
idpMetadataXml: saml?.idpMetadataXml ?? "",
});
}, [data, open, form]);
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -133,7 +199,11 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
},
});
toast.success("SAML provider registered successfully");
toast.success(
isEdit
? "SAML provider updated successfully"
: "SAML provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -149,10 +219,13 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update SAML provider" : "Register SAML provider"}
</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
{isEdit
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -167,8 +240,26 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
{isEdit && (
<FormDescription>
Cannot be changed when editing.
</FormDescription>
)}
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/saml2/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -317,7 +408,7 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -1,7 +1,15 @@
"use client";
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
Eye,
Loader2,
LogIn,
Pencil,
Plus,
Shield,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -21,7 +29,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
@@ -67,29 +77,107 @@ export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const baseURL = useUrl();
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const [newOriginInput, setNewOriginInput] = useState("");
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery(
undefined,
{ enabled: manageOriginsOpen },
);
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
api.sso.addTrustedOrigin.useMutation();
const { mutateAsync: removeTrustedOrigin, isLoading: isRemovingOrigin } =
api.sso.removeTrustedOrigin.useMutation();
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
api.sso.updateTrustedOrigin.useMutation();
const handleAddOrigin = async () => {
const value = newOriginInput.trim();
if (!value) return;
try {
await addTrustedOrigin({ origin: value });
toast.success("Trusted origin added");
setNewOriginInput("");
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to add trusted origin",
);
}
};
const handleRemoveOrigin = async (origin: string) => {
try {
await removeTrustedOrigin({ origin });
toast.success("Trusted origin removed");
if (editingOrigin === origin) setEditingOrigin(null);
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to remove trusted origin",
);
}
};
const handleStartEdit = (origin: string) => {
setEditingOrigin(origin);
setEditingValue(origin);
};
const handleSaveEdit = async () => {
if (editingOrigin == null || !editingValue.trim()) {
setEditingOrigin(null);
return;
}
try {
await updateTrustedOrigin({
oldOrigin: editingOrigin,
newOrigin: editingValue.trim(),
});
toast.success("Trusted origin updated");
setEditingOrigin(null);
setEditingValue("");
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update trusted origin",
);
}
};
const handleCancelEdit = () => {
setEditingOrigin(null);
setEditingValue("");
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
</div>
{isLoading ? (
@@ -177,6 +265,22 @@ export const SSOSettings = () => {
<Eye className="mr-1 size-3" />
View details
</Button>
{isOidc && (
<RegisterOidcDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterOidcDialog>
)}
{isSaml && (
<RegisterSamlDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterSamlDialog>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -256,8 +360,7 @@ export const SSOSettings = () => {
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
View-only. To change settings, remove this provider and add it
again with the new values.
Use Edit to change provider settings (OIDC or SAML).
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
@@ -366,6 +469,128 @@ export const SSOSettings = () => {
)}
</DialogContent>
</Dialog>
<Dialog open={manageOriginsOpen} onOpenChange={setManageOriginsOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="size-5" />
Trusted origins
</DialogTitle>
<DialogDescription>
Manage allowed origins for SSO callbacks. Add, edit, or remove
origins for your account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<span className="text-sm font-medium">Current origins</span>
{trustedOrigins.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No trusted origins yet. Add one below.
</p>
) : (
<ul className="flex flex-col gap-2">
{trustedOrigins.map((origin) => (
<li
key={origin}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
{editingOrigin === origin ? (
<>
<Input
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="https://..."
className="flex-1 font-mono text-sm"
autoFocus
/>
<Button
size="sm"
onClick={handleSaveEdit}
disabled={!editingValue.trim() || isUpdatingOrigin}
>
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancelEdit}
>
Cancel
</Button>
</>
) : (
<>
<span className="flex-1 break-all font-mono text-sm">
{origin}
</span>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => handleStartEdit(origin)}
>
<Pencil className="size-3.5" />
</Button>
<DialogAction
title="Remove trusted origin"
description={`Remove "${origin}" from trusted origins?`}
type="destructive"
onClick={async () => handleRemoveOrigin(origin)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isRemovingOrigin}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</>
)}
</li>
))}
</ul>
)}
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Add trusted origin</span>
<div className="flex gap-2">
<Input
value={newOriginInput}
onChange={(e) => setNewOriginInput(e.target.value)}
placeholder="https://example.com"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAddOrigin();
}
}}
/>
<Button
size="sm"
onClick={handleAddOrigin}
disabled={!newOriginInput.trim() || isAddingOrigin}
>
<Plus className="mr-1 size-4" />
Add
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setManageOriginsOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

View File

@@ -0,0 +1,8 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'teams';--> statement-breakpoint
CREATE TABLE "teams" (
"teamsId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "teamsId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_teamsId_teams_teamsId_fk" FOREIGN KEY ("teamsId") REFERENCES "public"."teams"("teamsId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1002,6 +1002,20 @@
"when": 1770615019498,
"tag": "0142_outstanding_tusk",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1771297084611,
"tag": "0144_odd_gunslinger",
"breakpoints": true
}
]
}

View File

@@ -1,29 +0,0 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = {
english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" },
persian: { code: "fa", name: "فارسی" },
dutch: { code: "nl", name: "Nederlands" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
kazakh: { code: "kz", name: "Қазақ" },
norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" },
malayalam: { code: "ml", name: "മലയാളം" },
};
export type Language = keyof typeof Languages;
export type LanguageCode = (typeof Languages)[keyof typeof Languages]["code"];

View File

@@ -10,15 +10,6 @@ const nextConfig = {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
async headers() {
return [
{

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.27.0",
"version": "v0.27.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -41,13 +41,13 @@
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
@@ -95,8 +95,8 @@
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -113,7 +113,6 @@
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
@@ -121,7 +120,6 @@
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -139,7 +137,6 @@
"react-day-picker": "8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"slugify": "^1.6.6",
@@ -147,7 +144,7 @@
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.22.0",
"swagger-ui-react": "^5.31.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
@@ -156,7 +153,7 @@
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod": "^3.25.76",
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},

View File

@@ -4,13 +4,11 @@ import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
const inter = Inter({ subsets: ["latin"] });
@@ -58,14 +56,4 @@ const MyApp = ({
);
};
export default api.withTRPC(
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
}),
);
export default api.withTRPC(MyApp);

View File

@@ -152,6 +152,10 @@ export default async function handler(
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "soft-serve") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
@@ -439,6 +443,13 @@ export const extractCommitMessage = (headers: any, body: any) => {
: "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.commits && body.commits.length > 0
? body.commits[0].message
: "NEW COMMIT";
}
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
@@ -476,6 +487,11 @@ export const extractHash = (headers: any, body: any) => {
return body.after || "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.after || "NEW COMMIT";
}
return "";
};
@@ -484,7 +500,10 @@ export const extractBranchName = (headers: any, body: any) => {
return body?.ref?.replace("refs/heads/", "");
}
if (headers["x-gitlab-event"]) {
if (
headers["x-gitlab-event"] ||
headers["x-softserve-event"]?.includes("push")
) {
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
}
@@ -512,6 +531,10 @@ export const getProviderByHeader = (headers: any) => {
return "bitbucket";
}
if (headers["x-softserve-event"]) {
return "soft-serve";
}
return null;
};

View File

@@ -6,7 +6,6 @@ import superjson from "superjson";
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -26,7 +25,6 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -55,7 +53,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -7,7 +7,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -35,7 +34,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -70,7 +68,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -9,7 +9,6 @@ import { ProfileForm } from "@/components/dashboard/settings/profile/profile-for
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data } = api.user.get.useQuery();
@@ -37,7 +36,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = getLocale(req.cookies);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
@@ -67,7 +65,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -10,7 +10,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data: user } = api.user.get.useQuery();
@@ -42,7 +41,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
@@ -85,7 +83,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -6,7 +6,6 @@ import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -25,7 +24,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req);
if (!user) {
return {
@@ -61,7 +59,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -8,7 +8,6 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -43,7 +42,6 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -78,7 +76,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -105,7 +105,6 @@ export default function Home({ IS_CLOUD }: Props) {
setIsLoginLoading(false);
}
};
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {
@@ -254,7 +253,6 @@ export default function Home({ IS_CLOUD }: Props) {
onChange={setTwoFactorCode}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
autoComplete="off"
autoFocus
>
<InputOTPGroup>

View File

@@ -1,6 +1,7 @@
import {
addNewService,
checkServiceAccess,
clearOldDeployments,
createApplication,
deleteAllMiddlewares,
findApplicationById,
@@ -746,6 +747,23 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this application",
});
}
await clearOldDeployments(application.appName, application.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {

View File

@@ -2,6 +2,7 @@ import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeployments,
cloneCompose,
createCommand,
createCompose,
@@ -263,6 +264,23 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this compose",
});
}
await clearOldDeployments(compose.appName, compose.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {

View File

@@ -8,6 +8,7 @@ import {
findComposeById,
findDeploymentById,
findServerById,
removeDeployment,
updateDeploymentStatus,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -107,4 +108,14 @@ export const deploymentRouter = createTRPCRouter({
await updateDeploymentStatus(deployment.deploymentId, "error");
}),
removeDeployment: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
return await removeDeployment(input.deploymentId);
}),
});

View File

@@ -8,6 +8,7 @@ import {
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTeamsNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
@@ -23,6 +24,7 @@ import {
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
@@ -33,6 +35,7 @@ import {
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTeamsNotification,
updateTelegramNotification,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -55,6 +58,7 @@ import {
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTeams,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
@@ -66,6 +70,7 @@ import {
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTeamsConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
@@ -76,6 +81,7 @@ import {
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTeams,
apiUpdateTelegram,
notifications,
server,
@@ -413,6 +419,7 @@ export const notificationRouter = createTRPCRouter({
custom: true,
lark: true,
pushover: true,
teams: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -705,6 +712,61 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createTeams: adminProcedure
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamsNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateTeams: adminProcedure
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateTeamsNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testTeamsConnection: adminProcedure
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
await sendTeamsNotification(input, {
title: "🤚 Test Notification",
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createPushover: adminProcedure
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {

View File

@@ -1,5 +1,5 @@
import { user } from "@dokploy/server/db/schema";
import { validateLicenseKey } from "@dokploy/server/index";
import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
@@ -184,18 +184,7 @@ export const licenseKeyRouter = createTRPCRouter({
};
}),
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
return await hasValidLicense(ctx.session.activeOrganizationId);
}),
updateEnterpriseSettings: adminProcedure
.input(

View File

@@ -2,7 +2,10 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import { requestToHeaders } from "@dokploy/server/index";
import {
getOrganizationOwnerId,
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
@@ -55,9 +58,148 @@ export const ssoRouter = createTRPCRouter({
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),
getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) return [];
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
return ownerUser?.trustedOrigins ?? [];
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to access it",
});
}
return provider;
}),
update: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const existing = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
},
});
if (!existing) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to update it",
});
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
});
for (const provider of providers) {
if (provider.providerId === input.providerId) continue;
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const issuerChanged =
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const trustedOrigins = ownerUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
);
if (!isInTrustedOrigins) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.",
});
}
}
const domain = input.domains.join(",");
const updateBody: {
issuer: string;
domain: string;
oidcConfig?: (typeof input)["oidcConfig"];
samlConfig?: (typeof input)["samlConfig"];
} = {
issuer: input.issuer,
domain,
};
if (input.oidcConfig != null) {
updateBody.oidcConfig = input.oidcConfig;
}
if (input.samlConfig != null) {
updateBody.samlConfig = input.samlConfig;
}
await auth.updateSSOProvider({
params: { providerId: input.providerId },
body: updateBody,
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
@@ -102,24 +244,6 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -147,25 +271,6 @@ export const ssoRouter = createTRPCRouter({
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {
@@ -177,4 +282,92 @@ export const ssoRouter = createTRPCRouter({
});
return { success: true };
}),
addTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
return { success: true };
}
const next = Array.from(new Set([...existing, normalized]));
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const next = existing.filter(
(o) => o.toLowerCase() !== normalized.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
.input(
z.object({
oldOrigin: z.string().min(1),
newOrigin: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
const newNorm = normalizeTrustedOrigin(input.newOrigin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const next = existing.map((o) =>
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
});

View File

@@ -27,12 +27,17 @@ export const stripeRouter = createTRPCRouter({
const products = await stripe.products.list({
expand: ["data.default_price"],
active: true,
ids: [PRODUCT_MONTHLY_ID, PRODUCT_ANNUAL_ID],
});
const filteredProducts = products.data.filter((product) => {
return (
product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
);
});
if (!stripeCustomerId) {
return {
products: products.data,
products: filteredProducts,
subscriptions: [],
};
}
@@ -44,7 +49,7 @@ export const stripeRouter = createTRPCRouter({
});
return {
products: products.data,
products: filteredProducts,
subscriptions: subscriptions.data,
};
}),

View File

@@ -7,6 +7,7 @@
* need to use are documented accordingly near the end.
*/
import { hasValidLicense } from "@dokploy/server/index";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
@@ -239,10 +240,11 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.isValidEnterpriseLicense
) {
const hasValidLicenseResult = await hasValidLicense(
ctx.session.activeOrganizationId,
);
if (!hasValidLicenseResult) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",

View File

@@ -3,7 +3,13 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { getShell, isValidContainerId } from "./utils";
import {
getShell,
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "./utils";
export const setupDockerContainerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -30,9 +36,9 @@ export const setupDockerContainerLogsWebSocketServer = (
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const containerId = url.searchParams.get("containerId");
const tail = url.searchParams.get("tail");
const search = url.searchParams.get("search");
const since = url.searchParams.get("since");
const tail = url.searchParams.get("tail") ?? "100";
const search = url.searchParams.get("search") ?? "";
const since = url.searchParams.get("since") ?? "all";
const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const { user, session } = await validateRequest(req);
@@ -48,6 +54,21 @@ export const setupDockerContainerLogsWebSocketServer = (
return;
}
if (!isValidTail(tail)) {
ws.close(4000, "Invalid tail parameter");
return;
}
if (!isValidSince(since)) {
ws.close(4000, "Invalid since parameter");
return;
}
if (search !== "" && !isValidSearch(search)) {
ws.close(4000, "Invalid search parameter");
return;
}
if (!user || !session) {
ws.close();
return;

View File

@@ -1,9 +1,9 @@
import { spawn } from "node:child_process";
import type http from "node:http";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { readValidDirectory } from "@dokploy/server/wss/utils";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { readValidDirectory } from "./utils";
export const setupDeploymentLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,

View File

@@ -15,6 +15,37 @@ export const isValidContainerId = (id: string): boolean => {
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
};
/**
* Validates the `tail` parameter for docker logs (number of lines, max 10000).
* Prevents command injection by allowing only digits.
*/
export const isValidTail = (tail: string): boolean => {
return (
/^\d+$/.test(tail) &&
Number.parseInt(tail, 10) <= 10000 &&
Number.parseInt(tail, 10) >= 0
);
};
/**
* Validates the `since` parameter for docker logs: "all" or duration like 5s, 10m, 1h, 2d.
* Prevents command injection by allowing only a strict format.
*/
export const isValidSince = (since: string): boolean => {
return since === "all" || /^\d+[smhd]$/.test(since);
};
/**
* Validates the `search` parameter for log filtering.
* Search is concatenated into shell commands (SSH path: double quotes; local path: single quotes).
* Only allow alphanumeric, space, dot, underscore, hyphen to prevent $, `, ', " from enabling command injection.
* Max length 500.
*/
export const isValidSearch = (search: string): boolean => {
// Space only (not \s) to reject \n, \r, \t and other control chars
return /^[a-zA-Z0-9 ._-]{0,500}$/.test(search);
};
/**
* Validates that the shell is one of the allowed shells.
*/
@@ -32,20 +63,6 @@ export const isValidShell = (shell: string): boolean => {
return allowedShells.includes(shell);
};
export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
const { BASE_PATH } = paths(!!serverId);
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);
return (
resolvedDir === resolvedBase ||
resolvedDir.startsWith(resolvedBase + path.sep)
);
};
export const getShell = () => {
if (IS_CLOUD) {
return "NO_AVAILABLE";

View File

@@ -39,8 +39,7 @@
"**/*.js",
".next/types/**/*.ts",
"env.js",
"next.config.mjs",
"next-i18next.config.mjs"
"next.config.mjs"
],
"exclude": [
"node_modules",

View File

@@ -1,16 +0,0 @@
import Cookies from "js-cookie";
import type { LanguageCode } from "@/lib/languages";
export default function useLocale() {
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as LanguageCode;
const setLocale = (locale: LanguageCode) => {
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
window.location.reload();
};
return {
locale: currentLocale,
setLocale,
};
}

View File

@@ -1,23 +0,0 @@
import type { NextApiRequestCookies } from "next/dist/server/api-utils";
export function getLocale(cookies: NextApiRequestCookies) {
const locale = cookies.DOKPLOY_LOCALE ?? "en";
return locale;
}
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
import { Languages } from "@/lib/languages";
export const serverSideTranslations = (
locale: string,
namespaces = ["common"],
) =>
originalServerSideTranslations(locale, namespaces, {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
});

View File

@@ -20,7 +20,7 @@
"pino-pretty": "11.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "^3.25.32"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -43,5 +43,10 @@
"resolutions": {
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2"
}
}
}

View File

@@ -0,0 +1,27 @@
# Debug build OOM orden para probar
Ejecuta desde `packages/server` (o `pnpm --filter=@dokploy/server run <script>` desde la raíz).
1. **`pnpm run build:debug:noEmit`**
Solo typecheck, no escribe archivos.
- Si hace **OOM** → el problema es el análisis de tipos (ej. zod u otras libs).
- Si **pasa** → el problema está en emit (JS o `.d.ts`).
2. **`pnpm run build:debug:noEmit:8gb`**
Mismo que el anterior pero con 8GB de heap.
- Si con 8GB **pasa** y sin 8GB **no** → el typecheck necesita más memoria.
3. **`pnpm run build:debug:noDecl`**
Compila solo JS (sin `declaration`).
- Si hace **OOM** → el problema es emitir JS.
- Si **pasa** → el problema es generar `.d.ts`.
4. **`pnpm run build:debug:declOnly`**
Solo genera declaraciones (`.d.ts`).
- Si hace **OOM** → el cuello de botella son las declaraciones.
5. **`pnpm run build:debug:full`**
Build completo con `--extendedDiagnostics` (imprime estadísticas al final).
- Para ver en qué paso se va la memoria si no has localizado antes.
Con eso sabes si el OOM viene de: typecheck, emit JS o emit declarations, y puedes elegir fix (más memoria, esbuild para JS, o no emitir declarations).

View File

@@ -30,13 +30,13 @@
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/utils": "0.3.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
@@ -44,11 +44,11 @@
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso":"1.4.18",
"@better-auth/sso": "1.4.18",
"@trpc/server": "^10.45.2",
"adm-zip": "^0.5.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -81,11 +81,11 @@
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"zod": "^3.25.32",
"zod": "^3.25.76",
"semver": "7.7.3"
},
"devDependencies": {
"@better-auth/cli": "1.4.18",
"@better-auth/cli": "1.4.18",
"@types/semver": "7.7.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
@@ -115,4 +115,4 @@
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}
}

View File

@@ -126,7 +126,6 @@ const schema = createInsertSchema(deployments, {
previewDeploymentId: z.string(),
buildServerId: z.string(),
});
export const apiCreateDeployment = schema
.pick({
title: true,

View File

@@ -23,6 +23,7 @@ export const notificationType = pgEnum("notificationType", [
"pushover",
"custom",
"lark",
"teams",
]);
export const notifications = pgTable("notification", {
@@ -72,6 +73,9 @@ export const notifications = pgTable("notification", {
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
onDelete: "cascade",
}),
teamsId: text("teamsId").references(() => teams.teamsId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -179,6 +183,14 @@ export const pushover = pgTable("pushover", {
expire: integer("expire"),
});
export const teams = pgTable("teams", {
teamsId: text("teamsId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -220,6 +232,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.pushoverId],
references: [pushover.pushoverId],
}),
teams: one(teams, {
fields: [notifications.teamsId],
references: [teams.teamsId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -507,6 +523,32 @@ export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiCreateTeams = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().min(1),
})
.required();
export const apiUpdateTeams = apiCreateTeams.partial().extend({
notificationId: z.string().min(1),
teamsId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestTeamsConnection = apiCreateTeams.pick({
webhookUrl: true,
});
export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({

View File

@@ -31,6 +31,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
export * from "./services/redis";

View File

@@ -18,6 +18,8 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
@@ -43,17 +45,14 @@ const { handler, api } = betterAuth({
},
}
: {}),
...(IS_CLOUD
? {
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github", "google"],
allowDifferentEmails: true,
},
},
}
: {}),
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github", "google", ...(trustedProviders || [])],
allowDifferentEmails: true,
},
},
appName: "Dokploy",
socialProviders: {
github: {
@@ -348,6 +347,7 @@ export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {

View File

@@ -2,13 +2,31 @@ import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateObject } from "ai";
import { generateText, Output } from "ai";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
interface SuggestionItem {
id: string;
name: string;
shortDescription: string;
description: string;
}
interface SuggestionsOutput {
suggestions: SuggestionItem[];
}
interface DockerOutput {
dockerCompose: string;
envVariables: Array<{ name: string; value: string }>;
domains: Array<{ host: string; port: number; serviceName: string }>;
configFiles?: Array<{ content: string; filePath: string }>;
}
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
where: eq(ai.organizationId, organizationId),
@@ -60,7 +78,7 @@ interface Props {
}
export const suggestVariants = async ({
organizationId,
organizationId: _organizationId,
aiId,
input,
serverId,
@@ -90,173 +108,177 @@ export const suggestVariants = async ({
ip = "127.0.0.1";
}
const { object } = await generateObject({
model,
output: "object",
schema: z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
}),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
const suggestionsSchema = z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
});
const suggestionsResult = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: suggestionsSchema }),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
});
const object = suggestionsResult.output as SuggestionsOutput | undefined;
if (object?.suggestions?.length) {
const dockerSchema = z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
});
const result = [];
for (const suggestion of object.suggestions) {
try {
const { object: docker } = await generateObject({
const dockerResult = await generateText({
model,
output: "object",
schema: z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
}),
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: dockerSchema }),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Note: configFiles is optional - only include it if configuration files are absolutely required.
Follow these rules:
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Note: configFiles is optional - only include it if configuration files are absolutely required.
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Follow these rules:
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
});
if (!!docker && !!docker.dockerCompose) {
const docker = dockerResult.output as DockerOutput | undefined;
if (docker?.dockerCompose) {
result.push({
...suggestion,
...docker,

View File

@@ -395,16 +395,14 @@ export const removeCompose = async (
if (compose.composeType === "stack") {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
docker stack rm ${compose.appName};
rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
}
await execAsync(command, {
cwd: projectPath,
});
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;

View File

@@ -13,7 +13,10 @@ import {
deployments,
} from "@dokploy/server/db/schema";
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
@@ -554,8 +557,25 @@ export const removeDeployment = async (deploymentId: string) => {
const deployment = await db
.delete(deployments)
.where(eq(deployments.deploymentId, deploymentId))
.returning();
return deployment[0];
.returning()
.then((result) => result[0]);
if (!deployment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment not found",
});
}
const command = `
rm -f ${deployment.logPath};
`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
}
return deployment;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error creating the deployment";
@@ -831,3 +851,19 @@ export const findAllDeploymentsByServerId = async (serverId: string) => {
});
return deploymentsList;
};
export const clearOldDeployments = async (
appName: string,
serverId: string | null,
) => {
const { LOGS_PATH } = paths(!!serverId);
const folder = path.join(LOGS_PATH, appName);
const command = `
rm -rf ${folder};
`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};

View File

@@ -101,6 +101,20 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
return projectEnvironments;
};
const environmentHasServices = (
env: Awaited<ReturnType<typeof findEnvironmentById>>,
) => {
return (
(env.applications?.length ?? 0) > 0 ||
(env.compose?.length ?? 0) > 0 ||
(env.mariadb?.length ?? 0) > 0 ||
(env.mongo?.length ?? 0) > 0 ||
(env.mysql?.length ?? 0) > 0 ||
(env.postgres?.length ?? 0) > 0 ||
(env.redis?.length ?? 0) > 0
);
};
export const deleteEnvironment = async (environmentId: string) => {
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault) {
@@ -109,6 +123,13 @@ export const deleteEnvironment = async (environmentId: string) => {
message: "You cannot delete the default environment",
});
}
if (environmentHasServices(currentEnvironment)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot delete environment: it has active services. Delete all services first.",
});
}
const deletedEnvironment = await db
.delete(environments)
.where(eq(environments.environmentId, environmentId))

View File

@@ -9,6 +9,7 @@ import {
type apiCreatePushover,
type apiCreateResend,
type apiCreateSlack,
type apiCreateTeams,
type apiCreateTelegram,
type apiUpdateCustom,
type apiUpdateDiscord,
@@ -19,6 +20,7 @@ import {
type apiUpdatePushover,
type apiUpdateResend,
type apiUpdateSlack,
type apiUpdateTeams,
type apiUpdateTelegram,
custom,
discord,
@@ -30,6 +32,7 @@ import {
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -796,6 +799,7 @@ export const findNotificationById = async (notificationId: string) => {
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
if (!notification) {
@@ -905,6 +909,96 @@ export const updateLarkNotification = async (
});
};
export const createTeamsNotification = async (
input: typeof apiCreateTeams._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(teams)
.values({
webhookUrl: input.webhookUrl,
})
.returning()
.then((value) => value[0]);
if (!newTeams) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting teams",
});
}
const newDestination = await tx
.insert(notifications)
.values({
teamsId: newTeams.teamsId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "teams",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateTeamsNotification = async (
input: typeof apiUpdateTeams._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(teams)
.set({
webhookUrl: input.webhookUrl,
})
.where(eq(teams.teamsId, input.teamsId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const updateNotificationById = async (
notificationId: string,
notificationData: Partial<Notification>,

View File

@@ -0,0 +1,24 @@
import { db } from "@dokploy/server/db";
import { user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { getOrganizationOwnerId } from "./sso";
export const hasValidLicense = async (organizationId: string) => {
const ownerId = await getOrganizationOwnerId(organizationId);
if (!ownerId) {
return false;
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
};

View File

@@ -1,4 +1,6 @@
import { db } from "@dokploy/server/db";
import { organization } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
export const getSSOProviders = async () => {
const providers = await db.query.ssoProvider.findMany({
@@ -33,3 +35,12 @@ export const normalizeTrustedOrigin = (value: string): string => {
// e.g. "https://example.com/" -> "https://example.com"
return value.trim().replace(/\/+$/, "");
};
export const getOrganizationOwnerId = async (organizationId: string) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
columns: { ownerId: true },
});
if (!org) return null;
return org.ownerId;
};

View File

@@ -88,7 +88,7 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --pull always --remove-orphans`;
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
}

View File

@@ -3,6 +3,7 @@ import path, { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { Application } from "@dokploy/server/services/application";
import { findServerById } from "@dokploy/server/services/server";
import { readValidDirectory } from "@dokploy/server/wss/utils";
import AdmZip from "adm-zip";
import { Client, type SFTPWrapper } from "ssh2";
import {
@@ -62,6 +63,17 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
if (!filePath) continue;
const fullPath = path.join(outputPath, filePath).replace(/\\/g, "/");
if (!readValidDirectory(fullPath, application.serverId)) {
throw new Error(
`Path traversal detected: resolved path escapes output directory: ${filePath}`,
);
}
if (isDangerousNode(entry)) {
throw new Error(
`Dangerous node entries are not allowed: ${entry.entryName}`,
);
}
if (application.serverId) {
if (!entry.isDirectory) {
@@ -132,3 +144,14 @@ const uploadFileToServer = (
});
});
};
function isDangerousNode(entry: AdmZip.IZipEntry) {
const type = (entry.header.attr >> 16) & 0o170000;
return (
type === 0o120000 || // symlink
type === 0o060000 || // block device
type === 0o020000 || // char device
type === 0o010000 // fifo/pipe
);
}

View File

@@ -164,10 +164,12 @@ export const addDomainToCompose = async (
for (const domain of domains) {
const { serviceName, https } = domain;
if (!serviceName) {
throw new Error("Service name not found");
throw new Error(`Domain "${domain.host}" is missing a service name`);
}
if (!result?.services?.[serviceName]) {
throw new Error(`The service ${serviceName} not found in the compose`);
throw new Error(
`Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`,
);
}
const httpLabels = createDomainLabels(appName, domain, "web");

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -52,6 +53,7 @@ export const sendBuildErrorNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -67,6 +69,7 @@ export const sendBuildErrorNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -382,6 +385,26 @@ export const sendBuildErrorNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}\nError: ${errorMessage}`,
);
}
if (teams) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
await sendTeamsNotification(teams, {
title: "⚠️ Build Failed",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Error Message", value: truncatedErrorMessage },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -15,6 +15,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -55,6 +56,7 @@ export const sendBuildSuccessNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -70,6 +72,7 @@ export const sendBuildSuccessNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -396,6 +399,24 @@ export const sendBuildSuccessNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nEnvironment: ${environmentName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Build Success",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Environment", value: environmentName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -52,6 +53,7 @@ export const sendDatabaseBackupNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -67,6 +69,7 @@ export const sendDatabaseBackupNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -410,6 +413,30 @@ export const sendDatabaseBackupNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nDatabase: ${databaseType}\nDatabase Name: ${databaseName}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Database Type", value: databaseType },
{ name: "Database Name", value: databaseName },
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
];
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Database Backup Successful"
: "❌ Database Backup Failed",
facts,
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -39,6 +40,7 @@ export const sendDockerCleanupNotifications = async (
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -54,6 +56,7 @@ export const sendDockerCleanupNotifications = async (
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -262,6 +265,16 @@ export const sendDockerCleanupNotifications = async (
`Date: ${date.toLocaleString()}\nMessage: ${message}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Docker Cleanup",
facts: [
{ name: "Date", value: format(date, "PP pp") },
{ name: "Message", value: message },
],
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,6 +14,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -33,6 +34,7 @@ export const sendDokployRestartNotifications = async () => {
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -48,6 +50,7 @@ export const sendDokployRestartNotifications = async () => {
custom,
lark,
pushover,
teams,
} = notification;
try {
@@ -251,6 +254,16 @@ export const sendDokployRestartNotifications = async () => {
`Date: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Dokploy Server Restarted",
facts: [
{ name: "Status", value: "Successful" },
{ name: "Restart Time", value: format(date, "PP pp") },
],
});
}
} catch (error) {
console.log(error);
}

View File

@@ -7,6 +7,7 @@ import {
sendLarkNotification,
sendPushoverNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -40,6 +41,7 @@ export const sendServerThresholdNotifications = async (
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -47,7 +49,8 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) {
const { discord, telegram, slack, custom, lark, pushover } = notification;
const { discord, telegram, slack, custom, lark, pushover, teams } =
notification;
if (discord) {
const decorate = (decoration: string, text: string) =>
@@ -276,5 +279,19 @@ export const sendServerThresholdNotifications = async (
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{ name: "Current Value", value: `${payload.Value.toFixed(2)}%` },
{ name: "Threshold", value: `${payload.Threshold.toFixed(2)}%` },
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
}
};

View File

@@ -8,6 +8,7 @@ import type {
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import nodemailer from "nodemailer";
@@ -253,6 +254,84 @@ export const sendLarkNotification = async (
}
};
export interface TeamsAdaptiveCardMessage {
title: string;
themeColor?: string;
facts?: { name: string; value: string }[];
potentialAction?: { type: "Action.OpenUrl"; title: string; url: string };
}
export const sendTeamsNotification = async (
connection: typeof teams.$inferInsert,
message: TeamsAdaptiveCardMessage,
) => {
try {
const bodyElements: Record<string, unknown>[] = [
{
type: "TextBlock",
text: message.title,
size: "Medium",
weight: "Bolder",
wrap: true,
},
];
if (message.facts && message.facts.length > 0) {
bodyElements.push({
type: "FactSet",
facts: message.facts.map((f) => ({
title: f.name,
value: f.value,
})),
});
}
const cardContent: Record<string, unknown> = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: bodyElements,
};
if (message.potentialAction) {
cardContent.actions = [
{
type: "Action.OpenUrl",
title: message.potentialAction.title,
url: message.potentialAction.url,
},
];
}
const payload = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: cardContent,
},
],
};
const response = await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`Failed to send Teams notification: ${response.statusText}`,
);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send Teams notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
export const sendPushoverNotification = async (
connection: typeof pushover.$inferInsert,
title: string,

View File

@@ -12,6 +12,7 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -57,12 +58,22 @@ export const sendVolumeBackupNotifications = async ({
gotify: true,
ntfy: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, resend, discord, telegram, slack, gotify, ntfy, pushover } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
pushover,
teams,
} = notification;
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
@@ -288,5 +299,29 @@ export const sendVolumeBackupNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nVolume: ${volumeName}\nService Type: ${serviceType}${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Volume Name", value: volumeName },
{ name: "Service Type", value: serviceType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Status", value: type === "success" ? "Successful" : "Failed" },
];
if (backupSize) {
facts.push({ name: "Backup Size", value: backupSize });
}
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
facts,
});
}
}
};

View File

@@ -104,6 +104,20 @@ export const removeDomain = async (
}
};
/**
* Converts an internationalized domain name (IDN) to ASCII punycode format.
* Traefik requires domain names in ASCII format, so non-ASCII characters
* must be converted (e.g., "тест.рф" → "xn--e1aybc.xn--p1ai").
*/
const toPunycode = (host: string): string => {
try {
return new URL(`http://${host}`).hostname;
} catch {
// If URL parsing fails, return the original host
return host;
}
};
export const createRouterConfig = async (
app: ApplicationNested,
domain: Domain,
@@ -114,8 +128,9 @@ export const createRouterConfig = async (
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
domain;
const punycodeHost = toPunycode(host);
const routerConfig: HttpRouter = {
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`,
middlewares: [],
entryPoints: [entryPoint],

View File

@@ -1,4 +1,6 @@
import os from "node:os";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { publicIpv4, publicIpv6 } from "public-ip";
export const getShell = () => {
@@ -33,3 +35,18 @@ export const getPublicIpWithFallback = async () => {
}
return ip;
};
export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
const { BASE_PATH } = paths(!!serverId);
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);
return (
resolvedDir === resolvedBase ||
resolvedDir.startsWith(resolvedBase + path.sep)
);
};

View File

@@ -34,6 +34,7 @@
"dokploy",
"config",
"dist",
".next",
"webpack.config.server.js",
"migration.ts",
"setup.ts"

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.server.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false
}
}

5320
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff