Merge branch 'canary' into feat/log-management

This commit is contained in:
Narciso
2026-10-07 08:49:17 -04:00
88 changed files with 2415 additions and 506 deletions
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateApplication = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => {
return {
checkServicePermissionAndAccess: vi.fn(async () => undefined),
};
});
vi.mock("@dokploy/server/index", () => ({
IS_CLOUD: true,
hasValidLicense: vi.fn(async () => false),
updateApplication: mockUpdateApplication,
findApplicationById: vi.fn(async () => ({
applicationId: "app-1",
appName: "example-app",
})),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
const { applicationRouter } = await import("@/server/api/routers/application");
const caller = applicationRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof applicationRouter.createCaller>[0]);
const providerChanges = [
{
name: "GitHub",
sourceType: "github",
save: () =>
caller.saveGithubProvider({
applicationId: "app-1",
repository: "example-repo",
owner: "example-owner",
branch: "main",
buildPath: "/",
githubId: "provider-1",
watchPaths: ["src/**"],
triggerType: "push",
enableSubmodules: false,
}),
},
{
name: "GitLab",
sourceType: "gitlab",
save: () =>
caller.saveGitlabProvider({
applicationId: "app-1",
gitlabRepository: "example-repo",
gitlabOwner: "example-owner",
gitlabBranch: "main",
gitlabBuildPath: "/",
gitlabId: "provider-1",
gitlabProjectId: 1,
gitlabPathNamespace: "example-owner/example-repo",
}),
},
{
name: "Bitbucket",
sourceType: "bitbucket",
save: () =>
caller.saveBitbucketProvider({
applicationId: "app-1",
bitbucketRepository: "example-repo",
bitbucketRepositorySlug: "example-repo",
bitbucketOwner: "example-owner",
bitbucketBranch: "main",
bitbucketBuildPath: "/",
bitbucketId: "provider-1",
}),
},
{
name: "Gitea",
sourceType: "gitea",
save: () =>
caller.saveGiteaProvider({
applicationId: "app-1",
giteaRepository: "example-repo",
giteaOwner: "example-owner",
giteaBranch: "main",
giteaBuildPath: "/",
giteaId: "provider-1",
}),
},
{
name: "Docker",
sourceType: "docker",
save: () =>
caller.saveDockerProvider({
applicationId: "app-1",
dockerImage: "nginx:alpine",
username: "",
password: "",
registryUrl: "",
}),
},
{
name: "custom Git",
sourceType: "git",
save: () =>
caller.saveGitProvider({
applicationId: "app-1",
customGitUrl: "https://example.com/repo.git",
customGitBranch: "main",
customGitBuildPath: "/",
customGitSSHKeyId: "key-1",
watchPaths: ["src/**"],
enableSubmodules: false,
}),
},
{
name: "disconnect Git provider",
sourceType: "github",
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
},
];
describe("saving an application provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(providerChanges)(
"keeps the status when $name settings change",
async ({ save, sourceType }) => {
for (const status of ["done", "running"]) {
mockUpdateApplication.mockClear();
let savedStatus = status;
mockUpdateApplication.mockImplementation(async (_id, update) => {
if (update.applicationStatus !== undefined) {
savedStatus = update.applicationStatus;
}
});
await save();
expect(savedStatus).toBe(status);
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
expect(mockUpdateApplication).toHaveBeenCalledWith(
"app-1",
expect.objectContaining({ sourceType }),
);
}
},
);
});
@@ -1,4 +1,6 @@
import type { Destination } from "@dokploy/server/services/destination";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
@@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => {
expect(redacted).toContain("[REDACTED]");
});
});
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
const buildCommand = (accessKey: string, secretAccessKey: string) =>
`rclone rcat ${getS3Credentials({
accessKey,
secretAccessKey,
region: "us-west-001",
endpoint: "https://s3.us-west-001.backblazeb2.com",
provider: "Other",
} as Destination).join(" ")} :s3:bucket/file.gz`;
it.each([
[
"plain alphanumeric",
"001aaaabbbbccccdd0000000001",
"K001FAKEfakeFAKEfake",
],
[
"containing slashes",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
],
["containing spaces", "key with space", "secret with space"],
["containing quotes", "it's-a-key", `say "hi" it's`],
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
const redacted = redactRcloneCredentials(
buildCommand(accessKey, secretAccessKey),
);
expect(redacted).toContain(
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
);
expect(redacted).not.toContain(accessKey);
expect(redacted).not.toContain(secretAccessKey);
expect(redacted).toContain(":s3:bucket/file.gz");
});
it("redacts credentials embedded in an error string", () => {
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MY/SECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
describe("Remote Server Action Tooltips", () => {
it("should close the tooltip when the dialog trigger is clicked", () => {
// Note: The UI layer in this project is not currently tested with DOM simulation tools
// like React Testing Library or JSDOM.
//
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
expect(true).toBe(true);
});
});
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateCompose = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: true,
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
updateCompose: mockUpdateCompose,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: {
add: vi.fn(),
},
}));
const { composeRouter } = await import("@/server/api/routers/compose");
const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof composeRouter.createCaller>[0]);
describe("saving a compose provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["done", "running"])(
"keeps the %s status when disconnecting a Git provider",
async (status) => {
let savedStatus = status;
mockUpdateCompose.mockImplementation(async (_id, update) => {
if (update.composeStatus !== undefined) {
savedStatus = update.composeStatus;
}
});
await caller.disconnectGitProvider({ composeId: "compose-1" });
expect(savedStatus).toBe(status);
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
expect(mockUpdateCompose).toHaveBeenCalledWith(
"compose-1",
expect.objectContaining({ sourceType: "github" }),
);
},
);
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
describe("isHttpsRequest", () => {
it("returns true when X-Forwarded-Proto is https", () => {
expect(isHttpsRequest("https")).toBe(true);
});
it("returns false for http or a missing header", () => {
expect(isHttpsRequest("http")).toBe(false);
expect(isHttpsRequest(undefined)).toBe(false);
});
it("uses the left-most value of a proxy chain", () => {
expect(isHttpsRequest("https, http")).toBe(true);
expect(isHttpsRequest("http, https")).toBe(false);
});
it("handles an array header value and is case-insensitive", () => {
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
});
});
describe("secureSetCookie", () => {
it("adds Secure to a cookie that lacks it", () => {
expect(
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
});
it("does not duplicate Secure when already present", () => {
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
expect(secureSetCookie(cookie)).toBe(cookie);
});
it("applies to every cookie in an array", () => {
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
"a=1; Path=/; Secure",
"b=2; Path=/; Secure",
]);
});
it("passes a numeric header value through untouched", () => {
expect(secureSetCookie(123)).toBe(123);
});
});
@@ -0,0 +1,66 @@
import { apiCreateLibsql } from "@dokploy/server/db/schema";
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
import { describe, expect, it } from "vitest";
describe("apiCreateLibsql appName", () => {
const validBase = {
name: "My libSQL",
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
environmentId: "env-1",
description: null,
databaseUser: "admin",
databasePassword: "secretPassword1",
sqldNode: "primary" as const,
sqldPrimaryUrl: null,
enableNamespaces: false,
serverId: null,
};
it.each([
["a space", "my libsql"],
["an at sign", "repro@lib"],
["a slash", "team/libsql"],
["a colon", "libsql:1"],
])("refuses an appName with %s", (_, appName) => {
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.message)).toEqual([
APP_NAME_MESSAGE,
]);
}
});
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "my-libsql.db_1",
});
expect(result.success).toBe(true);
});
it("refuses an appName longer than 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(64),
});
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
}
});
it("accepts an appName of 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(63),
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,104 @@
import {
getHostSystemStats,
isPhysicalDisk,
} from "@dokploy/server/monitoring/utils";
import { describe, expect, it, vi } from "vitest";
const GiB = 1024 ** 3;
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
device,
readBytes: { toBytes: () => readGiB * GiB },
writeBytes: { toBytes: () => writeGiB * GiB },
});
vi.mock("node-os-utils", () => ({
OSUtils: class {
cpu = { usage: async () => ({ success: false }) };
memory = { info: async () => ({ success: false }) };
network = { overview: async () => ({ success: false }) };
disk = {
stats: async () => ({
success: true,
data: [
diskStat("nvme0n1", 50, 100),
diskStat("nvme0n1p1", 50, 100),
diskStat("nvme1n1", 50, 100),
diskStat("nvme1n1p1", 50, 100),
diskStat("md0", 50, 100),
diskStat("md0p1", 50, 100),
diskStat("dm-0", 50, 100),
diskStat("zram0", 10, 20),
],
}),
};
},
}));
describe("isPhysicalDisk (#5513, #5385)", () => {
it.each([
"sda",
"sdab",
"hda",
"vda",
"xvda",
"nvme0n1",
"nvme1n1",
"mmcblk0",
])("counts whole disk %s", (device) => {
expect(isPhysicalDisk(device)).toBe(true);
});
it.each([
"sda1",
"sdab12",
"hda1",
"vda1",
"xvda1",
"nvme0n1p1",
"nvme1n1p4",
"mmcblk0p1",
])("skips partition %s", (device) => {
expect(isPhysicalDisk(device)).toBe(false);
});
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
"skips stacked device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
"skips virtual device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it("counts each write once on an mdadm RAID1 host", () => {
const devices = [
"nvme0n1",
"nvme0n1p1",
"nvme0n1p2",
"nvme0n1p3",
"nvme0n1p4",
"nvme1n1",
"nvme1n1p1",
"nvme1n1p2",
"nvme1n1p3",
"nvme1n1p4",
"md0",
"md1",
"md2",
"md3",
];
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
});
});
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
it("sums only whole physical disks", async () => {
const stats = await getHostSystemStats();
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
});
});
@@ -0,0 +1,182 @@
import {
checkDeletionCode,
generateDeletionCode,
serializeDeletionCode,
} from "@dokploy/server/services/account-deletion";
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cancelStripeSubscriptions } from "@/server/utils/billing";
const makeStripe = (
subscriptions: Array<{ id: string; status: string }>,
listError?: Error,
) => {
const stripe = {
subscriptions: {
list: vi.fn(() =>
listError
? Promise.reject(listError)
: Promise.resolve({ data: subscriptions }),
),
cancel: vi.fn(() => Promise.resolve({})),
},
};
return stripe as unknown as Stripe & typeof stripe;
};
describe("cancelStripeSubscriptions", () => {
test("cancels every non-terminal subscription without invoicing", async () => {
const stripe = makeStripe([
{ id: "sub_active", status: "active" },
{ id: "sub_trial", status: "trialing" },
{ id: "sub_past_due", status: "past_due" },
{ id: "sub_canceled", status: "canceled" },
{ id: "sub_expired", status: "incomplete_expired" },
]);
const result = await cancelStripeSubscriptions("cus_1", stripe);
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
customer: "cus_1",
status: "all",
limit: 100,
});
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
invoice_now: false,
prorate: false,
});
expect(result).toEqual({
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
});
});
test("treats a missing customer as having nothing to cancel", async () => {
const missing = new Stripe.errors.StripeInvalidRequestError({
type: "invalid_request_error",
code: "resource_missing",
message: "No such customer",
});
const stripe = makeStripe([], missing);
const result = await cancelStripeSubscriptions("cus_missing", stripe);
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
expect(result).toEqual({ cancelledSubscriptions: [] });
});
test("propagates unexpected Stripe errors", async () => {
const failure = new Stripe.errors.StripeAPIError({
type: "api_error",
message: "boom",
});
const stripe = makeStripe([], failure);
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
failure,
);
});
});
describe("deleteHubSpotContactByEmail", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
test("is skipped when no access token is configured", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
"skipped",
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("calls the GDPR delete endpoint with the email as id", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(null, { status: 204 })),
);
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"deleted",
);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
);
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token",
);
expect(JSON.parse(init.body as string)).toEqual({
idProperty: "email",
objectId: "a@b.com",
});
});
test("maps 404 to not_found and other errors to failed", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"not_found",
);
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"failed",
);
});
});
describe("account deletion codes", () => {
test("generates six-digit numeric codes", () => {
for (let i = 0; i < 50; i++) {
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
}
});
test("accepts the right code, ignoring surrounding whitespace", () => {
const value = serializeDeletionCode("482913");
expect(checkDeletionCode(value, "482913")).toEqual({
valid: true,
attempts: 0,
});
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
});
test("counts failed attempts without revealing the hash", () => {
const value = serializeDeletionCode("482913");
const first = checkDeletionCode(value, "000000");
expect(first.valid).toBe(false);
if (first.valid) throw new Error("unreachable");
expect(first.attempts).toBe(1);
expect(JSON.parse(first.nextValue)).toEqual({
...JSON.parse(value),
attempts: 1,
});
const second = checkDeletionCode(first.nextValue, "999999");
expect(second.valid).toBe(false);
if (second.valid) throw new Error("unreachable");
expect(second.attempts).toBe(2);
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
valid: true,
attempts: 2,
});
expect(value).not.toContain("482913");
});
});
@@ -0,0 +1,169 @@
import { EventEmitter } from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import WebSocket from "ws";
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
signals: string[] = [];
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
// even if the process ignores it and keeps running.
ignoreSigterm = false;
kill(signal: NodeJS.Signals = "SIGTERM") {
this.signals.push(signal);
this.killed = true;
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
this.signalCode = signal;
this.emit("close", null, signal);
return true;
}
}
const children = vi.hoisted(() => [] as FakeChild[]);
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: vi.fn(() => {
const child = new FakeChild();
child.ignoreSigterm = nextChild.ignoreSigterm;
children.push(child);
return child;
}),
}));
const sshClients = vi.hoisted(
() => [] as { connect: ReturnType<typeof vi.fn> }[],
);
vi.mock("ssh2", () => ({
Client: vi.fn(function (this: Record<string, unknown>) {
this.on = vi.fn(() => this);
this.connect = vi.fn(() => this);
this.end = vi.fn();
sshClients.push(this as never);
}),
}));
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockFindServerById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
validateRequest: mockValidateRequest,
findServerById: mockFindServerById,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
readValidDirectory: () => true,
}));
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
const AUTH = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 50));
let server: http.Server;
let url: string;
beforeEach(async () => {
children.length = 0;
sshClients.length = 0;
nextChild.ignoreSigterm = false;
vi.clearAllMocks();
server = http.createServer();
setupDeploymentLogsWebSocketServer(server);
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
});
afterEach(async () => {
vi.useRealTimers();
await new Promise((r) => server.close(r));
});
const connect = async (query = "") => {
const ws = new WebSocket(`${url}${query}`);
await new Promise((r) => ws.once("open", r));
return ws;
};
const closeClient = async (ws: WebSocket) => {
ws.close();
await new Promise((r) => ws.once("close", r));
await flush();
};
describe("deployment logs websocket (#5518)", () => {
it("kills tail when the client disconnects", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
expect(children).toHaveLength(1);
await closeClient(ws);
expect(children[0]!.signals).toContain("SIGTERM");
});
it("does not spawn tail when the client disconnects during auth", async () => {
const auth = deferred<typeof AUTH>();
mockValidateRequest.mockReturnValue(auth.promise);
const ws = await connect();
await closeClient(ws);
auth.resolve(AUTH);
await flush();
const leaked = children.filter(
(c) => c.exitCode === null && c.signalCode === null,
);
expect(leaked).toHaveLength(0);
});
it("does not open an SSH session when the client disconnects while loading the server", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const serverLookup = deferred<unknown>();
mockFindServerById.mockReturnValue(serverLookup.promise);
const ws = await connect("&serverId=srv-1");
await closeClient(ws);
serverLookup.resolve({
organizationId: "org-1",
sshKeyId: "key-1",
sshKey: { privateKey: "key" },
ipAddress: "10.0.0.1",
port: 22,
username: "root",
});
await flush();
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
expect(connected).toHaveLength(0);
});
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
nextChild.ignoreSigterm = true;
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
await closeClient(ws);
await new Promise((r) => setTimeout(r, 1100));
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
});
});
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
@@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
};
return (
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center gap-y-2 flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader>
<CardContent className="px-0">
<CardContent>
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({
};
return (
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
type={type}
serverId={serverId}
/>
</div>
</>
)}
</div>
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Volume Backups</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
</div>
</>
)}
</div>
</CardHeader>
<CardContent className="px-0">
<CardContent>
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
@@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any)
@@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
@@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -1,10 +1,4 @@
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -101,12 +95,9 @@ export const ShowBackups = ({
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Backups</CardTitle>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -1,10 +1,8 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
@@ -27,7 +25,6 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) {
return null;
}
export function ShowDeploymentsTable() {
interface ShowDeploymentsTableProps {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
@@ -446,177 +450,140 @@ export function ShowDeploymentsTable() {
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will appear
here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
@@ -0,0 +1,143 @@
import copy from "copy-to-clipboard";
import { Copy, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const CONFIRMATION_WORD = "DELETE";
export const DeleteAccountByEmail = () => {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [confirmation, setConfirmation] = useState("");
const [record, setRecord] = useState<string | null>(null);
const { mutateAsync, isPending } =
api.user.deleteAccountByEmail.useMutation();
const canConfirm =
email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD;
const reset = () => {
setEmail("");
setConfirmation("");
setRecord(null);
};
const handleDelete = async () => {
try {
const result = await mutateAsync({ email: email.trim() });
setRecord(JSON.stringify(result, null, 2));
toast.success(`Account ${result.email} deleted`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deleting account",
);
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) reset();
}}
>
<DialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<Trash2 className="h-4 w-4" />
Delete account
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Delete an account by email</DialogTitle>
<DialogDescription>
Cancels every Stripe subscription and deletes the user with all the
organizations they own. Invoices stay in Stripe for accounting. Use
it to fulfil data deletion requests.
</DialogDescription>
</DialogHeader>
{record ? (
<div className="flex flex-col gap-3">
<AlertBlock type="success">
Deletion completed. Keep this record as evidence of the request
being fulfilled.
</AlertBlock>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
{record}
</pre>
<Button
variant="outline"
size="sm"
className="gap-2 self-end"
onClick={() => {
copy(record);
toast.success("Deletion record copied to clipboard");
}}
>
<Copy className="h-4 w-4" />
Copy record
</Button>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="delete-account-email">Account email</Label>
<Input
id="delete-account-email"
type="email"
autoComplete="off"
placeholder="user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="delete-account-word">
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
confirm
</Label>
<Input
id="delete-account-word"
autoComplete="off"
placeholder={CONFIRMATION_WORD}
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter>
{record ? (
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
) : (
<Button
variant="destructive"
disabled={!canConfirm || isPending}
onClick={handleDelete}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Delete account
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { DeleteAccountByEmail } from "./delete-account-by-email";
type User = typeof authClient.$Infer.Session.user;
@@ -78,12 +79,12 @@ export const ImpersonationBar = () => {
});
const filteredUsers = response.data?.users.filter(
// @ts-ignore
// @ts-expect-error
(user) => user.allowImpersonation && data?.user?.email !== user.email,
);
if (!response.error) {
// @ts-ignore
// @ts-expect-error
setUsers(filteredUsers || []);
}
} catch (error) {
@@ -282,6 +283,7 @@ export const ImpersonationBar = () => {
<Shield className="h-4 w-4" />
Impersonate
</Button>
<DeleteAccountByEmail />
</div>
) : (
<div className="flex items-center gap-4 w-full flex-wrap">
@@ -1,5 +1,6 @@
import { Rocket } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import {
@@ -8,6 +9,14 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const SUBTAB_VALUES = ["deployments", "queue"] as const;
@@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => {
? router.query.subtab
: DEFAULT_SUBTAB;
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const setSubtab = (value: string) => {
if (!isValidSubtab(value)) return;
const { subtab: _current, ...query } = router.query;
@@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => {
onValueChange={setSubtab}
className="w-full min-w-0"
>
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
<TabsList className="self-start shrink-0">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
{subtab === "deployments" && (
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable />
<ShowDeploymentsTable
globalFilter={globalFilter}
statusFilter={statusFilter}
typeFilter={typeFilter}
/>
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
@@ -24,7 +24,7 @@ export const AiForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
@@ -62,7 +62,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,14 +111,20 @@ export const ShowBilling = () => {
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false);
const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
const planName = tier === "startup" ? "Startup" : "Hobby";
const handleSwitchTrialPlan = async (
tier: "hobby" | "startup",
isAnnualPlan: boolean,
label?: string,
) => {
const planName =
label ??
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
const switchPlan = async () => {
await upgradeSubscription({
tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: false,
isAnnual: isAnnualPlan,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([
@@ -133,7 +139,7 @@ export const ShowBilling = () => {
await toast
.promise(switchPlan(), {
loading: `Switching to ${planName}...`,
success: `Your trial is now on the ${planName} plan`,
success: `Your trial is now on ${planName}`,
error: (error) =>
error instanceof Error ? error.message : "Error switching plan",
})
@@ -336,6 +342,11 @@ export const ShowBilling = () => {
<Badge className="capitalize" variant="secondary">
{billingStatus.plan ?? "Trial"}
</Badge>
{billingStatus.plan && (
<Badge variant="outline">
{billingStatus.isAnnual ? "Annual" : "Monthly"}
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
{billingStatus.isOnTrial
@@ -356,23 +367,45 @@ export const ShowBilling = () => {
<div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial &&
useNewPricing &&
billingStatus.plan && (
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
billingStatus.plan &&
billingStatus.plan !== "legacy" && (
<>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
billingStatus.isAnnual,
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan as "hobby" | "startup",
!billingStatus.isAnnual,
billingStatus.isAnnual
? "monthly billing"
: "annual billing",
)
}
>
{billingStatus.isAnnual
? "Switch to monthly"
: "Switch to annual"}
</Button>
</>
)}
<Button
size="sm"
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1">
<span className="text-sm">
{index + 1}. {destination.name}
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
@@ -0,0 +1,214 @@
import { Loader2, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const errorMessage = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : fallback;
export const DeleteAccount = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"request" | "confirm">("request");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [expiresInMinutes, setExpiresInMinutes] = useState(10);
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: hasPassword } = api.user.hasPassword.useQuery();
const requestCode = api.user.requestAccountDeletionCode.useMutation();
const deleteAccount = api.user.deleteAccount.useMutation();
const email = data?.user?.email ?? "";
const isBusy = requestCode.isPending || deleteAccount.isPending;
if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) {
return null;
}
const reset = () => {
setStep("request");
setPassword("");
setCode("");
};
const handleRequestCode = async () => {
try {
const result = await requestCode.mutateAsync(
hasPassword ? { password } : {},
);
setExpiresInMinutes(result.expiresInMinutes);
setCode("");
setStep("confirm");
toast.success(`We sent a confirmation code to ${email}`);
} catch (error) {
toast.error(errorMessage(error, "Error sending the confirmation code"));
}
};
const handleDelete = async () => {
try {
await deleteAccount.mutateAsync({ code });
toast.success("Your account has been deleted");
await authClient.signOut().catch(() => undefined);
router.push("/");
} catch (error) {
toast.error(errorMessage(error, "Error deleting account"));
}
};
return (
<Card className="bg-transparent border-destructive/40">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2 text-destructive">
<Trash2 className="size-5" />
Delete account
</CardTitle>
<CardDescription>
Permanently remove your account and all the data associated with it.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
<li>
Active subscriptions are cancelled immediately. Invoices already
issued are kept for accounting.
</li>
<li>
Every organization you own is deleted, including its projects,
services, environments, domains and server connections.
</li>
<li>
In organizations you belong to but do not own, only your membership,
API keys and the Git providers you connected are removed.
Applications using those providers keep running but must be
reconnected to redeploy.
</li>
<li>Containers already running on your servers are not touched.</li>
<li>
We will email a confirmation code to{" "}
<span className="font-medium text-foreground">{email}</span> before
anything is deleted.
</li>
<li>This action cannot be undone.</li>
</ul>
<div>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
{step === "request"
? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.`
: `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
{step === "request" && hasPassword && (
<>
<Label htmlFor="delete-account-password">Password</Label>
<Input
id="delete-account-password"
type="password"
autoComplete="current-password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</>
)}
{step === "confirm" && (
<>
<Label htmlFor="delete-account-code">
Confirmation code
</Label>
<Input
id="delete-account-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, ""))
}
/>
<Button
type="button"
variant="link"
size="sm"
className="self-start px-0"
disabled={isBusy}
onClick={handleRequestCode}
>
Resend code
</Button>
</>
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
{step === "request" ? (
<Button
type="button"
variant="destructive"
disabled={isBusy || (hasPassword && password.length === 0)}
onClick={handleRequestCode}
>
{requestCode.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Send confirmation code
</Button>
) : (
<Button
type="button"
variant="destructive"
disabled={isBusy || code.length !== 6}
onClick={handleDelete}
>
{deleteAccount.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Delete account
</Button>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
};
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -1,6 +1,4 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
asButton?: boolean;
children?: React.ReactNode;
}
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
export const ShowServerActions = ({ serverId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
}}
>
View Actions
</DropdownMenuItem>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-xl">
<DialogContent
className="sm:max-w-xl"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle>
<DialogDescription>Reload or clean the web server.</DialogDescription>
@@ -126,7 +126,10 @@ export const DeleteServerModal = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent
className="max-w-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Pencil, PlusIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
interface Props {
serverId?: string;
asButton?: boolean;
children?: React.ReactNode;
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
export const HandleServers = ({ serverId, children }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{serverId ? (
asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : serverId ? (
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
Edit Server
</DropdownMenuItem>
)
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3">
@@ -167,7 +162,10 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-3xl ">
<DialogContent
className="sm:max-w-3xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
<DialogDescription>
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
asButton?: boolean;
children?: React.ReactNode;
}
export const SetupServer = ({ serverId, asButton = false }: Props) => {
export const SetupServer = ({ serverId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -81,24 +81,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{asButton ? (
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
<Button className="w-full cursor-pointer " size="sm">
Setup Server <Settings className="size-4" />
</Button>
</DialogTrigger>
) : (
<Button
className="w-full cursor-pointer "
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent className="sm:max-w-4xl ">
<DialogContent
className="sm:max-w-4xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
@@ -7,19 +7,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
interface Props {
url: string;
token: string;
children?: React.ReactNode;
}
export const ShowMonitoringModal = ({ url, token }: Props) => {
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-7xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex gap-4 py-4 w-full">
<ShowPaidMonitoring BASE_URL={url} token={token} />
</div>
@@ -1,11 +1,15 @@
import { format } from "date-fns";
import {
Activity,
BarChartHorizontalBigIcon,
Clock,
Key,
KeyIcon,
Loader2,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
User,
@@ -49,7 +53,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl w-full">
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -224,11 +228,19 @@ export const ShowServers = () => {
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<SetupServer
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
className="w-full cursor-pointer "
size="sm"
>
Setup Server{" "}
<Settings className="size-4" />
</Button>
</TooltipTrigger>
</SetupServer>
<TooltipContent
className="max-w-xs"
side="bottom"
@@ -251,22 +263,20 @@ export const ShowServers = () => {
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<Terminal className="h-4 w-4" />
</Button>
</TooltipTrigger>
</TerminalModal>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
@@ -274,14 +284,19 @@ export const ShowServers = () => {
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<HandleServers
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
</HandleServers>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
@@ -289,14 +304,19 @@ export const ShowServers = () => {
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<ShowServerActions
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Activity className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowServerActions>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
@@ -307,17 +327,23 @@ export const ShowServers = () => {
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowMonitoringModal>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
@@ -328,22 +354,20 @@ export const ShowServers = () => {
{permissions?.server.delete && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</DeleteServerModal>
</div>
</TooltipTrigger>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
</DeleteServerModal>
<TooltipContent>
<p>Delete Server</p>
</TooltipContent>
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
@@ -22,7 +22,7 @@ export const TagManager = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -124,8 +124,11 @@ export const AddInvitation = () => {
const mode = form.watch("mode");
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
if (!open) {
form.reset();
setError(null);
}
}, [open, form, form.reset]);
useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") {
@@ -225,7 +228,7 @@ export const AddInvitation = () => {
<FormLabel>Invite Method</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
@@ -313,7 +316,7 @@ export const AddInvitation = () => {
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -123,7 +123,7 @@ export const WebDomain = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
@@ -24,7 +24,7 @@ export const WebServer = () => {
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -45,9 +45,8 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
// @ts-expect-error
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
addonFit.fit();
@@ -89,9 +88,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}, [id, serverId]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 h-full w-full">
<div className="w-full h-full bg-transparent border rounded-lg p-2">
<div id={id} ref={termRef} className="rounded-xl" />
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
</div>
</div>
);
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import { useHubSpotChat } from "../shared/analytics";
import Page from "./side";
interface Props {
@@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
});
const isChatEnabled = isCloud === true && currentPlan === "startup";
useHubSpotChat(isChatEnabled);
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
const shouldRedirectToOnboarding =
@@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
</>
);
@@ -618,7 +618,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -1,14 +0,0 @@
import Script from "next/script";
export const HubSpotWidget = () => {
return (
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
);
};
@@ -92,5 +92,11 @@ export const useHubSpotChat = (enabled: boolean) => {
loadWidget,
];
}
return () => {
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
(callback) => callback !== loadWidget,
);
window.HubSpotConversations?.widget.remove();
};
}, [enabled]);
};
+25
View File
@@ -0,0 +1,25 @@
export const isHttpsRequest = (
forwardedProto: string | string[] | undefined,
): boolean => {
if (!forwardedProto) return false;
const value = Array.isArray(forwardedProto)
? forwardedProto[0]
: forwardedProto;
// take the first value in case a proxy chain sent a list
return value?.split(",")[0]?.trim().toLowerCase() === "https";
};
const hasSecureAttribute = (cookie: string): boolean =>
/;\s*secure(?:\s*;|\s*$)/i.test(cookie);
const withSecure = (cookie: string): string =>
hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`;
// Add Secure to a Set-Cookie header value (string or string[]).
export const secureSetCookie = (
value: string | number | readonly string[],
): string | number | string[] => {
if (typeof value === "number") return value;
if (Array.isArray(value)) return value.map(withSecure);
return withSecure(value as string);
};
+24 -1
View File
@@ -1,7 +1,30 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { auth } from "@dokploy/server/index";
import { toNodeHandler } from "better-auth/node";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
// Disallow body parsing, we will parse it manually
export const config = { api: { bodyParser: false } };
export default toNodeHandler(auth.handler);
const handler = toNodeHandler(auth.handler);
// Mark the session cookie as Secure when the request comes over HTTPS
export default function authHandler(req: IncomingMessage, res: ServerResponse) {
if (isHttpsRequest(req.headers["x-forwarded-proto"])) {
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? setHeader(name, secureSetCookie(value))
: setHeader(name, value);
const appendHeader = res.appendHeader?.bind(res);
if (appendHeader) {
res.appendHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? appendHeader(name, secureSetCookie(value) as string | string[])
: appendHeader(name, value);
}
}
return handler(req, res);
}
@@ -32,6 +32,14 @@ const getPackageVersion = (headers: any, body: any) => {
return null;
};
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
@@ -15,6 +15,14 @@ import {
logWebhookError,
} from "../[refreshToken]";
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
+8
View File
@@ -26,6 +26,14 @@ import {
const getGithubRepositoryOwner = (githubBody: any) =>
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
+4 -10
View File
@@ -158,11 +158,9 @@ export default async function handler(
newSubscription.customer as string,
);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
// The account may already be gone when the deletion flow cancelled
// the subscription; nothing left to disable.
if (!admin || admin.isEnterpriseCloud) {
break;
}
@@ -314,11 +312,7 @@ export default async function handler(
const customer = event.data.object as Stripe.Customer;
const admin = await findUserByStripeCustomerId(customer.id);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
if (!admin || admin.isEnterpriseCloud) {
break;
}
+4 -6
View File
@@ -12,12 +12,10 @@ function SchedulesPage() {
{(serverId) => (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</div>
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</Card>
</div>
)}
@@ -21,7 +21,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
import { DeleteAccount } from "@/components/dashboard/settings/profile/delete-account";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
@@ -16,10 +17,11 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{permissions?.api.read && <ShowApiKeys />}
<DeleteAccount />
</div>
</div>
);
@@ -15,7 +15,7 @@ const Page = () => {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<WebDomain />
<WebServer />
<div className="w-full flex flex-col gap-4">
@@ -25,7 +25,7 @@ interface Props {
const Page = ({ isCloud }: Props) => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
@@ -12,7 +12,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
@@ -545,7 +545,6 @@ export const applicationRouter = createTRPCRouter({
sourceType: "github",
owner: input.owner,
buildPath: input.buildPath,
applicationStatus: "idle",
githubId: input.githubId,
watchPaths: input.watchPaths,
triggerType: input.triggerType,
@@ -572,7 +571,6 @@ export const applicationRouter = createTRPCRouter({
gitlabBranch: input.gitlabBranch,
gitlabBuildPath: input.gitlabBuildPath,
sourceType: "gitlab",
applicationStatus: "idle",
gitlabId: input.gitlabId,
gitlabProjectId: input.gitlabProjectId,
gitlabPathNamespace: input.gitlabPathNamespace,
@@ -601,7 +599,6 @@ export const applicationRouter = createTRPCRouter({
bitbucketBranch: input.bitbucketBranch,
bitbucketBuildPath: input.bitbucketBuildPath,
sourceType: "bitbucket",
applicationStatus: "idle",
bitbucketId: input.bitbucketId,
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
@@ -627,7 +624,6 @@ export const applicationRouter = createTRPCRouter({
giteaBranch: input.giteaBranch,
giteaBuildPath: input.giteaBuildPath,
sourceType: "gitea",
applicationStatus: "idle",
giteaId: input.giteaId,
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
@@ -652,7 +648,6 @@ export const applicationRouter = createTRPCRouter({
username: input.username,
password: input.password,
sourceType: "docker",
applicationStatus: "idle",
registryUrl: input.registryUrl,
});
const application = await findApplicationById(input.applicationId);
@@ -676,7 +671,6 @@ export const applicationRouter = createTRPCRouter({
customGitUrl: input.customGitUrl,
customGitSSHKeyId: input.customGitSSHKeyId,
sourceType: "git",
applicationStatus: "idle",
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
});
@@ -729,7 +723,6 @@ export const applicationRouter = createTRPCRouter({
customGitSSHKeyId: null,
sourceType: "github", // Reset to default
applicationStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
@@ -759,7 +759,6 @@ export const composeRouter = createTRPCRouter({
customGitSSHKeyId: null,
sourceType: "github", // Reset to default
composeStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
@@ -388,6 +388,9 @@ export const stripeRouter = createTRPCRouter({
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems,
proration_behavior: isTrialing ? "none" : "create_prorations",
...(isTrialing && subscription.trial_end
? { trial_end: subscription.trial_end }
: {}),
});
return { ok: true };
+140 -10
View File
@@ -1,6 +1,10 @@
import {
ACCOUNT_DELETION_CODE_TTL_MINUTES,
consumeAccountDeletionCode,
createAccountDeletionCode,
createApiKey,
createOrganizationUserWithCredentials,
findCredentialAccount,
findNotificationById,
findOrganizationById,
findPasskeysByUserId,
@@ -11,6 +15,7 @@ import {
IS_CLOUD,
removeUserById,
renderInvitationEmail,
sendAccountDeletionCodeEmail,
sendEmailNotification,
sendResendNotification,
updateUser,
@@ -24,6 +29,7 @@ import {
apiUpdateUser,
invitation,
member,
organization,
session,
user,
} from "@dokploy/server/db/schema";
@@ -38,6 +44,7 @@ import { and, asc, desc, eq, gt, ne } from "drizzle-orm";
import { z } from "zod";
import { apiKeyNameSchema } from "@/lib/api-keys";
import { audit } from "@/server/api/utils/audit";
import { deleteAccount } from "@/server/utils/account-deletion";
import {
adminProcedure,
createTRPCRouter,
@@ -63,6 +70,47 @@ const apiCreateApiKey = z.object({
refillInterval: z.number().optional(),
});
const hasRootAccess = (ctx: {
user: { id: string };
session: { impersonatedBy?: string } | null;
}) => {
const rootId = process.env.USER_ADMIN_ID;
if (!IS_CLOUD || !rootId) {
return false;
}
return rootId === ctx.user.id || ctx.session?.impersonatedBy === rootId;
};
const assertCanDeleteOwnAccount = async (ctx: {
user: { id: string };
session: { impersonatedBy?: string };
}) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
if (ctx.session.impersonatedBy) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accounts cannot be deleted while impersonating",
});
}
const ownedOrganization = await db.query.organization.findFirst({
where: eq(organization.ownerId, ctx.user.id),
columns: { id: true },
});
if (!ownedOrganization) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only organization owners can delete their account",
});
}
};
export const userRouter = createTRPCRouter({
all: withPermission("member", "read").query(async ({ ctx }) => {
return await db.query.member.findMany({
@@ -178,16 +226,7 @@ export const userRouter = createTRPCRouter({
return findPasskeysByUserId(ctx.user.id);
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;
}
if (
process.env.USER_ADMIN_ID === ctx.user.id ||
ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID
) {
return true;
}
return false;
return hasRootAccess(ctx);
}),
getBackups: adminProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
@@ -458,6 +497,97 @@ export const userRouter = createTRPCRouter({
});
return result;
}),
hasPassword: protectedProcedure.query(async ({ ctx }) => {
const credential = await findCredentialAccount(ctx.user.id);
return !!credential?.password;
}),
requestAccountDeletionCode: protectedProcedure
.input(z.object({ password: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
await assertCanDeleteOwnAccount(ctx);
const credential = await findCredentialAccount(ctx.user.id);
if (
credential?.password &&
!bcrypt.compareSync(input.password ?? "", credential.password)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The password is incorrect",
});
}
const code = await createAccountDeletionCode(ctx.user.id);
try {
await sendAccountDeletionCodeEmail({
email: ctx.user.email,
userName: ctx.user.name || ctx.user.email,
code,
expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES,
});
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "The confirmation email could not be sent",
});
}
return { expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES };
}),
deleteAccount: protectedProcedure
.input(z.object({ code: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertCanDeleteOwnAccount(ctx);
const { requestedAt } = await consumeAccountDeletionCode(
ctx.user.id,
input.code,
);
await audit(ctx, {
action: "delete",
resourceType: "user",
resourceId: ctx.user.id,
metadata: { selfService: true },
});
return deleteAccount(ctx.user.id, ctx.user.id, { requestedAt });
}),
deleteAccountByEmail: protectedProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
if (!hasRootAccess(ctx)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the root account can delete accounts by email",
});
}
const target = await db.query.user.findFirst({
where: eq(user.email, input.email.trim().toLowerCase()),
columns: { id: true },
});
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No account found with that email",
});
}
if (target.id === process.env.USER_ADMIN_ID) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The root account cannot be deleted",
});
}
return deleteAccount(
target.id,
ctx.session.impersonatedBy ?? ctx.user.id,
);
}),
assignPermissions: withPermission("member", "update")
.input(apiAssignPermissions)
.mutation(async ({ input, ctx }) => {
@@ -0,0 +1,44 @@
import {
deleteHubSpotContactByEmail,
deleteUserAccountData,
findUserById,
IS_CLOUD,
} from "@dokploy/server";
import { cancelStripeSubscriptions } from "./billing";
export const deleteAccount = async (
userId: string,
requestedBy: string,
options?: { requestedAt?: Date | null },
) => {
const target = await findUserById(userId);
const startedAt = new Date().toISOString();
const stripe =
IS_CLOUD && target.stripeCustomerId
? {
customerId: target.stripeCustomerId,
...(await cancelStripeSubscriptions(target.stripeCustomerId)),
}
: null;
const { organizationsDeleted } = await deleteUserAccountData(userId);
const hubspot = IS_CLOUD
? await deleteHubSpotContactByEmail(target.email)
: "skipped";
const record = {
userId,
email: target.email,
requestedBy,
requestedAt: options?.requestedAt?.toISOString() ?? startedAt,
startedAt,
completedAt: new Date().toISOString(),
organizationsDeleted,
stripe,
hubspot,
};
return record;
};
+40
View File
@@ -92,6 +92,7 @@ export interface BillingStatus {
hasUsedTrial: boolean;
hasActiveAccess: boolean;
hasPaymentMethod: boolean;
isAnnual: boolean;
}
export const getBillingStatus = async (
@@ -106,6 +107,7 @@ export const getBillingStatus = async (
hasUsedTrial: false,
hasActiveAccess: true,
hasPaymentMethod: true,
isAnnual: false,
};
}
@@ -119,6 +121,7 @@ export const getBillingStatus = async (
hasUsedTrial: false,
hasActiveAccess: false,
hasPaymentMethod: false,
isAnnual: false,
};
}
@@ -146,6 +149,9 @@ export const getBillingStatus = async (
const hasPaymentMethod =
!!currentSub?.default_payment_method ||
!!customer?.invoice_settings?.default_payment_method;
const isAnnual =
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
?.interval === "year";
const trialEndsAt = trialingSub?.trial_end
? new Date(trialingSub.trial_end * 1000)
: null;
@@ -164,5 +170,39 @@ export const getBillingStatus = async (
hasPaymentMethod,
hasUsedTrial: subscriptions.data.length > 0,
hasActiveAccess: plan !== null || !!trialingSub,
isAnnual,
};
};
const isMissingResource = (error: unknown) =>
error instanceof Stripe.errors.StripeError &&
error.code === "resource_missing";
export const cancelStripeSubscriptions = async (
stripeCustomerId: string,
stripe: Stripe = getStripeClient(),
) => {
const subscriptions = await stripe.subscriptions
.list({ customer: stripeCustomerId, status: "all", limit: 100 })
.catch((error: unknown) => {
if (isMissingResource(error)) return null;
throw error;
});
const cancelledSubscriptions: string[] = [];
for (const subscription of subscriptions?.data ?? []) {
if (
subscription.status === "canceled" ||
subscription.status === "incomplete_expired"
) {
continue;
}
await stripe.subscriptions.cancel(subscription.id, {
invoice_now: false,
prorate: false,
});
cancelledSubscriptions.push(subscription.id);
}
return { cancelledSubscriptions };
};
+30 -21
View File
@@ -33,6 +33,11 @@ export const setupDeploymentLogsWebSocketServer = (
const serverId = url.searchParams.get("serverId");
const { user, session } = await validateRequest(req);
// Client may have disconnected during the await; a later close handler would never fire.
if (ws.readyState !== ws.OPEN) {
return;
}
// Generate unique connection ID for tracking
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
if (!logPath) {
@@ -54,10 +59,33 @@ export const setupDeploymentLogsWebSocketServer = (
let tailProcess: ReturnType<typeof spawn> | null = null;
let sshClient: Client | null = null;
// `killed` is set once a signal is sent, not when the process exits.
const isTailRunning = () =>
tailProcess !== null &&
tailProcess.exitCode === null &&
tailProcess.signalCode === null;
const stopTailProcess = () => {
if (!isTailRunning()) {
return;
}
tailProcess!.kill("SIGTERM");
// Force kill after a timeout if it doesn't terminate
setTimeout(() => {
if (isTailRunning()) {
tailProcess!.kill("SIGKILL");
}
}, 1000);
};
try {
if (serverId) {
const server = await findServerById(serverId);
if (ws.readyState !== ws.OPEN) {
return;
}
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
@@ -155,30 +183,11 @@ export const setupDeploymentLogsWebSocketServer = (
}
});
ws.on("close", () => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGTERM");
// Force kill after a timeout if it doesn't terminate
setTimeout(() => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGKILL");
} else {
}
}, 1000);
} else {
}
});
ws.on("close", stopTailProcess);
}
} catch (error) {
// Clean up resources on error
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGTERM");
setTimeout(() => {
if (tailProcess && !tailProcess.killed) {
tailProcess.kill("SIGKILL");
}
}, 1000);
}
stopTailProcess();
if (sshClient) {
sshClient.end();
}
+3 -1
View File
@@ -35,6 +35,8 @@ import {
UpdateConfigSwarmSchema,
} from "./shared";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
@@ -115,7 +117,7 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
const createSchema = createInsertSchema(libsql, {
libsqlId: z.string(),
name: z.string().min(1),
appName: z.string().min(1),
appName: z.string().min(1).max(63).regex(APP_NAME_REGEX, APP_NAME_MESSAGE),
createdAt: z.string(),
databaseUser: z.string().min(1),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
@@ -0,0 +1,87 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { emailTailwindConfig } from "../tailwind-config";
export type TemplateProps = {
userName: string;
code: string;
expiresInMinutes: number;
};
export const AccountDeletionCodeTemplate = ({
userName = "User",
code = "123456",
expiresInMinutes = 10,
}: TemplateProps) => {
const previewText = `${code} is your account deletion code`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={emailTailwindConfig}>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Confirm your account deletion
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, we received a request to permanently delete
your Dokploy account. Enter the code below to confirm it.
</Text>
<Section className="text-center mb-[24px]">
<Text className="inline-block bg-[#f4f4f5] rounded-lg text-[#09090b] text-[32px] font-semibold tracking-[8px] px-[24px] py-[16px] m-0">
{code}
</Text>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
This code expires in {expiresInMinutes} minutes. Once confirmed,
your subscriptions are cancelled and every organization you own
is deleted. This cannot be undone.
</Text>
</Section>
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
If you did not request this, someone else may have access to
your account. Change your password and contact{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy support
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default AccountDeletionCodeTemplate;
+1
View File
@@ -7,6 +7,7 @@ export * from "./db/validations/index";
export * from "./lib/auth";
export * from "./lib/logger";
export * from "./monitoring/utils";
export * from "./services/account-deletion";
export * from "./services/admin";
export * from "./services/application";
export * from "./services/backup";
+6
View File
@@ -126,6 +126,12 @@ const createBetterAuth = () =>
...(await resolveTrustedOrigins()),
].filter(Boolean);
if (ctx.path === "/admin/remove-user") {
throw new APIError("FORBIDDEN", {
message: "Use the account deletion flow to remove users",
});
}
const isBlockedAuthPath =
ctx.path.startsWith("/sign-in/email") ||
ctx.path.startsWith("/sign-in/social") ||
+15 -7
View File
@@ -59,6 +59,19 @@ export const recordAdvancedStats = async (
}
};
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
const NON_PHYSICAL_DEVICE_PATTERNS = [
/^(loop|ram|zram)/,
/^(sr|fd)\d+$/,
// Partitions and md/dm devices repeat I/O already counted on the underlying disk.
/^(sd|hd|vd|xvd)[a-z]+\d+$/,
/^(nvme\d+n\d+|mmcblk\d+)p\d+$/,
/^(md|dm-)/,
];
export const isPhysicalDisk = (device: string) =>
!NON_PHYSICAL_DEVICE_PATTERNS.some((pattern) => pattern.test(device));
/**
* Get host system statistics using node-os-utils
* This is used when monitoring "dokploy" to show host stats instead of container stats
@@ -99,14 +112,9 @@ export const getHostSystemStats = async (): Promise<Container> => {
let blockWriteBytes = 0;
const diskStats = await osutils.disk.stats();
if (diskStats.success && diskStats.data.length > 0) {
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
const excludePatterns = [/^loop/, /^ram/, /^sr\d+$/, /^fd\d+$/];
for (const stat of diskStats.data) {
// Skip virtual devices
if (
stat.device &&
excludePatterns.some((pattern) => pattern.test(stat.device))
) {
// Skip virtual, partition and stacked devices
if (stat.device && !isPhysicalDisk(stat.device)) {
continue;
}
// readBytes and writeBytes are DataSize objects with .toBytes() method
@@ -0,0 +1,179 @@
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { db } from "@dokploy/server/db";
import {
auditLog,
backups,
invitation,
organization,
user,
verification,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq, inArray } from "drizzle-orm";
import { nanoid } from "nanoid";
export const ANONYMIZED_EMAIL = "deleted-user";
export const ACCOUNT_DELETION_CODE_TTL_MINUTES = 10;
const ACCOUNT_DELETION_CODE_RESEND_SECONDS = 60;
const ACCOUNT_DELETION_CODE_MAX_ATTEMPTS = 5;
const codeIdentifier = (userId: string) => `account-deletion:${userId}`;
const hashCode = (code: string) =>
createHash("sha256").update(code.trim()).digest("hex");
export const generateDeletionCode = () =>
randomInt(0, 1_000_000).toString().padStart(6, "0");
export const serializeDeletionCode = (code: string) =>
JSON.stringify({ hash: hashCode(code), attempts: 0 });
export const checkDeletionCode = (value: string, code: string) => {
const stored = JSON.parse(value) as { hash: string; attempts: number };
const expected = Buffer.from(stored.hash, "hex");
const actual = Buffer.from(hashCode(code), "hex");
if (expected.length === actual.length && timingSafeEqual(expected, actual)) {
return { valid: true as const, attempts: stored.attempts };
}
const attempts = stored.attempts + 1;
return {
valid: false as const,
attempts,
nextValue: JSON.stringify({ ...stored, attempts }),
};
};
export const createAccountDeletionCode = async (userId: string) => {
const identifier = codeIdentifier(userId);
const existing = await db.query.verification.findFirst({
where: eq(verification.identifier, identifier),
});
if (
existing?.createdAt &&
Date.now() - existing.createdAt.getTime() <
ACCOUNT_DELETION_CODE_RESEND_SECONDS * 1000
) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Wait ${ACCOUNT_DELETION_CODE_RESEND_SECONDS} seconds before requesting a new code`,
});
}
await db.delete(verification).where(eq(verification.identifier, identifier));
const code = generateDeletionCode();
const now = new Date();
await db.insert(verification).values({
id: nanoid(),
identifier,
value: serializeDeletionCode(code),
expiresAt: new Date(
now.getTime() + ACCOUNT_DELETION_CODE_TTL_MINUTES * 60_000,
),
createdAt: now,
updatedAt: now,
});
return code;
};
export const consumeAccountDeletionCode = async (
userId: string,
code: string,
) => {
const row = await db.query.verification.findFirst({
where: eq(verification.identifier, codeIdentifier(userId)),
});
if (!row) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Request a confirmation code first",
});
}
if (row.expiresAt.getTime() < Date.now()) {
await db.delete(verification).where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "The code has expired, request a new one",
});
}
const result = checkDeletionCode(row.value, code);
if (!result.valid) {
if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) {
await db.delete(verification).where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "Too many incorrect attempts, request a new code",
});
}
await db
.update(verification)
.set({ value: result.nextValue, updatedAt: new Date() })
.where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "The code is incorrect",
});
}
await db.delete(verification).where(eq(verification.id, row.id));
return { requestedAt: row.createdAt };
};
export const deleteUserAccountData = async (userId: string) => {
return db.transaction(async (tx) => {
const target = await tx.query.user.findFirst({
where: eq(user.id, userId),
columns: { id: true, email: true },
});
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const ownedOrganizations = await tx.query.organization.findMany({
where: eq(organization.ownerId, userId),
columns: { id: true },
});
const organizationIds = ownedOrganizations.map((org) => org.id);
if (organizationIds.length > 0) {
await tx
.delete(auditLog)
.where(inArray(auditLog.organizationId, organizationIds));
}
// Actions performed in organizations owned by someone else stay in
// their audit trail, but without the deleted user's PII.
await tx
.update(auditLog)
.set({ userEmail: ANONYMIZED_EMAIL })
.where(eq(auditLog.userId, userId));
await tx.delete(invitation).where(eq(invitation.email, target.email));
// backups.userId has no ON DELETE clause, so a leftover reference would
// abort the user delete.
await tx
.update(backups)
.set({ userId: null })
.where(eq(backups.userId, userId));
await tx.delete(user).where(eq(user.id, userId));
return {
email: target.email,
organizationsDeleted: organizationIds.length,
};
});
};
+10
View File
@@ -566,3 +566,13 @@ export const createApiKey = async (
return result;
};
export const findCredentialAccount = async (userId: string) => {
return db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, "credential"),
),
columns: { id: true, password: true },
});
};
@@ -65,6 +65,7 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
"X-Pplx-Integration": "dokploy",
},
});
case "mistral":
+15 -3
View File
@@ -1,12 +1,24 @@
// A shell word as produced by shell-quote: bare, 'single' or "double" quoted.
const SHELL_WORD = String.raw`(?:[^\s'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+`;
const ACCESS_KEY_PATTERN = new RegExp(
`(--s3-access-key-id=)${SHELL_WORD}`,
"g",
);
const SECRET_KEY_PATTERN = new RegExp(
`(--s3-secret-access-key=)${SHELL_WORD}`,
"g",
);
/**
* Redacts S3 credentials from rclone command strings.
*
* Used to prevent credential leakage in structured logs and error output.
* Matches the flag format produced by `getS3Credentials()`:
* --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE"
* --s3-access-key-id=VALUE and --s3-secret-access-key=VALUE (shell-quoted)
*/
export const redactRcloneCredentials = (command: string): string => {
return command
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
.replace(ACCESS_KEY_PATTERN, '$1"[REDACTED]"')
.replace(SECRET_KEY_PATTERN, '$1"[REDACTED]"');
};
@@ -123,3 +123,48 @@ export async function submitToHubSpot(
return false;
}
}
export type HubSpotDeletionResult =
| "deleted"
| "not_found"
| "skipped"
| "failed";
export async function deleteHubSpotContactByEmail(
email: string,
accessToken = process.env.HUBSPOT_ACCESS_TOKEN,
): Promise<HubSpotDeletionResult> {
if (!accessToken) {
return "skipped";
}
try {
const response = await fetch(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ idProperty: "email", objectId: email }),
},
);
if (response.ok) {
return "deleted";
}
if (response.status === 404) {
return "not_found";
}
console.error(
"HubSpot GDPR delete error:",
response.status,
await response.text(),
);
return "failed";
} catch (error) {
console.error("Error deleting HubSpot contact:", error);
return "failed";
}
}
@@ -1,4 +1,5 @@
import { render } from "@react-email/components";
import AccountDeletionCodeTemplate from "../emails/emails/account-deletion-code";
import InvitationEmail from "../emails/emails/invitation";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
@@ -91,3 +92,28 @@ export const sendInvitationEmail = async ({
text: html,
});
};
export const sendAccountDeletionCodeEmail = async ({
email,
userName,
code,
expiresInMinutes,
}: {
email: string;
userName: string;
code: string;
expiresInMinutes: number;
}) => {
const html = await render(
AccountDeletionCodeTemplate({
userName: userName || "User",
code,
expiresInMinutes,
}),
);
await sendEmail({
email,
subject: `${code} is your account deletion code`,
text: html,
});
};