mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-01 03:55:22 +02:00
Compare commits
95 Commits
copilot/fi
...
v0.27.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c688311580 | ||
|
|
b9c62cc515 | ||
|
|
605931861b | ||
|
|
4e8d37bff7 | ||
|
|
be35709cea | ||
|
|
6c3230648a | ||
|
|
756d276f47 | ||
|
|
1d5ab71bd5 | ||
|
|
9880c71dba | ||
|
|
33c3a4ed4e | ||
|
|
3689a82ec5 | ||
|
|
b818d661fd | ||
|
|
1302d705e7 | ||
|
|
685a4c0b69 | ||
|
|
b58f2b236f | ||
|
|
06fd561bb1 | ||
|
|
62fb117ecf | ||
|
|
8713d3e1aa | ||
|
|
76038f6db6 | ||
|
|
a511f4db40 | ||
|
|
95a944c4e5 | ||
|
|
6d6cf18108 | ||
|
|
32ed0c7285 | ||
|
|
923466b4fa | ||
|
|
d5163322fb | ||
|
|
714849883e | ||
|
|
407ce3f425 | ||
|
|
49a189fcbf | ||
|
|
7e8d3b7162 | ||
|
|
24010af265 | ||
|
|
33192ce4d1 | ||
|
|
02a695c6af | ||
|
|
e5f51fd7be | ||
|
|
620e4c4835 | ||
|
|
125c23e2c0 | ||
|
|
51e005701d | ||
|
|
c04dd63db8 | ||
|
|
4fd06b00a0 | ||
|
|
1f9335ad5d | ||
|
|
2cd3c27ae9 | ||
|
|
53ae08cec4 | ||
|
|
8aab8dd2a5 | ||
|
|
e8bec0ae03 | ||
|
|
389a69484e | ||
|
|
f656e624f7 | ||
|
|
f5635f6645 | ||
|
|
81a04d0777 | ||
|
|
b63c22a7df | ||
|
|
05ad6d812c | ||
|
|
aa579977e3 | ||
|
|
2788323e01 | ||
|
|
3b74425d35 | ||
|
|
edbc98aea7 | ||
|
|
60f5ab304a | ||
|
|
8291c6d835 | ||
|
|
7928d117b3 | ||
|
|
eec4e21751 | ||
|
|
343a84d6bc | ||
|
|
89416fef47 | ||
|
|
74d72f1494 | ||
|
|
a24dbe365a | ||
|
|
3b753ecfbf | ||
|
|
7184b7d4b2 | ||
|
|
5c36ca3986 | ||
|
|
3a3f3ab7d4 | ||
|
|
1779a8a950 | ||
|
|
a51a4b3e87 | ||
|
|
034d55d7cb | ||
|
|
eeb7f00d05 | ||
|
|
1326d14a00 | ||
|
|
59f843f8a0 | ||
|
|
fe807ae2a6 | ||
|
|
744ebab15a | ||
|
|
17da1d5b3c | ||
|
|
f7613d9375 | ||
|
|
a43ad106f2 | ||
|
|
0e26c5023b | ||
|
|
f4a4530481 | ||
|
|
00dc3fae11 | ||
|
|
1da23f8888 | ||
|
|
bee4e4639c | ||
|
|
bd5b27ad51 | ||
|
|
b391abfd5c | ||
|
|
21a6657e00 | ||
|
|
d348ad5556 | ||
|
|
5d8b7b9b99 | ||
|
|
2be92d20bb | ||
|
|
2be938a695 | ||
|
|
95dd9ddeb6 | ||
|
|
33fb21bfe1 | ||
|
|
5ca4d8366e | ||
|
|
cc49db63da | ||
|
|
f5f21ef195 | ||
|
|
464d58daaa | ||
|
|
50b0a5d61c |
21
.devcontainer/Dockerfile
Normal file
21
.devcontainer/Dockerfile
Normal 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
|
||||||
53
.devcontainer/devcontainer.json
Normal file
53
.devcontainer/devcontainer.json
Normal 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
22
.github/workflows/pr-quality.yml
vendored
Normal 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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,6 +44,3 @@ yarn-error.log*
|
|||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
# Development environment
|
|
||||||
.devcontainer
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
"zod": "^3.25.32"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.0",
|
"@types/node": "^20.16.0",
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
{ commits: [{ message: "[skip ci] test" }] },
|
{ commits: [{ message: "[skip ci] test" }] },
|
||||||
),
|
),
|
||||||
).toBe("[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", () => {
|
it("should handle missing commit message", () => {
|
||||||
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
||||||
"NEW COMMIT",
|
"NEW COMMIT",
|
||||||
);
|
);
|
||||||
|
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
|
||||||
|
"NEW COMMIT",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const OUTPUT_BASE = "./__test__/drop/zips/output";
|
||||||
const { APPLICATIONS_PATH } = paths();
|
const { APPLICATIONS_PATH } = paths();
|
||||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...actual,
|
...actual,
|
||||||
paths: () => ({
|
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,
|
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", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
// const { APPLICATIONS_PATH } = paths();
|
// const { APPLICATIONS_PATH } = paths();
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
|
|||||||
try {
|
try {
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||||
console.log(`Output Path: ${outputPath}`);
|
|
||||||
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
||||||
const file = new File([zipBuffer], "single.zip");
|
const file = new File([zipBuffer], "single.zip");
|
||||||
await unzipDrop(file, baseApp);
|
await unzipDrop(file, baseApp);
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/etc/passwd
|
||||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
@@ -275,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
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("тест.рф");
|
||||||
|
});
|
||||||
|
|||||||
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,7 +105,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
|
|
||||||
const modeData =
|
const modeData =
|
||||||
formData.type === "Replicated"
|
formData.type === "Replicated"
|
||||||
? { Replicated: { Replicas: formData.Replicas } }
|
? {
|
||||||
|
Replicated: {
|
||||||
|
Replicas:
|
||||||
|
formData.Replicas !== undefined && formData.Replicas !== ""
|
||||||
|
? Number(formData.Replicas)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
: { Global: {} };
|
: { Global: {} };
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Paintbrush } from "lucide-react";
|
import { Ban } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||||
Cancel Queues
|
Cancel Queues
|
||||||
<Paintbrush className="size-4" />
|
<Ban className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
Settings,
|
Settings,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
|
import { ClearDeployments } from "./clear-deployments";
|
||||||
import { KillBuild } from "./kill-build";
|
import { KillBuild } from "./kill-build";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
@@ -77,6 +79,8 @@ export const ShowDeployments = ({
|
|||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||||
api.deployment.killProcess.useMutation();
|
api.deployment.killProcess.useMutation();
|
||||||
|
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
|
||||||
|
api.deployment.removeDeployment.useMutation();
|
||||||
|
|
||||||
// Cancel deployment mutations
|
// Cancel deployment mutations
|
||||||
const {
|
const {
|
||||||
@@ -144,6 +148,9 @@ export const ShowDeployments = ({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||||
|
{(type === "application" || type === "compose") && (
|
||||||
|
<ClearDeployments id={id} type={type} />
|
||||||
|
)}
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<KillBuild id={id} type={type} />
|
<KillBuild id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -252,6 +259,8 @@ export const ShowDeployments = ({
|
|||||||
const isExpanded = expandedDescriptions.has(
|
const isExpanded = expandedDescriptions.has(
|
||||||
deployment.deploymentId,
|
deployment.deploymentId,
|
||||||
);
|
);
|
||||||
|
const canDelete =
|
||||||
|
deployment.status === "done" || deployment.status === "error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -370,6 +379,33 @@ export const ShowDeployments = ({
|
|||||||
View
|
View
|
||||||
</Button>
|
</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?.rollback &&
|
||||||
deployment.status === "done" &&
|
deployment.status === "done" &&
|
||||||
type === "application" && (
|
type === "application" && (
|
||||||
|
|||||||
@@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!bitbucketId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a Bitbucket account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo: GiteaRepository) =>
|
(repo: GiteaRepository) =>
|
||||||
repo.name === field.value.repo,
|
repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!giteaId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a Gitea account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!githubId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a GitHub account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!gitlabId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a GitLab account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!bitbucketId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a Bitbucket account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!giteaId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a Gitea account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!githubId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a GitHub account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "Select repository"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!gitlabId ? (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a GitLab account first
|
||||||
|
</span>
|
||||||
|
) : isLoadingRepositories ? (
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ export const ShowProjects = () => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2">
|
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||||
<span className="flex flex-col gap-1.5 ">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookIcon className="size-4 text-muted-foreground" />
|
<BookIcon className="size-4 text-muted-foreground" />
|
||||||
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground break-all">
|
<span className="text-sm font-medium text-muted-foreground break-normal">
|
||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link2, Loader2, Unlink } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
const LINKING_CALLBACK_URL = "/dashboard/settings/profile";
|
||||||
|
|
||||||
|
const TRUSTED_PROVIDERS = ["google", "github"] as const;
|
||||||
|
type SocialProvider = (typeof TRUSTED_PROVIDERS)[number];
|
||||||
|
|
||||||
|
type AccountItem = {
|
||||||
|
providerId: string;
|
||||||
|
accountId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerLabel(providerId: string): string {
|
||||||
|
return providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkingAccount() {
|
||||||
|
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||||
|
const [accountsLoading, setAccountsLoading] = useState(true);
|
||||||
|
const [linkingProvider, setLinkingProvider] = useState<SocialProvider | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [unlinkingProviderId, setUnlinkingProviderId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchAccounts = useCallback(async () => {
|
||||||
|
setAccountsLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await authClient.listAccounts();
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: ((data && typeof data === "object" && "accounts" in data
|
||||||
|
? (data as { accounts?: AccountItem[] }).accounts
|
||||||
|
: null) ?? []);
|
||||||
|
setAccounts(Array.isArray(list) ? list : []);
|
||||||
|
} catch {
|
||||||
|
setAccounts([]);
|
||||||
|
} finally {
|
||||||
|
setAccountsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts();
|
||||||
|
}, [fetchAccounts]);
|
||||||
|
|
||||||
|
const linkedProviderIds = new Set(accounts.map((a) => a.providerId));
|
||||||
|
const socialAccounts = accounts.filter((a) =>
|
||||||
|
TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLinkSocial = async (provider: SocialProvider) => {
|
||||||
|
setLinkingProvider(provider);
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.linkSocial({
|
||||||
|
provider,
|
||||||
|
callbackURL: LINKING_CALLBACK_URL,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? "Failed to link account");
|
||||||
|
setLinkingProvider(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
"Failed to link account",
|
||||||
|
err instanceof Error ? { description: err.message } : undefined,
|
||||||
|
);
|
||||||
|
setLinkingProvider(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = async (providerId: string, accountId?: string) => {
|
||||||
|
setUnlinkingProviderId(providerId);
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.unlinkAccount({
|
||||||
|
providerId,
|
||||||
|
...(accountId && { accountId }),
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? "Failed to unlink account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Account unlinked");
|
||||||
|
await fetchAccounts();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
"Failed to unlink account",
|
||||||
|
err instanceof Error ? { description: err.message } : undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setUnlinkingProviderId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canUnlink = accounts.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Link2 className="size-6 text-muted-foreground self-center" />
|
||||||
|
Linking account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Link your Google or GitHub account to sign in with them.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 py-8 border-t">
|
||||||
|
{/* Linked accounts */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Linked accounts</p>
|
||||||
|
{accountsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : socialAccounts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
No social accounts linked yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{socialAccounts.map((acc) => (
|
||||||
|
<li
|
||||||
|
key={acc.accountId ?? acc.providerId}
|
||||||
|
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{providerLabel(acc.providerId)}
|
||||||
|
</span>
|
||||||
|
{canUnlink && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() =>
|
||||||
|
handleUnlink(acc.providerId, acc.accountId)
|
||||||
|
}
|
||||||
|
disabled={unlinkingProviderId === acc.providerId}
|
||||||
|
isLoading={unlinkingProviderId === acc.providerId}
|
||||||
|
>
|
||||||
|
{unlinkingProviderId === acc.providerId ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Unlink className="mr-1.5 size-4" />
|
||||||
|
Unlink
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click a provider below to link it to your account. You will be
|
||||||
|
redirected to complete the flow.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{!linkedProviderIds.has("google") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
className="min-w-[180px]"
|
||||||
|
onClick={() => handleLinkSocial("google")}
|
||||||
|
disabled={!!linkingProvider}
|
||||||
|
isLoading={linkingProvider === "google"}
|
||||||
|
>
|
||||||
|
{linkingProvider === "google" ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Link with Google
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!linkedProviderIds.has("github") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
className="min-w-[180px]"
|
||||||
|
onClick={() => handleLinkSocial("github")}
|
||||||
|
disabled={!!linkingProvider}
|
||||||
|
isLoading={linkingProvider === "github"}
|
||||||
|
>
|
||||||
|
{linkingProvider === "github" ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="mr-2 size-4"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Link with GitHub
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
PushoverIcon,
|
PushoverIcon,
|
||||||
ResendIcon,
|
ResendIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
|
TeamsIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
import { Button } from "@/components/ui/button";
|
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" }),
|
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("teams"),
|
||||||
|
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||||
|
})
|
||||||
|
.merge(notificationBaseSchema),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const notificationsMap = {
|
export const notificationsMap = {
|
||||||
@@ -183,6 +190,10 @@ export const notificationsMap = {
|
|||||||
icon: <LarkIcon className="text-muted-foreground" />,
|
icon: <LarkIcon className="text-muted-foreground" />,
|
||||||
label: "Lark",
|
label: "Lark",
|
||||||
},
|
},
|
||||||
|
teams: {
|
||||||
|
icon: <TeamsIcon className="text-muted-foreground" />,
|
||||||
|
label: "Microsoft Teams",
|
||||||
|
},
|
||||||
email: {
|
email: {
|
||||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||||
label: "Email",
|
label: "Email",
|
||||||
@@ -244,6 +255,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testNtfyConnection.useMutation();
|
api.notification.testNtfyConnection.useMutation();
|
||||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||||
api.notification.testLarkConnection.useMutation();
|
api.notification.testLarkConnection.useMutation();
|
||||||
|
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
|
||||||
|
api.notification.testTeamsConnection.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||||
api.notification.testCustomConnection.useMutation();
|
api.notification.testCustomConnection.useMutation();
|
||||||
@@ -278,6 +291,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
const larkMutation = notificationId
|
const larkMutation = notificationId
|
||||||
? api.notification.updateLark.useMutation()
|
? api.notification.updateLark.useMutation()
|
||||||
: api.notification.createLark.useMutation();
|
: api.notification.createLark.useMutation();
|
||||||
|
const teamsMutation = notificationId
|
||||||
|
? api.notification.updateTeams.useMutation()
|
||||||
|
: api.notification.createTeams.useMutation();
|
||||||
const pushoverMutation = notificationId
|
const pushoverMutation = notificationId
|
||||||
? api.notification.updatePushover.useMutation()
|
? api.notification.updatePushover.useMutation()
|
||||||
: api.notification.createPushover.useMutation();
|
: api.notification.createPushover.useMutation();
|
||||||
@@ -353,7 +369,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.discord?.webhookUrl,
|
webhookUrl: notification.discord?.webhookUrl,
|
||||||
decoration: notification.discord?.decoration || undefined,
|
decoration: notification.discord?.decoration ?? undefined,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
@@ -400,7 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
appToken: notification.gotify?.appToken,
|
appToken: notification.gotify?.appToken,
|
||||||
decoration: notification.gotify?.decoration || undefined,
|
decoration: notification.gotify?.decoration ?? undefined,
|
||||||
priority: notification.gotify?.priority,
|
priority: notification.gotify?.priority,
|
||||||
serverUrl: notification.gotify?.serverUrl,
|
serverUrl: notification.gotify?.serverUrl,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
@@ -435,6 +451,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
serverThreshold: notification.serverThreshold,
|
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") {
|
} else if (notification.notificationType === "custom") {
|
||||||
form.reset({
|
form.reset({
|
||||||
appBuildError: notification.appBuildError,
|
appBuildError: notification.appBuildError,
|
||||||
@@ -488,6 +517,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
|
teams: teamsMutation,
|
||||||
custom: customMutation,
|
custom: customMutation,
|
||||||
pushover: pushoverMutation,
|
pushover: pushoverMutation,
|
||||||
};
|
};
|
||||||
@@ -630,6 +660,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
larkId: notification?.larkId || "",
|
larkId: notification?.larkId || "",
|
||||||
serverThreshold: serverThreshold,
|
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") {
|
} else if (data.type === "custom") {
|
||||||
// Convert headers array to object
|
// Convert headers array to object
|
||||||
const headersRecord =
|
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" && (
|
{type === "pushover" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1780,6 +1850,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingLark ||
|
isLoadingLark ||
|
||||||
|
isLoadingTeams ||
|
||||||
isLoadingCustom ||
|
isLoadingCustom ||
|
||||||
isLoadingPushover
|
isLoadingPushover
|
||||||
}
|
}
|
||||||
@@ -1841,6 +1912,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "teams") {
|
||||||
|
await testTeamsConnection({
|
||||||
|
webhookUrl: data.webhookUrl,
|
||||||
|
});
|
||||||
} else if (data.type === "custom") {
|
} else if (data.type === "custom") {
|
||||||
const headersRecord =
|
const headersRecord =
|
||||||
data.headers && data.headers.length > 0
|
data.headers && data.headers.length > 0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
NtfyIcon,
|
NtfyIcon,
|
||||||
ResendIcon,
|
ResendIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
|
TeamsIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -37,7 +38,7 @@ export const ShowNotifications = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add your providers to receive notifications, like Discord, Slack,
|
Add your providers to receive notifications, like Discord, Slack,
|
||||||
Telegram, Email, Resend, Lark.
|
Telegram, Teams, Email, Resend, Lark.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
@@ -112,6 +113,11 @@ export const ShowNotifications = () => {
|
|||||||
<LarkIcon className="size-7 text-muted-foreground" />
|
<LarkIcon className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{notification.notificationType === "teams" && (
|
||||||
|
<div className="flex items-center justify-center rounded-lg">
|
||||||
|
<TeamsIcon className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{notification.name}
|
{notification.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2, Palette, User } from "lucide-react";
|
import { Loader2, Palette, User } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -73,7 +72,6 @@ export const ProfileForm = () => {
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = api.user.update.useMutation();
|
} = api.user.update.useMutation();
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -157,10 +155,10 @@ export const ProfileForm = () => {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<User className="size-6 text-muted-foreground self-center" />
|
<User className="size-6 text-muted-foreground self-center" />
|
||||||
{t("settings.profile.title")}
|
Account
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("settings.profile.description")}
|
Change the details of your profile here.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,12 +211,9 @@ export const ProfileForm = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("settings.profile.email")}</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="Email" {...field} />
|
||||||
placeholder={t("settings.profile.email")}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -233,7 +228,7 @@ export const ProfileForm = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t("settings.profile.password")}
|
placeholder="Current Password"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
@@ -247,13 +242,11 @@ export const ProfileForm = () => {
|
|||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
{t("settings.profile.password")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t("settings.profile.password")}
|
placeholder="Password"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
@@ -268,9 +261,7 @@ export const ProfileForm = () => {
|
|||||||
name="image"
|
name="image"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Avatar</FormLabel>
|
||||||
{t("settings.profile.avatar")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
@@ -454,7 +445,7 @@ export const ProfileForm = () => {
|
|||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" isLoading={isUpdating}>
|
<Button type="submit" isLoading={isUpdating}>
|
||||||
{t("settings.common.save")}
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
|
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,7 +16,6 @@ import { TerminalModal } from "../../web-server/terminal-modal";
|
|||||||
import { GPUSupportModal } from "../gpu-support-modal";
|
import { GPUSupportModal } from "../gpu-support-modal";
|
||||||
|
|
||||||
export const ShowDokployActions = () => {
|
export const ShowDokployActions = () => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const { mutateAsync: reloadServer, isLoading } =
|
const { mutateAsync: reloadServer, isLoading } =
|
||||||
api.settings.reloadServer.useMutation();
|
api.settings.reloadServer.useMutation();
|
||||||
|
|
||||||
@@ -30,13 +28,11 @@ export const ShowDokployActions = () => {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||||
<Button isLoading={isLoading} variant="outline">
|
<Button isLoading={isLoading} variant="outline">
|
||||||
{t("settings.server.webServer.server.label")}
|
Server
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="start">
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
{t("settings.server.webServer.actions")}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -51,17 +47,17 @@ export const ShowDokployActions = () => {
|
|||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>{t("settings.server.webServer.reload")}</span>
|
<span>Reload</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<TerminalModal serverId="local">
|
<TerminalModal serverId="local">
|
||||||
<span>{t("settings.common.enterTerminal")}</span>
|
<span>Terminal</span>
|
||||||
</TerminalModal>
|
</TerminalModal>
|
||||||
<ShowModalLogs appName="dokploy">
|
<ShowModalLogs appName="dokploy">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{t("settings.server.webServer.watchLogs")}
|
View Logs
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ShowModalLogs>
|
</ShowModalLogs>
|
||||||
<GPUSupportModal />
|
<GPUSupportModal />
|
||||||
@@ -70,7 +66,7 @@ export const ShowDokployActions = () => {
|
|||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{t("settings.server.webServer.updateServerIp")}
|
Update Server IP
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</UpdateServerIp>
|
</UpdateServerIp>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +15,6 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ShowStorageActions = ({ serverId }: Props) => {
|
export const ShowStorageActions = ({ serverId }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
||||||
api.settings.cleanAll.useMutation();
|
api.settings.cleanAll.useMutation();
|
||||||
|
|
||||||
@@ -64,13 +62,11 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{t("settings.server.webServer.storage.label")}
|
Space
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-64" align="start">
|
<DropdownMenuContent className="w-64" align="start">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
{t("settings.server.webServer.actions")}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -87,9 +83,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>Clean unused images</span>
|
||||||
{t("settings.server.webServer.storage.cleanUnusedImages")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
@@ -105,9 +99,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>Clean unused volumes</span>
|
||||||
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -124,9 +116,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>Clean stopped containers</span>
|
||||||
{t("settings.server.webServer.storage.cleanStoppedContainers")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -143,9 +133,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>Clean Docker Builder & System</span>
|
||||||
{t("settings.server.webServer.storage.cleanDockerBuilder")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!serverId && (
|
{!serverId && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -160,9 +148,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>Clean Monitoring</span>
|
||||||
{t("settings.server.webServer.storage.cleanMonitoring")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -180,7 +166,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
|
<span>Clean all</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -22,7 +21,6 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ShowTraefikActions = ({ serverId }: Props) => {
|
export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
|
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
|
||||||
api.settings.reloadTraefik.useMutation();
|
api.settings.reloadTraefik.useMutation();
|
||||||
|
|
||||||
@@ -75,13 +73,11 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{t("settings.server.webServer.traefik.label")}
|
Traefik
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="start">
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
{t("settings.server.webServer.actions")}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -100,7 +96,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
disabled={isReloadHealthCheckExecuting}
|
disabled={isReloadHealthCheckExecuting}
|
||||||
>
|
>
|
||||||
<span>{t("settings.server.webServer.reload")}</span>
|
<span>Reload</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<ShowModalLogs
|
<ShowModalLogs
|
||||||
appName="dokploy-traefik"
|
appName="dokploy-traefik"
|
||||||
@@ -111,7 +107,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("settings.server.webServer.watchLogs")}
|
View Logs
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ShowModalLogs>
|
</ShowModalLogs>
|
||||||
<EditTraefikEnv serverId={serverId}>
|
<EditTraefikEnv serverId={serverId}>
|
||||||
@@ -119,7 +115,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
|
<span>Modify Environment</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</EditTraefikEnv>
|
</EditTraefikEnv>
|
||||||
|
|
||||||
@@ -176,7 +172,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
|
<span>Additional Port Mappings</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ManageTraefikPorts>
|
</ManageTraefikPorts>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil, PlusIcon } from "lucide-react";
|
import { Pencil, PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -63,8 +62,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: canCreateMoreServers, refetch } =
|
const { data: canCreateMoreServers, refetch } =
|
||||||
@@ -365,7 +362,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
name="ipAddress"
|
name="ipAddress"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
|
<FormLabel>IP Address</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="192.168.1.100" {...field} />
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -379,7 +376,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
name="port"
|
name="port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("settings.terminal.port")}</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="22"
|
placeholder="22"
|
||||||
@@ -409,7 +406,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("settings.terminal.username")}</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="root" {...field} />
|
<Input placeholder="root" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
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";
|
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||||
|
|
||||||
export const ShowServers = () => {
|
export const ShowServers = () => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const query = router.query;
|
const query = router.query;
|
||||||
const { data, refetch, isLoading } = api.server.all.useQuery();
|
const { data, refetch, isLoading } = api.server.all.useQuery();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { GlobeIcon } from "lucide-react";
|
import { GlobeIcon } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -66,7 +65,6 @@ const addServerDomain = z
|
|||||||
type AddServerDomain = z.infer<typeof addServerDomain>;
|
type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||||
|
|
||||||
export const WebDomain = () => {
|
export const WebDomain = () => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.settings.assignDomainServer.useMutation();
|
api.settings.assignDomainServer.useMutation();
|
||||||
@@ -119,10 +117,10 @@ export const WebDomain = () => {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<GlobeIcon className="size-6 text-muted-foreground self-center" />
|
<GlobeIcon className="size-6 text-muted-foreground self-center" />
|
||||||
{t("settings.server.domain.title")}
|
Server Domain
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("settings.server.domain.description")}
|
Add a domain to your server application.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -151,9 +149,7 @@ export const WebDomain = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Domain</FormLabel>
|
||||||
{t("settings.server.domain.form.domain")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -173,9 +169,7 @@ export const WebDomain = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Let's Encrypt Email</FormLabel>
|
||||||
{t("settings.server.domain.form.letsEncryptEmail")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -216,32 +210,20 @@ export const WebDomain = () => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<FormLabel>
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
{t("settings.server.domain.form.certificate.label")}
|
|
||||||
</FormLabel>
|
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue placeholder="Select a certificate" />
|
||||||
placeholder={t(
|
|
||||||
"settings.server.domain.form.certificate.placeholder",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={"none"}>
|
<SelectItem value={"none"}>None</SelectItem>
|
||||||
{t(
|
|
||||||
"settings.server.domain.form.certificateOptions.none",
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={"letsencrypt"}>
|
<SelectItem value={"letsencrypt"}>
|
||||||
{t(
|
Let's Encrypt
|
||||||
"settings.server.domain.form.certificateOptions.letsencrypt",
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -254,7 +236,7 @@ export const WebDomain = () => {
|
|||||||
|
|
||||||
<div className="flex w-full justify-end col-span-2">
|
<div className="flex w-full justify-end col-span-2">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
{t("settings.common.save")}
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ServerIcon } from "lucide-react";
|
import { ServerIcon } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -15,7 +14,6 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
|
|||||||
import { UpdateServer } from "./web-server/update-server";
|
import { UpdateServer } from "./web-server/update-server";
|
||||||
|
|
||||||
export const WebServer = () => {
|
export const WebServer = () => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const { data: webServerSettings } =
|
const { data: webServerSettings } =
|
||||||
api.settings.getWebServerSettings.useQuery();
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
|
||||||
@@ -29,18 +27,16 @@ export const WebServer = () => {
|
|||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<ServerIcon className="size-6 text-muted-foreground self-center" />
|
<ServerIcon className="size-6 text-muted-foreground self-center" />
|
||||||
{t("settings.server.webServer.title")}
|
Web Server
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Reload or clean the web server.</CardDescription>
|
||||||
{t("settings.server.webServer.description")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{/* <CardHeader>
|
{/* <CardHeader>
|
||||||
<CardTitle className="text-xl">
|
<CardTitle className="text-xl">
|
||||||
{t("settings.server.webServer.title")}
|
Web Server
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("settings.server.webServer.description")}
|
Reload or clean the web server.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader> */}
|
</CardHeader> */}
|
||||||
<CardContent className="space-y-6 py-6 border-t">
|
<CardContent className="space-y-6 py-6 border-t">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -52,8 +51,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LocalServerConfig = ({ onSave }: Props) => {
|
const LocalServerConfig = ({ onSave }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: getLocalServerData(),
|
defaultValues: getLocalServerData(),
|
||||||
resolver: zodResolver(Schema),
|
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 items-center gap-2 justify-between w-full">
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
<span className="dark:hover:text-white">
|
<span className="dark:hover:text-white">Connection settings</span>
|
||||||
{t("settings.terminal.connectionSettings")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
@@ -96,7 +91,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
name="port"
|
name="port"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("settings.terminal.port")}</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
@@ -124,7 +119,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("settings.terminal.username")}</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="root" {...field} />
|
<Input placeholder="root" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
|
|||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
disabled={!form.formState.isDirty}
|
disabled={!form.formState.isDirty}
|
||||||
>
|
>
|
||||||
{t("settings.common.save")}
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -56,7 +55,6 @@ const TraefikPortsSchema = z.object({
|
|||||||
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
|
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
|
||||||
|
|
||||||
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TraefikPortsForm>({
|
const form = useForm<TraefikPortsForm>({
|
||||||
@@ -84,7 +82,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
isExecuting: isHealthCheckExecuting,
|
isExecuting: isHealthCheckExecuting,
|
||||||
} = useHealthCheckAfterMutation({
|
} = useHealthCheckAfterMutation({
|
||||||
initialDelay: 5000,
|
initialDelay: 5000,
|
||||||
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
|
successMessage: "Ports updated successfully",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetchPorts();
|
refetchPorts();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -129,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
<DialogContent className="sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
{t("settings.server.webServer.traefik.managePorts")}
|
Additional Port Mappings
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-base w-full">
|
<DialogDescription className="text-base w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{t(
|
Add or remove additional ports for Traefik
|
||||||
"settings.server.webServer.traefik.managePortsDescription",
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
|
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
|
||||||
configured
|
configured
|
||||||
@@ -179,9 +175,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-sm font-medium text-muted-foreground">
|
<FormLabel className="text-sm font-medium text-muted-foreground">
|
||||||
{t(
|
Target Port
|
||||||
"settings.server.webServer.traefik.targetPort",
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -210,9 +204,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-sm font-medium text-muted-foreground">
|
<FormLabel className="text-sm font-medium text-muted-foreground">
|
||||||
{t(
|
Published Port
|
||||||
"settings.server.webServer.traefik.publishedPort",
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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">
|
<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" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{dokployVersion} | {releaseTag}
|
{dokployVersion}{" "}
|
||||||
|
{(releaseTag === "canary" || releaseTag === "feature") &&
|
||||||
|
`(${releaseTag})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -88,6 +88,35 @@ export const DiscordIcon = ({ className }: Props) => {
|
|||||||
</svg>
|
</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) => {
|
export const LarkIcon = ({ className }: Props) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -630,135 +630,137 @@ function SidebarLogo() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="rounded-lg"
|
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
|
||||||
align="start"
|
align="start"
|
||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
|
||||||
Organizations
|
Organizations
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{organizations?.map((org) => {
|
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
|
||||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
{organizations?.map((org) => {
|
||||||
return (
|
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||||
<div
|
return (
|
||||||
className="flex flex-row justify-between"
|
<div
|
||||||
key={org.name}
|
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="flex flex-col gap-1">
|
<DropdownMenuItem
|
||||||
<div className="flex items-center gap-2">
|
onClick={async () => {
|
||||||
{org.name}
|
await authClient.organization.setActive({
|
||||||
</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({
|
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
})
|
});
|
||||||
.then(() => {
|
window.location.reload();
|
||||||
refetch();
|
|
||||||
toast.success("Default organization updated");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
error?.message ||
|
|
||||||
"Error setting default organization",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
title={
|
className="w-full gap-2 p-2"
|
||||||
isDefault
|
|
||||||
? "Default organization"
|
|
||||||
: "Set as default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isDefault ? (
|
<div className="flex flex-col gap-1">
|
||||||
<Star
|
<div className="flex items-center gap-2">
|
||||||
fill="#eab308"
|
{org.name}
|
||||||
stroke="#eab308"
|
</div>
|
||||||
className="size-4 text-yellow-500"
|
</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>
|
||||||
<Star
|
</DropdownMenuItem>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
<div className="flex items-center gap-2">
|
||||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
</Button>
|
className={cn(
|
||||||
{org.ownerId === session?.user?.id && (
|
"group",
|
||||||
<>
|
isDefault
|
||||||
<AddOrganization organizationId={org.id} />
|
? "hover:bg-yellow-500/10"
|
||||||
<DialogAction
|
: "hover:bg-blue-500/10",
|
||||||
title="Delete Organization"
|
)}
|
||||||
description="Are you sure you want to delete this organization?"
|
isLoading={isSettingDefault && !isDefault}
|
||||||
type="destructive"
|
disabled={isDefault}
|
||||||
onClick={async () => {
|
onClick={async (e) => {
|
||||||
await deleteOrganization({
|
if (isDefault) return;
|
||||||
organizationId: org.id,
|
e.stopPropagation();
|
||||||
|
await setDefaultOrganization({
|
||||||
|
organizationId: org.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success("Default organization updated");
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch((error) => {
|
||||||
refetch();
|
toast.error(
|
||||||
toast.success(
|
error?.message ||
|
||||||
"Organization deleted successfully",
|
"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) => {
|
.then(() => {
|
||||||
toast.error(
|
refetch();
|
||||||
error?.message ||
|
toast.success(
|
||||||
"Error deleting organization",
|
"Organization deleted successfully",
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
}}
|
.catch((error) => {
|
||||||
>
|
toast.error(
|
||||||
<Button
|
error?.message ||
|
||||||
variant="ghost"
|
"Error deleting organization",
|
||||||
size="icon"
|
);
|
||||||
className="group hover:bg-red-500/10"
|
});
|
||||||
isLoading={isRemoving}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
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>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
{(user?.role === "owner" ||
|
{(user?.role === "owner" ||
|
||||||
user?.role === "admin" ||
|
user?.role === "admin" ||
|
||||||
isCloud) && (
|
isCloud) && (
|
||||||
|
|||||||
@@ -10,18 +10,9 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { Languages } from "@/lib/languages";
|
|
||||||
import { getFallbackAvatarInitials } from "@/lib/utils";
|
import { getFallbackAvatarInitials } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import useLocale from "@/utils/hooks/use-locale";
|
|
||||||
import { ModeToggle } from "../ui/modeToggle";
|
import { ModeToggle } from "../ui/modeToggle";
|
||||||
import { SidebarMenuButton } from "../ui/sidebar";
|
import { SidebarMenuButton } from "../ui/sidebar";
|
||||||
|
|
||||||
@@ -32,7 +23,6 @@ export const UserNav = () => {
|
|||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
const { locale, setLocale } = useLocale();
|
|
||||||
// const { mutateAsync } = api.auth.logout.useMutation();
|
// const { mutateAsync } = api.auth.logout.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,39 +145,19 @@ export const UserNav = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="flex items-center justify-between px-2 py-1.5">
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
className="cursor-pointer"
|
||||||
className="cursor-pointer"
|
onClick={async () => {
|
||||||
onClick={async () => {
|
await authClient.signOut().then(() => {
|
||||||
await authClient.signOut().then(() => {
|
router.push("/");
|
||||||
router.push("/");
|
});
|
||||||
});
|
// await mutateAsync().then(() => {
|
||||||
// await mutateAsync().then(() => {
|
// router.push("/");
|
||||||
// router.push("/");
|
// });
|
||||||
// });
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Log out
|
||||||
Log out
|
</DropdownMenuItem>
|
||||||
</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>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { FieldArrayPath } from "react-hook-form";
|
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 { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
|
|
||||||
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ const oidcProviderSchema = z.object({
|
|||||||
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
||||||
|
|
||||||
interface RegisterOidcDialogProps {
|
interface RegisterOidcDialogProps {
|
||||||
|
providerId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,16 +72,86 @@ const formDefaultValues = {
|
|||||||
scopes: [...DEFAULT_SCOPES],
|
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 utils = api.useUtils();
|
||||||
const [open, setOpen] = useState(false);
|
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>({
|
const form = useForm<OidcProviderForm>({
|
||||||
resolver: zodResolver(oidcProviderSchema),
|
resolver: zodResolver(oidcProviderSchema),
|
||||||
defaultValues: formDefaultValues,
|
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({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "domains" as FieldArrayPath<OidcProviderForm>,
|
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);
|
form.reset(formDefaultValues);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
await utils.sso.listProviders.invalidate();
|
await utils.sso.listProviders.invalidate();
|
||||||
@@ -146,11 +222,13 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
|||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Register OIDC provider</DialogTitle>
|
<DialogTitle>
|
||||||
|
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
|
{isEdit
|
||||||
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
|
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
|
||||||
from the issuer URL when possible.
|
: "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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -162,11 +240,28 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Provider ID</FormLabel>
|
<FormLabel>Provider ID</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Unique identifier; used in callback URL path.
|
Unique identifier; used in callback URL path.
|
||||||
|
{isEdit && " Cannot be changed when editing."}
|
||||||
</FormDescription>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -341,7 +436,7 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Register provider
|
{isEdit ? "Update provider" : "Register provider"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "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 { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,6 +33,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
|
|
||||||
const domainsArraySchema = z
|
const domainsArraySchema = z
|
||||||
.array(z.string().trim())
|
.array(z.string().trim())
|
||||||
@@ -58,6 +64,7 @@ const samlProviderSchema = z.object({
|
|||||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||||
|
|
||||||
interface RegisterSamlDialogProps {
|
interface RegisterSamlDialogProps {
|
||||||
|
providerId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,24 +77,83 @@ const formDefaultValues: SamlProviderForm = {
|
|||||||
idpMetadataXml: "",
|
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 utils = api.useUtils();
|
||||||
const [open, setOpen] = useState(false);
|
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(() => {
|
const isEdit = !!providerId;
|
||||||
if (typeof window !== "undefined") {
|
const mutateAsync = isEdit
|
||||||
setBaseURL(window.location.origin);
|
? updateMutation.mutateAsync
|
||||||
}
|
: registerMutation.mutateAsync;
|
||||||
}, []);
|
const isLoading = isEdit
|
||||||
|
? updateMutation.isLoading
|
||||||
|
: registerMutation.isLoading;
|
||||||
|
|
||||||
|
const baseURL = useUrl();
|
||||||
|
|
||||||
const form = useForm<SamlProviderForm>({
|
const form = useForm<SamlProviderForm>({
|
||||||
resolver: zodResolver(samlProviderSchema),
|
resolver: zodResolver(samlProviderSchema),
|
||||||
defaultValues: formDefaultValues,
|
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({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "domains" as FieldArrayPath<SamlProviderForm>,
|
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);
|
form.reset(formDefaultValues);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
await utils.sso.listProviders.invalidate();
|
await utils.sso.listProviders.invalidate();
|
||||||
@@ -149,10 +219,13 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
|||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Register SAML provider</DialogTitle>
|
<DialogTitle>
|
||||||
|
{isEdit ? "Update SAML provider" : "Register SAML provider"}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
|
{isEdit
|
||||||
OneLogin). You need the IdP's SSO URL and signing certificate.
|
? "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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -167,8 +240,26 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g. okta-saml or azure-saml"
|
placeholder="e.g. okta-saml or azure-saml"
|
||||||
{...field}
|
{...field}
|
||||||
|
readOnly={isEdit}
|
||||||
|
className={isEdit ? "bg-muted" : undefined}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -317,7 +408,7 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Register provider
|
{isEdit ? "Update provider" : "Register provider"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -21,7 +29,9 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
|
|
||||||
@@ -67,29 +77,107 @@ export const SSOSettings = () => {
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [detailsProvider, setDetailsProvider] =
|
const [detailsProvider, setDetailsProvider] =
|
||||||
useState<ProviderForDetails | null>(null);
|
useState<ProviderForDetails | null>(null);
|
||||||
const [baseURL, setBaseURL] = useState("");
|
const baseURL = useUrl();
|
||||||
|
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
|
||||||
useEffect(() => {
|
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
|
||||||
if (typeof window !== "undefined") {
|
const [editingValue, setEditingValue] = useState("");
|
||||||
setBaseURL(window.location.origin);
|
const [newOriginInput, setNewOriginInput] = useState("");
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
||||||
|
const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery(
|
||||||
|
undefined,
|
||||||
|
{ enabled: manageOriginsOpen },
|
||||||
|
);
|
||||||
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
||||||
api.sso.deleteProvider.useMutation();
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<LogIn className="size-6 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
<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's IdP.
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<Button
|
||||||
Configure OIDC or SAML identity providers for enterprise sign-in.
|
variant="outline"
|
||||||
Users can sign in with their organization's IdP.
|
size="sm"
|
||||||
</CardDescription>
|
onClick={() => setManageOriginsOpen(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Shield className="mr-2 size-4" />
|
||||||
|
Manage origins
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -177,6 +265,22 @@ export const SSOSettings = () => {
|
|||||||
<Eye className="mr-1 size-3" />
|
<Eye className="mr-1 size-3" />
|
||||||
View details
|
View details
|
||||||
</Button>
|
</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
|
<DialogAction
|
||||||
title="Remove SSO provider"
|
title="Remove SSO provider"
|
||||||
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
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>
|
<DialogHeader>
|
||||||
<DialogTitle>SSO provider details</DialogTitle>
|
<DialogTitle>SSO provider details</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
View-only. To change settings, remove this provider and add it
|
Use Edit to change provider settings (OIDC or SAML).
|
||||||
again with the new values.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3 py-2">
|
<div className="grid gap-3 py-2">
|
||||||
@@ -366,6 +469,128 @@ export const SSOSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
1
apps/dokploy/drizzle/0143_brown_ultron.sql
Normal file
1
apps/dokploy/drizzle/0143_brown_ultron.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||||
8
apps/dokploy/drizzle/0144_odd_gunslinger.sql
Normal file
8
apps/dokploy/drizzle/0144_odd_gunslinger.sql
Normal 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;
|
||||||
7291
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
7291
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7336
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
7336
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1002,6 +1002,20 @@
|
|||||||
"when": 1770615019498,
|
"when": 1770615019498,
|
||||||
"tag": "0142_outstanding_tusk",
|
"tag": "0142_outstanding_tusk",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"];
|
|
||||||
@@ -10,15 +10,6 @@ const nextConfig = {
|
|||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
transpilePackages: ["@dokploy/server"],
|
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() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.27.0",
|
"version": "v0.27.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -41,13 +41,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resend": "^6.0.2",
|
"resend": "^6.0.2",
|
||||||
"@better-auth/sso": "1.4.18",
|
"@better-auth/sso": "1.4.18",
|
||||||
"@ai-sdk/anthropic": "^2.0.5",
|
"@ai-sdk/anthropic": "^3.0.44",
|
||||||
"@ai-sdk/azure": "^2.0.16",
|
"@ai-sdk/azure": "^3.0.30",
|
||||||
"@ai-sdk/cohere": "^2.0.4",
|
"@ai-sdk/cohere": "^3.0.21",
|
||||||
"@ai-sdk/deepinfra": "^1.0.10",
|
"@ai-sdk/deepinfra": "^2.0.34",
|
||||||
"@ai-sdk/mistral": "^2.0.7",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^2.0.16",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.10",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
"@xterm/addon-clipboard": "0.1.0",
|
"@xterm/addon-clipboard": "0.1.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"ai": "^5.0.17",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^0.5.1",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.4.18",
|
"better-auth": "1.4.18",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
@@ -113,7 +113,6 @@
|
|||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"i18next": "^23.16.8",
|
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
@@ -121,7 +120,6 @@
|
|||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-i18next": "^15.4.2",
|
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
@@ -139,7 +137,6 @@
|
|||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-i18next": "^15.5.2",
|
|
||||||
"react-markdown": "^9.1.0",
|
"react-markdown": "^9.1.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
@@ -147,7 +144,7 @@
|
|||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"stripe": "17.2.0",
|
"stripe": "17.2.0",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"swagger-ui-react": "^5.22.0",
|
"swagger-ui-react": "^5.31.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
@@ -156,7 +153,7 @@
|
|||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"zod": "^3.25.32",
|
"zod": "^3.25.76",
|
||||||
"zod-form-data": "^2.0.7",
|
"zod-form-data": "^2.0.7",
|
||||||
"semver": "7.7.3"
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import type { NextPage } from "next";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { appWithTranslation } from "next-i18next";
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import NextTopLoader from "nextjs-toploader";
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Languages } from "@/lib/languages";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -58,14 +56,4 @@ const MyApp = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api.withTRPC(
|
export default api.withTRPC(MyApp);
|
||||||
appWithTranslation(MyApp, {
|
|
||||||
i18n: {
|
|
||||||
defaultLocale: "en",
|
|
||||||
locales: Object.values(Languages).map((language) => language.code),
|
|
||||||
localeDetection: false,
|
|
||||||
},
|
|
||||||
fallbackLng: "en",
|
|
||||||
keySeparator: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ export default async function handler(
|
|||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap(
|
||||||
(commit: any) => commit.modified,
|
(commit: any) => commit.modified,
|
||||||
);
|
);
|
||||||
|
} else if (provider === "soft-serve") {
|
||||||
|
normalizedCommits = req.body?.commits?.flatMap(
|
||||||
|
(commit: any) => commit.modified,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
@@ -439,6 +443,13 @@ export const extractCommitMessage = (headers: any, body: any) => {
|
|||||||
: "NEW COMMIT";
|
: "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 (headers["user-agent"]?.includes("Go-http-client")) {
|
||||||
if (body.push_data && body.repository) {
|
if (body.push_data && body.repository) {
|
||||||
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
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";
|
return body.after || "NEW COMMIT";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft Serve
|
||||||
|
if (headers["x-softserve-event"]) {
|
||||||
|
return body.after || "NEW COMMIT";
|
||||||
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -484,7 +500,10 @@ export const extractBranchName = (headers: any, body: any) => {
|
|||||||
return body?.ref?.replace("refs/heads/", "");
|
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;
|
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +531,10 @@ export const getProviderByHeader = (headers: any) => {
|
|||||||
return "bitbucket";
|
return "bitbucket";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (headers["x-softserve-event"]) {
|
||||||
|
return "soft-serve";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import superjson from "superjson";
|
|||||||
import { AiForm } from "@/components/dashboard/settings/ai-form";
|
import { AiForm } from "@/components/dashboard/settings/ai-form";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +25,6 @@ export async function getServerSideProps(
|
|||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
const locale = getLocale(req.cookies);
|
|
||||||
|
|
||||||
const helpers = createServerSideHelpers({
|
const helpers = createServerSideHelpers({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
@@ -55,7 +53,6 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
...(await serverSideTranslations(locale, ["settings"])),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
|||||||
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
|
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +34,6 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const locale = await getLocale(req.cookies);
|
|
||||||
const { user, session } = await validateRequest(ctx.req);
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -70,7 +68,6 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
...(await serverSideTranslations(locale, ["settings"])),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,24 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
||||||
|
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
|
||||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
// const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
|
{isCloud && <LinkingAccount />}
|
||||||
{(data?.canAccessToAPI ||
|
{(data?.canAccessToAPI ||
|
||||||
data?.role === "owner" ||
|
data?.role === "owner" ||
|
||||||
data?.role === "admin") && <ShowApiKeys />}
|
data?.role === "admin") && <ShowApiKeys />}
|
||||||
|
|
||||||
{/* {isCloud && <RemoveSelfAccount />} */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -37,7 +36,6 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const locale = getLocale(req.cookies);
|
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
const helpers = createServerSideHelpers({
|
const helpers = createServerSideHelpers({
|
||||||
@@ -67,7 +65,6 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
...(await serverSideTranslations(locale, ["settings"])),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const { data: user } = api.user.get.useQuery();
|
const { data: user } = api.user.get.useQuery();
|
||||||
@@ -42,7 +41,6 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const locale = await getLocale(req.cookies);
|
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
@@ -85,7 +83,6 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
...(await serverSideTranslations(locale, ["settings"])),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import superjson from "superjson";
|
|||||||
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
|
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -25,7 +24,6 @@ export async function getServerSideProps(
|
|||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
) {
|
) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const locale = await getLocale(req.cookies);
|
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -61,7 +59,6 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
...(await serverSideTranslations(locale, ["settings"])),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
|
|||||||
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +42,6 @@ Page.getLayout = (page: ReactElement) => {
|
|||||||
|
|
||||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const locale = await getLocale(req.cookies);
|
|
||||||
const { user, session } = await validateRequest(ctx.req);
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -78,7 +76,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
...(await serverSideTranslations(locale, ["settings"])),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
setIsLoginLoading(false);
|
setIsLoginLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (twoFactorCode.length !== 6) {
|
if (twoFactorCode.length !== 6) {
|
||||||
@@ -254,7 +253,6 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
onChange={setTwoFactorCode}
|
onChange={setTwoFactorCode}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
pattern={REGEXP_ONLY_DIGITS}
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addNewService,
|
addNewService,
|
||||||
checkServiceAccess,
|
checkServiceAccess,
|
||||||
|
clearOldDeployments,
|
||||||
createApplication,
|
createApplication,
|
||||||
deleteAllMiddlewares,
|
deleteAllMiddlewares,
|
||||||
findApplicationById,
|
findApplicationById,
|
||||||
@@ -746,6 +747,23 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
await cleanQueuesByApplication(input.applicationId);
|
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
|
killBuild: protectedProcedure
|
||||||
.input(apiFindOneApplication)
|
.input(apiFindOneApplication)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
addDomainToCompose,
|
addDomainToCompose,
|
||||||
addNewService,
|
addNewService,
|
||||||
checkServiceAccess,
|
checkServiceAccess,
|
||||||
|
clearOldDeployments,
|
||||||
cloneCompose,
|
cloneCompose,
|
||||||
createCommand,
|
createCommand,
|
||||||
createCompose,
|
createCompose,
|
||||||
@@ -263,6 +264,23 @@ export const composeRouter = createTRPCRouter({
|
|||||||
await cleanQueuesByCompose(input.composeId);
|
await cleanQueuesByCompose(input.composeId);
|
||||||
return { success: true, message: "Queues cleaned successfully" };
|
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
|
killBuild: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
findComposeById,
|
findComposeById,
|
||||||
findDeploymentById,
|
findDeploymentById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
removeDeployment,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -107,4 +108,14 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
removeDeployment: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deploymentId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await removeDeployment(input.deploymentId);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createPushoverNotification,
|
createPushoverNotification,
|
||||||
createResendNotification,
|
createResendNotification,
|
||||||
createSlackNotification,
|
createSlackNotification,
|
||||||
|
createTeamsNotification,
|
||||||
createTelegramNotification,
|
createTelegramNotification,
|
||||||
findNotificationById,
|
findNotificationById,
|
||||||
getWebServerSettings,
|
getWebServerSettings,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
sendResendNotification,
|
sendResendNotification,
|
||||||
sendServerThresholdNotifications,
|
sendServerThresholdNotifications,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
|
sendTeamsNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
updateCustomNotification,
|
updateCustomNotification,
|
||||||
updateDiscordNotification,
|
updateDiscordNotification,
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
updatePushoverNotification,
|
updatePushoverNotification,
|
||||||
updateResendNotification,
|
updateResendNotification,
|
||||||
updateSlackNotification,
|
updateSlackNotification,
|
||||||
|
updateTeamsNotification,
|
||||||
updateTelegramNotification,
|
updateTelegramNotification,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -55,6 +58,7 @@ import {
|
|||||||
apiCreatePushover,
|
apiCreatePushover,
|
||||||
apiCreateResend,
|
apiCreateResend,
|
||||||
apiCreateSlack,
|
apiCreateSlack,
|
||||||
|
apiCreateTeams,
|
||||||
apiCreateTelegram,
|
apiCreateTelegram,
|
||||||
apiFindOneNotification,
|
apiFindOneNotification,
|
||||||
apiTestCustomConnection,
|
apiTestCustomConnection,
|
||||||
@@ -66,6 +70,7 @@ import {
|
|||||||
apiTestPushoverConnection,
|
apiTestPushoverConnection,
|
||||||
apiTestResendConnection,
|
apiTestResendConnection,
|
||||||
apiTestSlackConnection,
|
apiTestSlackConnection,
|
||||||
|
apiTestTeamsConnection,
|
||||||
apiTestTelegramConnection,
|
apiTestTelegramConnection,
|
||||||
apiUpdateCustom,
|
apiUpdateCustom,
|
||||||
apiUpdateDiscord,
|
apiUpdateDiscord,
|
||||||
@@ -76,6 +81,7 @@ import {
|
|||||||
apiUpdatePushover,
|
apiUpdatePushover,
|
||||||
apiUpdateResend,
|
apiUpdateResend,
|
||||||
apiUpdateSlack,
|
apiUpdateSlack,
|
||||||
|
apiUpdateTeams,
|
||||||
apiUpdateTelegram,
|
apiUpdateTelegram,
|
||||||
notifications,
|
notifications,
|
||||||
server,
|
server,
|
||||||
@@ -413,6 +419,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
pushover: true,
|
pushover: true,
|
||||||
|
teams: true,
|
||||||
},
|
},
|
||||||
orderBy: desc(notifications.createdAt),
|
orderBy: desc(notifications.createdAt),
|
||||||
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
|
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
|
createPushover: adminProcedure
|
||||||
.input(apiCreatePushover)
|
.input(apiCreatePushover)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { user } from "@dokploy/server/db/schema";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -184,18 +184,7 @@ export const licenseKeyRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
|
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
|
||||||
const currentUserId = ctx.user.id;
|
return await hasValidLicense(ctx.session.activeOrganizationId);
|
||||||
const currentUser = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, currentUserId),
|
|
||||||
columns: {
|
|
||||||
enableEnterpriseFeatures: true,
|
|
||||||
isValidEnterpriseLicense: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return !!(
|
|
||||||
currentUser?.enableEnterpriseFeatures &&
|
|
||||||
currentUser?.isValidEnterpriseLicense
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
updateEnterpriseSettings: adminProcedure
|
updateEnterpriseSettings: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
|
|||||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
||||||
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
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 { auth } from "@dokploy/server/lib/auth";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
@@ -55,9 +58,148 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
samlConfig: true,
|
samlConfig: true,
|
||||||
organizationId: true,
|
organizationId: true,
|
||||||
},
|
},
|
||||||
|
orderBy: [asc(ssoProvider.createdAt)],
|
||||||
});
|
});
|
||||||
return providers;
|
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
|
deleteProvider: enterpriseProcedure
|
||||||
.input(z.object({ providerId: z.string().min(1) }))
|
.input(z.object({ providerId: z.string().min(1) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
register: enterpriseProcedure
|
register: enterpriseProcedure
|
||||||
@@ -147,25 +271,6 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const domain = input.domains.join(",");
|
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({
|
await auth.registerSSOProvider({
|
||||||
body: {
|
body: {
|
||||||
@@ -177,4 +282,92 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return { success: true };
|
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 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getStripeItems, WEBSITE_URL } from "@/server/utils/stripe";
|
import {
|
||||||
|
getStripeItems,
|
||||||
|
PRODUCT_ANNUAL_ID,
|
||||||
|
PRODUCT_MONTHLY_ID,
|
||||||
|
WEBSITE_URL,
|
||||||
|
} from "@/server/utils/stripe";
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc";
|
import { adminProcedure, createTRPCRouter } from "../trpc";
|
||||||
|
|
||||||
export const stripeRouter = createTRPCRouter({
|
export const stripeRouter = createTRPCRouter({
|
||||||
@@ -24,9 +29,15 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredProducts = products.data.filter((product) => {
|
||||||
|
return (
|
||||||
|
product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!stripeCustomerId) {
|
if (!stripeCustomerId) {
|
||||||
return {
|
return {
|
||||||
products: products.data,
|
products: filteredProducts,
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -38,7 +49,7 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
products: products.data,
|
products: filteredProducts,
|
||||||
subscriptions: subscriptions.data,
|
subscriptions: subscriptions.data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* need to use are documented accordingly near the end.
|
* need to use are documented accordingly near the end.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { hasValidLicense } from "@dokploy/server/index";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
@@ -239,10 +240,11 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
|
|||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const hasValidLicenseResult = await hasValidLicense(
|
||||||
!ctx.user?.enableEnterpriseFeatures ||
|
ctx.session.activeOrganizationId,
|
||||||
!ctx.user.isValidEnterpriseLicense
|
);
|
||||||
) {
|
|
||||||
|
if (!hasValidLicenseResult) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Valid enterprise license required",
|
message: "Valid enterprise license required",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
deployApplication,
|
deployApplication,
|
||||||
deployCompose,
|
deployCompose,
|
||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
|
IS_CLOUD,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
rebuildPreviewApplication,
|
rebuildPreviewApplication,
|
||||||
@@ -13,70 +14,83 @@ import { type Job, Worker } from "bullmq";
|
|||||||
import type { DeploymentJob } from "./queue-types";
|
import type { DeploymentJob } from "./queue-types";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
export const deploymentWorker = new Worker(
|
const createDeploymentWorker = () =>
|
||||||
"deployments",
|
new Worker(
|
||||||
async (job: Job<DeploymentJob>) => {
|
"deployments",
|
||||||
try {
|
async (job: Job<DeploymentJob>) => {
|
||||||
if (job.data.applicationType === "application") {
|
try {
|
||||||
await updateApplicationStatus(job.data.applicationId, "running");
|
if (job.data.applicationType === "application") {
|
||||||
|
await updateApplicationStatus(job.data.applicationId, "running");
|
||||||
|
|
||||||
if (job.data.type === "redeploy") {
|
if (job.data.type === "redeploy") {
|
||||||
await rebuildApplication({
|
await rebuildApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "deploy") {
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (job.data.applicationType === "compose") {
|
||||||
|
await updateCompose(job.data.composeId, {
|
||||||
|
composeStatus: "running",
|
||||||
});
|
});
|
||||||
} else if (job.data.type === "deploy") {
|
if (job.data.type === "deploy") {
|
||||||
await deployApplication({
|
await deployCompose({
|
||||||
applicationId: job.data.applicationId,
|
composeId: job.data.composeId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "redeploy") {
|
||||||
|
await rebuildCompose({
|
||||||
|
composeId: job.data.composeId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (job.data.applicationType === "application-preview") {
|
||||||
|
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||||
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else if (job.data.applicationType === "compose") {
|
|
||||||
await updateCompose(job.data.composeId, {
|
|
||||||
composeStatus: "running",
|
|
||||||
});
|
|
||||||
if (job.data.type === "deploy") {
|
|
||||||
await deployCompose({
|
|
||||||
composeId: job.data.composeId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
} else if (job.data.type === "redeploy") {
|
|
||||||
await rebuildCompose({
|
|
||||||
composeId: job.data.composeId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (job.data.applicationType === "application-preview") {
|
|
||||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
|
||||||
previewStatus: "running",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (job.data.type === "redeploy") {
|
if (job.data.type === "redeploy") {
|
||||||
await rebuildPreviewApplication({
|
await rebuildPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
});
|
});
|
||||||
} else if (job.data.type === "deploy") {
|
} else if (job.data.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error", error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.log("Error", error);
|
{
|
||||||
}
|
autorun: false,
|
||||||
},
|
connection: redisConfig,
|
||||||
{
|
},
|
||||||
autorun: false,
|
);
|
||||||
connection: redisConfig,
|
|
||||||
},
|
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
||||||
);
|
const noopWorker = {
|
||||||
|
run: () => Promise.resolve(),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
cancelJob: () => Promise.resolve(),
|
||||||
|
cancelAllJobs: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deploymentWorker = !IS_CLOUD
|
||||||
|
? createDeploymentWorker()
|
||||||
|
: (noopWorker as unknown as Worker<DeploymentJob>);
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import type { Job } from "bullmq";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { deploymentWorker } from "./deployments-queue";
|
import { deploymentWorker } from "./deployments-queue";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
const myQueue = new Queue("deployments", {
|
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
||||||
connection: redisConfig,
|
const createNoopQueue = () => ({
|
||||||
|
getJobs: () => Promise.resolve([] as Job[]),
|
||||||
|
add: () =>
|
||||||
|
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
on: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const myQueue = !IS_CLOUD
|
||||||
|
? new Queue("deployments", { connection: redisConfig })
|
||||||
|
: (createNoopQueue() as unknown as Queue);
|
||||||
|
|
||||||
export const getJobsByApplicationId = async (applicationId: string) => {
|
export const getJobsByApplicationId = async (applicationId: string) => {
|
||||||
const jobs = await myQueue.getJobs();
|
const jobs = await myQueue.getJobs();
|
||||||
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
||||||
@@ -20,19 +31,21 @@ export const getJobsByComposeId = async (composeId: string) => {
|
|||||||
return jobs.filter((job) => job?.data?.composeId === composeId);
|
return jobs.filter((job) => job?.data?.composeId === composeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
if (!IS_CLOUD) {
|
||||||
myQueue.close();
|
process.on("SIGTERM", () => {
|
||||||
process.exit(0);
|
myQueue.close();
|
||||||
});
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
myQueue.on("error", (error) => {
|
myQueue.on("error", (error) => {
|
||||||
if ((error as any).code === "ECONNREFUSED") {
|
if ((error as any).code === "ECONNREFUSED") {
|
||||||
console.error(
|
console.error(
|
||||||
"Make sure you have installed Redis and it is running.",
|
"Make sure you have installed Redis and it is running.",
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const cleanQueuesByApplication = async (applicationId: string) => {
|
export const cleanQueuesByApplication = async (applicationId: string) => {
|
||||||
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ function isNetworkError(error: unknown): boolean {
|
|||||||
if (error.message === "fetch failed") return true;
|
if (error.message === "fetch failed") return true;
|
||||||
const cause = (error as Error & { cause?: { code?: string } }).cause;
|
const cause = (error as Error & { cause?: { code?: string } }).cause;
|
||||||
const code = cause?.code;
|
const code = cause?.code;
|
||||||
return code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT";
|
return (
|
||||||
|
code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ export const WEBSITE_URL =
|
|||||||
? "http://localhost:3000"
|
? "http://localhost:3000"
|
||||||
: process.env.SITE_URL;
|
: process.env.SITE_URL;
|
||||||
|
|
||||||
const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
|
export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
|
||||||
|
|
||||||
const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
|
export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
|
||||||
|
|
||||||
|
export const PRODUCT_MONTHLY_ID = process.env.PRODUCT_MONTHLY_ID!;
|
||||||
|
export const PRODUCT_ANNUAL_ID = process.env.PRODUCT_ANNUAL_ID!;
|
||||||
|
|
||||||
export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
|
export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
|||||||
import { spawn } from "node-pty";
|
import { spawn } from "node-pty";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { getShell, isValidContainerId } from "./utils";
|
import {
|
||||||
|
getShell,
|
||||||
|
isValidContainerId,
|
||||||
|
isValidSearch,
|
||||||
|
isValidSince,
|
||||||
|
isValidTail,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
export const setupDockerContainerLogsWebSocketServer = (
|
export const setupDockerContainerLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
@@ -30,9 +36,9 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
wssTerm.on("connection", async (ws, req) => {
|
wssTerm.on("connection", async (ws, req) => {
|
||||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||||
const containerId = url.searchParams.get("containerId");
|
const containerId = url.searchParams.get("containerId");
|
||||||
const tail = url.searchParams.get("tail");
|
const tail = url.searchParams.get("tail") ?? "100";
|
||||||
const search = url.searchParams.get("search");
|
const search = url.searchParams.get("search") ?? "";
|
||||||
const since = url.searchParams.get("since");
|
const since = url.searchParams.get("since") ?? "all";
|
||||||
const serverId = url.searchParams.get("serverId");
|
const serverId = url.searchParams.get("serverId");
|
||||||
const runType = url.searchParams.get("runType");
|
const runType = url.searchParams.get("runType");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
@@ -48,6 +54,21 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
return;
|
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) {
|
if (!user || !session) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type http from "node:http";
|
import type http from "node:http";
|
||||||
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
|
import { readValidDirectory } from "@dokploy/server/wss/utils";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { readValidDirectory } from "./utils";
|
|
||||||
|
|
||||||
export const setupDeploymentLogsWebSocketServer = (
|
export const setupDeploymentLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
|
|||||||
@@ -15,6 +15,37 @@ export const isValidContainerId = (id: string): boolean => {
|
|||||||
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
|
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.
|
* Validates that the shell is one of the allowed shells.
|
||||||
*/
|
*/
|
||||||
@@ -32,20 +63,6 @@ export const isValidShell = (shell: string): boolean => {
|
|||||||
return allowedShells.includes(shell);
|
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 = () => {
|
export const getShell = () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return "NO_AVAILABLE";
|
return "NO_AVAILABLE";
|
||||||
|
|||||||
@@ -39,8 +39,7 @@
|
|||||||
"**/*.js",
|
"**/*.js",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"env.js",
|
"env.js",
|
||||||
"next.config.mjs",
|
"next.config.mjs"
|
||||||
"next-i18next.config.mjs"
|
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"zod": "^3.25.32"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.0",
|
"@types/node": "^20.16.0",
|
||||||
|
|||||||
@@ -43,5 +43,10 @@
|
|||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0"
|
"@types/react-dom": "18.3.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": "0.20.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
packages/server/DEBUG-BUILD.md
Normal file
27
packages/server/DEBUG-BUILD.md
Normal 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).
|
||||||
@@ -30,13 +30,13 @@
|
|||||||
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
|
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.5",
|
"@ai-sdk/anthropic": "^3.0.44",
|
||||||
"@ai-sdk/azure": "^2.0.16",
|
"@ai-sdk/azure": "^3.0.30",
|
||||||
"@ai-sdk/cohere": "^2.0.4",
|
"@ai-sdk/cohere": "^3.0.21",
|
||||||
"@ai-sdk/deepinfra": "^1.0.10",
|
"@ai-sdk/deepinfra": "^2.0.34",
|
||||||
"@ai-sdk/mistral": "^2.0.7",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^2.0.16",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.10",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/utils": "0.3.0",
|
"@better-auth/utils": "0.3.0",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@react-email/components": "^0.0.21",
|
"@react-email/components": "^0.0.21",
|
||||||
"@better-auth/sso":"1.4.18",
|
"@better-auth/sso": "1.4.18",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"ai": "^5.0.17",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^0.5.1",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.4.18",
|
"better-auth": "1.4.18",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
@@ -81,11 +81,11 @@
|
|||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"zod": "^3.25.32",
|
"zod": "^3.25.76",
|
||||||
"semver": "7.7.3"
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "1.4.18",
|
"@better-auth/cli": "1.4.18",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ const schema = createInsertSchema(deployments, {
|
|||||||
previewDeploymentId: z.string(),
|
previewDeploymentId: z.string(),
|
||||||
buildServerId: z.string(),
|
buildServerId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateDeployment = schema
|
export const apiCreateDeployment = schema
|
||||||
.pick({
|
.pick({
|
||||||
title: true,
|
title: true,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const notificationType = pgEnum("notificationType", [
|
|||||||
"pushover",
|
"pushover",
|
||||||
"custom",
|
"custom",
|
||||||
"lark",
|
"lark",
|
||||||
|
"teams",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const notifications = pgTable("notification", {
|
export const notifications = pgTable("notification", {
|
||||||
@@ -72,6 +73,9 @@ export const notifications = pgTable("notification", {
|
|||||||
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
|
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
teamsId: text("teamsId").references(() => teams.teamsId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
organizationId: text("organizationId")
|
organizationId: text("organizationId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
@@ -179,6 +183,14 @@ export const pushover = pgTable("pushover", {
|
|||||||
expire: integer("expire"),
|
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 }) => ({
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
slack: one(slack, {
|
slack: one(slack, {
|
||||||
fields: [notifications.slackId],
|
fields: [notifications.slackId],
|
||||||
@@ -220,6 +232,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
|||||||
fields: [notifications.pushoverId],
|
fields: [notifications.pushoverId],
|
||||||
references: [pushover.pushoverId],
|
references: [pushover.pushoverId],
|
||||||
}),
|
}),
|
||||||
|
teams: one(teams, {
|
||||||
|
fields: [notifications.teamsId],
|
||||||
|
references: [teams.teamsId],
|
||||||
|
}),
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [notifications.organizationId],
|
fields: [notifications.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
@@ -507,6 +523,32 @@ export const apiTestLarkConnection = apiCreateLark.pick({
|
|||||||
webhookUrl: true,
|
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
|
export const apiCreatePushover = notificationsSchema
|
||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { relations } from "drizzle-orm";
|
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 { z } from "zod";
|
||||||
import { organization } from "./account";
|
import { organization } from "./account";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
@@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
|
|||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export * from "./services/port";
|
|||||||
export * from "./services/postgres";
|
export * from "./services/postgres";
|
||||||
export * from "./services/preview-deployment";
|
export * from "./services/preview-deployment";
|
||||||
export * from "./services/project";
|
export * from "./services/project";
|
||||||
|
export * from "./services/proprietary/license-key";
|
||||||
export * from "./services/proprietary/sso";
|
export * from "./services/proprietary/sso";
|
||||||
export * from "./services/redirect";
|
export * from "./services/redirect";
|
||||||
export * from "./services/redis";
|
export * from "./services/redis";
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
|||||||
import { sendEmail } from "../verification/send-verification-email";
|
import { sendEmail } from "../verification/send-verification-email";
|
||||||
import { getPublicIpWithFallback } from "../wss/utils";
|
import { getPublicIpWithFallback } from "../wss/utils";
|
||||||
|
|
||||||
|
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
|
||||||
|
|
||||||
const { handler, api } = betterAuth({
|
const { handler, api } = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
@@ -43,6 +45,14 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|
||||||
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
enabled: true,
|
||||||
|
trustedProviders: ["github", "google", ...(trustedProviders || [])],
|
||||||
|
allowDifferentEmails: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
appName: "Dokploy",
|
appName: "Dokploy",
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
@@ -337,6 +347,7 @@ export const auth = {
|
|||||||
handler,
|
handler,
|
||||||
createApiKey: api.createApiKey,
|
createApiKey: api.createApiKey,
|
||||||
registerSSOProvider: api.registerSSOProvider,
|
registerSSOProvider: api.registerSSOProvider,
|
||||||
|
updateSSOProvider: api.updateSSOProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateRequest = async (request: IncomingMessage) => {
|
export const validateRequest = async (request: IncomingMessage) => {
|
||||||
|
|||||||
@@ -2,13 +2,31 @@ import { db } from "@dokploy/server/db";
|
|||||||
import { ai } from "@dokploy/server/db/schema";
|
import { ai } from "@dokploy/server/db/schema";
|
||||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateObject } from "ai";
|
import { generateText, Output } from "ai";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { IS_CLOUD } from "../constants";
|
import { IS_CLOUD } from "../constants";
|
||||||
import { findServerById } from "./server";
|
import { findServerById } from "./server";
|
||||||
import { getWebServerSettings } from "./web-server-settings";
|
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) => {
|
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||||
const aiSettings = await db.query.ai.findMany({
|
const aiSettings = await db.query.ai.findMany({
|
||||||
where: eq(ai.organizationId, organizationId),
|
where: eq(ai.organizationId, organizationId),
|
||||||
@@ -60,7 +78,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const suggestVariants = async ({
|
export const suggestVariants = async ({
|
||||||
organizationId,
|
organizationId: _organizationId,
|
||||||
aiId,
|
aiId,
|
||||||
input,
|
input,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -90,173 +108,177 @@ export const suggestVariants = async ({
|
|||||||
ip = "127.0.0.1";
|
ip = "127.0.0.1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const { object } = await generateObject({
|
const suggestionsSchema = z.object({
|
||||||
model,
|
suggestions: z.array(
|
||||||
output: "object",
|
z.object({
|
||||||
schema: z.object({
|
id: z.string(),
|
||||||
suggestions: z.array(
|
name: z.string(),
|
||||||
z.object({
|
shortDescription: z.string(),
|
||||||
id: z.string(),
|
description: 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 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) {
|
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 = [];
|
const result = [];
|
||||||
for (const suggestion of object.suggestions) {
|
for (const suggestion of object.suggestions) {
|
||||||
try {
|
try {
|
||||||
const { object: docker } = await generateObject({
|
const dockerResult = await generateText({
|
||||||
model,
|
model,
|
||||||
output: "object",
|
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||||
schema: z.object({
|
output: Output.object({ schema: dockerSchema }),
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
prompt: `
|
prompt: `
|
||||||
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
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:
|
Return your response as a JSON object with this structure:
|
||||||
{
|
{
|
||||||
"dockerCompose": "yaml string here",
|
"dockerCompose": "yaml string here",
|
||||||
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
||||||
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
||||||
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
||||||
}
|
}
|
||||||
|
|
||||||
Note: configFiles is optional - only include it if configuration files are absolutely required.
|
Note: configFiles is optional - only include it if configuration files are absolutely required.
|
||||||
|
|
||||||
Follow these rules:
|
Follow these rules:
|
||||||
|
|
||||||
Docker Compose Rules:
|
Docker Compose Rules:
|
||||||
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
||||||
2. Use complex values for passwords/secrets variables
|
2. Use complex values for passwords/secrets variables
|
||||||
3. Don't set container_name field in services
|
3. Don't set container_name field in services
|
||||||
4. Don't set version field in the docker compose
|
4. Don't set version field in the docker compose
|
||||||
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
|
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
|
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
|
7. Make sure all required services are defined in the docker-compose
|
||||||
|
|
||||||
Docker Image Rules (CRITICAL):
|
Docker Image Rules (CRITICAL):
|
||||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
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)
|
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.)
|
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
|
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||||
6. Examples of correct image usage:
|
6. Examples of correct image usage:
|
||||||
- image: sendingtk/chatwoot:develop
|
- image: sendingtk/chatwoot:develop
|
||||||
- image: postgres:16-alpine
|
- image: postgres:16-alpine
|
||||||
- image: redis:7-alpine
|
- image: redis:7-alpine
|
||||||
- image: chatwoot/chatwoot:latest
|
- image: chatwoot/chatwoot:latest
|
||||||
7. Examples of INCORRECT usage (DO NOT USE):
|
7. Examples of INCORRECT usage (DO NOT USE):
|
||||||
- build: .
|
- build: .
|
||||||
- build: ./app
|
- build: ./app
|
||||||
- build:
|
- build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
Volume Mounting and Configuration Rules:
|
Volume Mounting and Configuration Rules:
|
||||||
1. DO NOT create configuration files unless the service CANNOT work without them
|
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
|
2. Most services can work with just environment variables - USE THEM FIRST
|
||||||
3. Ask yourself: "Can this be configured with an environment variable instead?"
|
3. Ask yourself: "Can this be configured with an environment variable instead?"
|
||||||
4. If and ONLY IF a config file is absolutely required:
|
4. If and ONLY IF a config file is absolutely required:
|
||||||
- Keep it minimal with only critical settings
|
- Keep it minimal with only critical settings
|
||||||
- Use "../files/" prefix for all mounts
|
- Use "../files/" prefix for all mounts
|
||||||
- Format: "../files/folder:/container/path"
|
- Format: "../files/folder:/container/path"
|
||||||
5. DO NOT add configuration files for:
|
5. DO NOT add configuration files for:
|
||||||
- Default configurations that work out of the box
|
- Default configurations that work out of the box
|
||||||
- Settings that can be handled by environment variables
|
- Settings that can be handled by environment variables
|
||||||
- Proxy or routing configurations (these are handled elsewhere)
|
- Proxy or routing configurations (these are handled elsewhere)
|
||||||
|
|
||||||
Environment Variables Rules:
|
Environment Variables Rules:
|
||||||
1. For the envVariables array, provide ACTUAL example values, not placeholders
|
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)
|
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
|
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
|
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
|
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
|
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:
|
For each service that needs to be exposed to the internet:
|
||||||
1. Define a domain configuration with:
|
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
|
- 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
|
- port: the internal port the service runs on
|
||||||
- serviceName: the name of the service in the docker-compose
|
- serviceName: the name of the service in the docker-compose
|
||||||
2. Make sure the service is properly configured to work with the specified port
|
2. Make sure the service is properly configured to work with the specified port
|
||||||
|
|
||||||
User's original request: ${input}
|
User's original request: ${input}
|
||||||
|
|
||||||
Project details:
|
Project details:
|
||||||
${suggestion?.description}
|
${suggestion?.description}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
if (!!docker && !!docker.dockerCompose) {
|
const docker = dockerResult.output as DockerOutput | undefined;
|
||||||
|
if (docker?.dockerCompose) {
|
||||||
result.push({
|
result.push({
|
||||||
...suggestion,
|
...suggestion,
|
||||||
...docker,
|
...docker,
|
||||||
|
|||||||
@@ -395,16 +395,14 @@ export const removeCompose = async (
|
|||||||
if (compose.composeType === "stack") {
|
if (compose.composeType === "stack") {
|
||||||
const command = `
|
const command = `
|
||||||
docker network disconnect ${compose.appName} dokploy-traefik;
|
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) {
|
if (compose.serverId) {
|
||||||
await execAsyncRemote(compose.serverId, command);
|
await execAsyncRemote(compose.serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(command);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
await execAsync(command, {
|
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const command = `
|
const command = `
|
||||||
docker network disconnect ${compose.appName} dokploy-traefik;
|
docker network disconnect ${compose.appName} dokploy-traefik;
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
deployments,
|
deployments,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
@@ -554,8 +557,25 @@ export const removeDeployment = async (deploymentId: string) => {
|
|||||||
const deployment = await db
|
const deployment = await db
|
||||||
.delete(deployments)
|
.delete(deployments)
|
||||||
.where(eq(deployments.deploymentId, deploymentId))
|
.where(eq(deployments.deploymentId, deploymentId))
|
||||||
.returning();
|
.returning()
|
||||||
return deployment[0];
|
.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) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Error creating the deployment";
|
error instanceof Error ? error.message : "Error creating the deployment";
|
||||||
@@ -831,3 +851,19 @@ export const findAllDeploymentsByServerId = async (serverId: string) => {
|
|||||||
});
|
});
|
||||||
return deploymentsList;
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
|
|||||||
return projectEnvironments;
|
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) => {
|
export const deleteEnvironment = async (environmentId: string) => {
|
||||||
const currentEnvironment = await findEnvironmentById(environmentId);
|
const currentEnvironment = await findEnvironmentById(environmentId);
|
||||||
if (currentEnvironment.isDefault) {
|
if (currentEnvironment.isDefault) {
|
||||||
@@ -109,6 +123,13 @@ export const deleteEnvironment = async (environmentId: string) => {
|
|||||||
message: "You cannot delete the default environment",
|
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
|
const deletedEnvironment = await db
|
||||||
.delete(environments)
|
.delete(environments)
|
||||||
.where(eq(environments.environmentId, environmentId))
|
.where(eq(environments.environmentId, environmentId))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type apiCreatePushover,
|
type apiCreatePushover,
|
||||||
type apiCreateResend,
|
type apiCreateResend,
|
||||||
type apiCreateSlack,
|
type apiCreateSlack,
|
||||||
|
type apiCreateTeams,
|
||||||
type apiCreateTelegram,
|
type apiCreateTelegram,
|
||||||
type apiUpdateCustom,
|
type apiUpdateCustom,
|
||||||
type apiUpdateDiscord,
|
type apiUpdateDiscord,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
type apiUpdatePushover,
|
type apiUpdatePushover,
|
||||||
type apiUpdateResend,
|
type apiUpdateResend,
|
||||||
type apiUpdateSlack,
|
type apiUpdateSlack,
|
||||||
|
type apiUpdateTeams,
|
||||||
type apiUpdateTelegram,
|
type apiUpdateTelegram,
|
||||||
custom,
|
custom,
|
||||||
discord,
|
discord,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
pushover,
|
pushover,
|
||||||
resend,
|
resend,
|
||||||
slack,
|
slack,
|
||||||
|
teams,
|
||||||
telegram,
|
telegram,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -796,6 +799,7 @@ export const findNotificationById = async (notificationId: string) => {
|
|||||||
custom: true,
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
pushover: true,
|
pushover: true,
|
||||||
|
teams: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!notification) {
|
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 (
|
export const updateNotificationById = async (
|
||||||
notificationId: string,
|
notificationId: string,
|
||||||
notificationData: Partial<Notification>,
|
notificationData: Partial<Notification>,
|
||||||
|
|||||||
24
packages/server/src/services/proprietary/license-key.ts
Normal file
24
packages/server/src/services/proprietary/license-key.ts
Normal 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
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { organization } from "@dokploy/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const getSSOProviders = async () => {
|
export const getSSOProviders = async () => {
|
||||||
const providers = await db.query.ssoProvider.findMany({
|
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"
|
// e.g. "https://example.com/" -> "https://example.com"
|
||||||
return value.trim().replace(/\/+$/, "");
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const createCommand = (compose: ComposeNested) => {
|
|||||||
let command = "";
|
let command = "";
|
||||||
|
|
||||||
if (composeType === "docker-compose") {
|
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") {
|
} else if (composeType === "stack") {
|
||||||
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
|
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path, { join } from "node:path";
|
|||||||
import { paths } from "@dokploy/server/constants";
|
import { paths } from "@dokploy/server/constants";
|
||||||
import type { Application } from "@dokploy/server/services/application";
|
import type { Application } from "@dokploy/server/services/application";
|
||||||
import { findServerById } from "@dokploy/server/services/server";
|
import { findServerById } from "@dokploy/server/services/server";
|
||||||
|
import { readValidDirectory } from "@dokploy/server/wss/utils";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { Client, type SFTPWrapper } from "ssh2";
|
import { Client, type SFTPWrapper } from "ssh2";
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,17 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
|
|||||||
if (!filePath) continue;
|
if (!filePath) continue;
|
||||||
|
|
||||||
const fullPath = path.join(outputPath, filePath).replace(/\\/g, "/");
|
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 (application.serverId) {
|
||||||
if (!entry.isDirectory) {
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { user as userSchema } from "../../db/schema/user";
|
|||||||
export const LICENSE_KEY_URL =
|
export const LICENSE_KEY_URL =
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? "http://localhost:4002"
|
? "http://localhost:4002"
|
||||||
: "https://licenses.dokploy.com";
|
: "https://licenses-api.dokploy.com";
|
||||||
|
|
||||||
export const initEnterpriseBackupCronJobs = async () => {
|
export const initEnterpriseBackupCronJobs = async () => {
|
||||||
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user