mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 21:55:31 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12784ec0ff | |||
| 5bc53bfbff | |||
| 549b124fcd | |||
| 0c5da0b36f | |||
| ee18724dd7 | |||
| 1ae9b4025c | |||
| 5bc870dc2d | |||
| 299950a323 |
+8403
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
|
||||
HOSTINGER_API_KEY=
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
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,6 +1,4 @@
|
||||
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)", () => {
|
||||
@@ -50,48 +48,3 @@ 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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
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 flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Patches</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Patches</CardTitle>
|
||||
<CardDescription>
|
||||
Apply code patches to your repository during build. Patches are
|
||||
applied after cloning the repository and before building.
|
||||
|
||||
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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} />
|
||||
)}
|
||||
</div>
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-0">
|
||||
{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" />
|
||||
|
||||
+27
-18
@@ -77,26 +77,35 @@ export const ShowVolumeBackups = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-0">
|
||||
{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" />
|
||||
|
||||
+1
@@ -158,6 +158,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
composeId,
|
||||
sourceType: "bitbucket",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
sourceType: "git",
|
||||
composePath: values.composePath,
|
||||
composeStatus: "idle",
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
|
||||
+1
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
giteaId: data.giteaId,
|
||||
composeId,
|
||||
sourceType: "gitea",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
} as any)
|
||||
|
||||
+1
@@ -155,6 +155,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
githubId: data.githubId,
|
||||
sourceType: "github",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
triggerType: data.triggerType,
|
||||
|
||||
+1
@@ -169,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
gitlabProjectId: data.repository.id,
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
sourceType: "gitlab",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ClipboardList,
|
||||
Database,
|
||||
DatabaseBackup,
|
||||
Play,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -95,9 +101,12 @@ export const ShowBackups = ({
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<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>
|
||||
<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>
|
||||
<CardDescription>
|
||||
Add backups to your database to save the data to a different
|
||||
provider.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
@@ -25,6 +27,7 @@ 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,
|
||||
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ShowDeploymentsTableProps {
|
||||
globalFilter: string;
|
||||
statusFilter: string;
|
||||
typeFilter: string;
|
||||
}
|
||||
|
||||
export function ShowDeploymentsTable({
|
||||
globalFilter,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
}: ShowDeploymentsTableProps) {
|
||||
export function ShowDeploymentsTable() {
|
||||
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,
|
||||
@@ -450,140 +446,177 @@ 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="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>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
</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 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 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>
|
||||
</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..."
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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,7 +46,6 @@ 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;
|
||||
|
||||
@@ -79,12 +78,12 @@ export const ImpersonationBar = () => {
|
||||
});
|
||||
|
||||
const filteredUsers = response.data?.users.filter(
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||
);
|
||||
|
||||
if (!response.error) {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
setUsers(filteredUsers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -283,7 +282,6 @@ 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,6 +1,5 @@
|
||||
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 {
|
||||
@@ -9,14 +8,6 @@ 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;
|
||||
@@ -35,10 +26,6 @@ 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;
|
||||
@@ -72,50 +59,12 @@ export const ShowOverviewDeployments = () => {
|
||||
onValueChange={setSubtab}
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
<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>
|
||||
<TabsList className="mt-2">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||
<ShowDeploymentsTable
|
||||
globalFilter={globalFilter}
|
||||
statusFilter={statusFilter}
|
||||
typeFilter={typeFilter}
|
||||
/>
|
||||
<ShowDeploymentsTable />
|
||||
</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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import { CreditCard, FileText, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
@@ -29,7 +34,7 @@ export const ShowBillingInvoices = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -83,6 +84,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: <Clock className="size-3" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
provisioning: {
|
||||
label: "Provisioning",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
configuring: {
|
||||
label: "Installing Dokploy",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
variant: "default",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
icon: <XCircle className="size-3" />,
|
||||
variant: "destructive",
|
||||
},
|
||||
terminating: {
|
||||
label: "Terminating",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
terminated: {
|
||||
label: "Terminated",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
variant: "outline",
|
||||
},
|
||||
};
|
||||
|
||||
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
|
||||
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
|
||||
}
|
||||
|
||||
function formatPrice(priceCents: number) {
|
||||
return `$${(priceCents / 100).toFixed(2)}/mo`;
|
||||
}
|
||||
|
||||
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
|
||||
|
||||
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||
const [selectedZone, setSelectedZone] = useState<string>("");
|
||||
|
||||
const { data: plans, isLoading: loadingPlans } =
|
||||
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||
const { data: zones, isLoading: loadingZones } =
|
||||
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||
|
||||
const isLoadingOptions = loadingPlans || loadingZones;
|
||||
|
||||
const purchase = api.managedServer.purchase.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server order placed! Provisioning will take ~5 minutes.");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const plan = plans?.find((p) => p.id === selectedPlan);
|
||||
|
||||
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
|
||||
if (!acc[z.continent]) acc[z.continent] = [];
|
||||
acc[z.continent]!.push(z);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Order Server
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Order a Managed Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll provision and configure a server for you automatically. Ready
|
||||
in ~5 minutes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading available plans...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Plan cards */}
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{plans?.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlan(p.id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
|
||||
selectedPlan === p.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{formatPrice(p.priceCents)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone selector grouped by continent */}
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<Select value={selectedZone} onValueChange={setSelectedZone}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a location..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
className="max-h-64 overflow-y-auto"
|
||||
>
|
||||
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
|
||||
<div key={continent}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{continent}
|
||||
</div>
|
||||
{groupedZones?.[continent]?.map((z) => (
|
||||
<SelectItem key={z.id} value={z.id}>
|
||||
{z.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{plan && selectedZone && (
|
||||
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Specs</span>
|
||||
<span className="font-medium">
|
||||
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Zone</span>
|
||||
<span className="font-medium">
|
||||
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedPlan || !selectedZone || purchase.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedPlan || !selectedZone) return;
|
||||
purchase.mutate({
|
||||
plan: selectedPlan,
|
||||
zone: selectedZone,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{purchase.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Placing order...
|
||||
</>
|
||||
) : (
|
||||
"Order Server"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
hobby: "Hobby",
|
||||
starter: "Starter",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
};
|
||||
|
||||
export const ShowManagedServers = () => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: servers, isLoading } = api.managedServer.list.useQuery();
|
||||
|
||||
const syncStatus = api.managedServer.syncStatus.useMutation({
|
||||
onSuccess: () => utils.managedServer.list.invalidate(),
|
||||
});
|
||||
|
||||
const reconnect = api.managedServer.reconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server reconnected successfully.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteServer = api.managedServer.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server terminated.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<Server className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and servers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Managed Servers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Servers provisioned and managed by Dokploy Cloud
|
||||
</p>
|
||||
</div>
|
||||
<OrderServerDialog
|
||||
onSuccess={() => utils.managedServer.list.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers?.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg border-dashed">
|
||||
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No managed servers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Order a server and we'll provision and configure it for you
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers?.map((s) => {
|
||||
const status =
|
||||
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
|
||||
const isProvisioning = [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
].includes(s.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.managedServerId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="size-5 text-muted-foreground shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PLAN_LABELS[s.plan] ?? s.plan}
|
||||
</span>
|
||||
<Badge
|
||||
variant={status?.variant}
|
||||
className="flex items-center gap-1 text-xs h-5"
|
||||
>
|
||||
{status?.icon}
|
||||
{status?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.hostname ?? ""}
|
||||
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
||||
{s.zone ? ` · ${s.zone}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isProvisioning && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncStatus.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={syncStatus.isPending}
|
||||
>
|
||||
<Loader2
|
||||
className={cn(
|
||||
"size-4",
|
||||
syncStatus.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && !s.serverId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
reconnect.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={reconnect.isPending}
|
||||
>
|
||||
{reconnect.isPending ? (
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && s.server && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/settings/server?serverId=${s.serverId}`}
|
||||
>
|
||||
<ExternalLink className="size-3.5 mr-1.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Terminate Server"
|
||||
description="This will permanently destroy the server and all data on it. This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={() =>
|
||||
deleteServer.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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">
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
+19
-17
@@ -1,4 +1,6 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,31 +15,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
children?: React.ReactNode;
|
||||
asButton?: boolean;
|
||||
}
|
||||
|
||||
export const ShowServerActions = ({ serverId, children }: Props) => {
|
||||
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : (
|
||||
{asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
View Actions
|
||||
</DropdownMenuItem>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
View Actions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DialogContent
|
||||
className="sm:max-w-xl"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
||||
|
||||
@@ -121,10 +121,7 @@ export const DeleteServerModal = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Pencil, 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;
|
||||
children?: React.ReactNode;
|
||||
asButton?: boolean;
|
||||
}
|
||||
|
||||
export const HandleServers = ({ serverId, children }: Props) => {
|
||||
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: canCreateMoreServers, refetch } =
|
||||
@@ -141,19 +141,24 @@ export const HandleServers = ({ serverId, children }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : serverId ? (
|
||||
<DialogTrigger asChild>
|
||||
{serverId ? (
|
||||
asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<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">
|
||||
@@ -162,10 +167,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl "
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-3xl ">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
children?: React.ReactNode;
|
||||
asButton?: boolean;
|
||||
}
|
||||
|
||||
export const SetupServer = ({ serverId, children }: Props) => {
|
||||
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
@@ -81,19 +81,24 @@ export const SetupServer = ({ serverId, children }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : (
|
||||
{asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full cursor-pointer " size="sm">
|
||||
Setup Server <Settings className="size-4" />
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings className="h-4 w-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 "
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl ">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -7,27 +7,19 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
|
||||
interface Props {
|
||||
url: string;
|
||||
token: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
|
||||
export const ShowMonitoringModal = ({ url, token }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{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()}
|
||||
>
|
||||
<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 ">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
BarChartHorizontalBigIcon,
|
||||
Clock,
|
||||
Key,
|
||||
KeyIcon,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
ServerIcon,
|
||||
Settings,
|
||||
Terminal,
|
||||
Trash2,
|
||||
User,
|
||||
@@ -53,7 +49,7 @@ export const ShowServers = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{query?.success && isCloud && <WelcomeSubscription />}
|
||||
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -228,19 +224,11 @@ 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>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="w-full cursor-pointer "
|
||||
size="sm"
|
||||
>
|
||||
Setup Server{" "}
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</SetupServer>
|
||||
<TooltipTrigger asChild>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-xs"
|
||||
side="bottom"
|
||||
@@ -263,20 +251,22 @@ export const ShowServers = () => {
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</TerminalModal>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
@@ -284,19 +274,14 @@ export const ShowServers = () => {
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<HandleServers
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit Server</p>
|
||||
</TooltipContent>
|
||||
@@ -304,19 +289,14 @@ export const ShowServers = () => {
|
||||
|
||||
{server.sshKeyId && !isBuildServer && (
|
||||
<Tooltip>
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Web Server Actions</p>
|
||||
</TooltipContent>
|
||||
@@ -327,23 +307,17 @@ export const ShowServers = () => {
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig
|
||||
?.server?.token
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Monitoring</p>
|
||||
</TooltipContent>
|
||||
@@ -354,20 +328,22 @@ export const ShowServers = () => {
|
||||
|
||||
{permissions?.server.delete && (
|
||||
<Tooltip>
|
||||
<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"
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DeleteServerModal
|
||||
serverId={server.serverId}
|
||||
serverName={server.name}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DeleteServerModal>
|
||||
<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>
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -124,11 +124,8 @@ export const AddInvitation = () => {
|
||||
const mode = form.watch("mode");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setError(null);
|
||||
}
|
||||
}, [open, form, form.reset]);
|
||||
form.reset();
|
||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloud && form.getValues("mode") === "credentials") {
|
||||
@@ -228,7 +225,7 @@ export const AddInvitation = () => {
|
||||
<FormLabel>Invite Method</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -316,7 +313,7 @@ export const AddInvitation = () => {
|
||||
<FormLabel>Email Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -45,8 +45,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
addonFit.fit();
|
||||
|
||||
@@ -88,9 +89,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
}, [id, serverId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 h-full w-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full h-full bg-transparent border rounded-lg p-2">
|
||||
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
|
||||
<div id={id} ref={termRef} className="rounded-xl" />
|
||||
</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 { useHubSpotChat } from "../shared/analytics";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import Page from "./side";
|
||||
|
||||
interface Props {
|
||||
@@ -26,7 +26,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
});
|
||||
|
||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||
useHubSpotChat(isChatEnabled);
|
||||
|
||||
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
||||
const shouldRedirectToOnboarding =
|
||||
@@ -53,6 +52,12 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
</Head>
|
||||
)}
|
||||
<Page>{children}</Page>
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
<HubSpotWidget />
|
||||
</>
|
||||
)}
|
||||
|
||||
{haveRootAccess === true && <ImpersonationBar />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
|
||||
|
||||
export const ManageCustomRoles = () => {
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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,11 +92,5 @@ export const useHubSpotChat = (enabled: boolean) => {
|
||||
loadWidget,
|
||||
];
|
||||
}
|
||||
return () => {
|
||||
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
|
||||
(callback) => callback !== loadWidget,
|
||||
);
|
||||
window.HubSpotConversations?.widget.remove();
|
||||
};
|
||||
}, [enabled]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
|
||||
CREATE TABLE "managed_server" (
|
||||
"managedServerId" text PRIMARY KEY NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"plan" text NOT NULL,
|
||||
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
|
||||
"providerVmId" text,
|
||||
"zone" text NOT NULL,
|
||||
"sshKeyId" text,
|
||||
"ipAddress" text,
|
||||
"hostname" text,
|
||||
"stripeSubscriptionId" text,
|
||||
"stripePriceId" text,
|
||||
"errorMessage" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"updatedAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1380,6 +1380,13 @@
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1790609715152,
|
||||
"tag": "0197_aberrant_psynapse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,30 +1,7 @@
|
||||
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 } };
|
||||
|
||||
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);
|
||||
}
|
||||
export default toNodeHandler(auth.handler);
|
||||
|
||||
@@ -32,14 +32,6 @@ 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,14 +15,6 @@ import {
|
||||
logWebhookError,
|
||||
} from "../[refreshToken]";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: "25mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
|
||||
@@ -26,14 +26,6 @@ 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,
|
||||
|
||||
@@ -158,9 +158,11 @@ export default async function handler(
|
||||
newSubscription.customer as string,
|
||||
);
|
||||
|
||||
// The account may already be gone when the deletion flow cancelled
|
||||
// the subscription; nothing left to disable.
|
||||
if (!admin || admin.isEnterpriseCloud) {
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
if (admin.isEnterpriseCloud) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -312,7 +314,11 @@ export default async function handler(
|
||||
const customer = event.data.object as Stripe.Customer;
|
||||
|
||||
const admin = await findUserByStripeCustomerId(customer.id);
|
||||
if (!admin || admin.isEnterpriseCloud) {
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
if (admin.isEnterpriseCloud) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ function SchedulesPage() {
|
||||
{(serverId) => (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
|
||||
<ShowSchedules
|
||||
scheduleType={serverId ? "server" : "dokploy-server"}
|
||||
id={serverId ?? "dokploy-server"}
|
||||
/>
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<ShowSchedules
|
||||
scheduleType={serverId ? "server" : "dokploy-server"}
|
||||
id={serverId ?? "dokploy-server"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ const Page = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
|
||||
const Page = () => {
|
||||
return <ShowManagedServers />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext,
|
||||
) {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { props: {} };
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -17,11 +16,10 @@ const Page = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full 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">
|
||||
|
||||
@@ -38,6 +38,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||
import { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
@@ -122,6 +123,7 @@ export const appRouter = createTRPCRouter({
|
||||
patch: patchRouter,
|
||||
overview: overviewRouter,
|
||||
transfer: transferRouter,
|
||||
managedServer: managedServerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -545,6 +545,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
sourceType: "github",
|
||||
owner: input.owner,
|
||||
buildPath: input.buildPath,
|
||||
applicationStatus: "idle",
|
||||
githubId: input.githubId,
|
||||
watchPaths: input.watchPaths,
|
||||
triggerType: input.triggerType,
|
||||
@@ -571,6 +572,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
gitlabBranch: input.gitlabBranch,
|
||||
gitlabBuildPath: input.gitlabBuildPath,
|
||||
sourceType: "gitlab",
|
||||
applicationStatus: "idle",
|
||||
gitlabId: input.gitlabId,
|
||||
gitlabProjectId: input.gitlabProjectId,
|
||||
gitlabPathNamespace: input.gitlabPathNamespace,
|
||||
@@ -599,6 +601,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
bitbucketBranch: input.bitbucketBranch,
|
||||
bitbucketBuildPath: input.bitbucketBuildPath,
|
||||
sourceType: "bitbucket",
|
||||
applicationStatus: "idle",
|
||||
bitbucketId: input.bitbucketId,
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
@@ -624,6 +627,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
giteaBranch: input.giteaBranch,
|
||||
giteaBuildPath: input.giteaBuildPath,
|
||||
sourceType: "gitea",
|
||||
applicationStatus: "idle",
|
||||
giteaId: input.giteaId,
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
@@ -648,6 +652,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
username: input.username,
|
||||
password: input.password,
|
||||
sourceType: "docker",
|
||||
applicationStatus: "idle",
|
||||
registryUrl: input.registryUrl,
|
||||
});
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
@@ -671,6 +676,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
customGitUrl: input.customGitUrl,
|
||||
customGitSSHKeyId: input.customGitSSHKeyId,
|
||||
sourceType: "git",
|
||||
applicationStatus: "idle",
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
});
|
||||
@@ -723,6 +729,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
customGitSSHKeyId: null,
|
||||
|
||||
sourceType: "github", // Reset to default
|
||||
applicationStatus: "idle",
|
||||
watchPaths: null,
|
||||
enableSubmodules: false,
|
||||
});
|
||||
|
||||
@@ -759,6 +759,7 @@ export const composeRouter = createTRPCRouter({
|
||||
customGitSSHKeyId: null,
|
||||
|
||||
sourceType: "github", // Reset to default
|
||||
composeStatus: "idle",
|
||||
watchPaths: null,
|
||||
enableSubmodules: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
createServer,
|
||||
IS_CLOUD,
|
||||
serverSetup,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
apiCreateManagedServer,
|
||||
apiDeleteManagedServer,
|
||||
apiFindOneManagedServer,
|
||||
} from "@dokploy/server/db/schema/managed-server";
|
||||
import {
|
||||
createManagedServer,
|
||||
deleteManagedServer,
|
||||
findManagedServerById,
|
||||
findManagedServersByOrg,
|
||||
updateManagedServer,
|
||||
} from "@dokploy/server/services/managed-server";
|
||||
import { createSshKey } from "@dokploy/server/services/ssh-key";
|
||||
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
|
||||
import {
|
||||
DOKPLOY_PLANS,
|
||||
createUpCloudServer,
|
||||
deleteUpCloudServer,
|
||||
getPublicIPv4,
|
||||
getUpCloudServer,
|
||||
getUpCloudZones,
|
||||
stopUpCloudServer,
|
||||
} from "@dokploy/server/utils/upcloud";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { adminProcedure, createTRPCRouter } from "../../trpc";
|
||||
|
||||
|
||||
export const managedServerRouter = createTRPCRouter({
|
||||
getPlans: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return DOKPLOY_PLANS;
|
||||
}),
|
||||
|
||||
getDataCenters: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return getUpCloudZones();
|
||||
}),
|
||||
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return [];
|
||||
return findManagedServersByOrg(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
one: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return record;
|
||||
}),
|
||||
|
||||
purchase: adminProcedure
|
||||
.input(apiCreateManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
const plan = DOKPLOY_PLANS.find((p) => p.id === input.plan);
|
||||
if (!plan) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
|
||||
}
|
||||
|
||||
const hostname =
|
||||
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
|
||||
|
||||
const managedRecord = await createManagedServer({
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
plan: input.plan,
|
||||
zone: input.zone,
|
||||
status: "provisioning",
|
||||
});
|
||||
|
||||
provisionManagedServer(
|
||||
managedRecord.managedServerId,
|
||||
plan.upcloudPlan,
|
||||
input.zone,
|
||||
hostname,
|
||||
ctx.session.activeOrganizationId,
|
||||
).catch(async (err) => {
|
||||
const responseBody = err?.response?.data;
|
||||
const detail =
|
||||
responseBody?.message ??
|
||||
responseBody?.error ??
|
||||
(typeof responseBody === "string" ? responseBody : null);
|
||||
const errorMessage = detail
|
||||
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
|
||||
: (err?.message ?? "Unknown error during provisioning");
|
||||
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
|
||||
await updateManagedServer(managedRecord.managedServerId, {
|
||||
status: "error",
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
return managedRecord;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(apiDeleteManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
status: "terminating",
|
||||
});
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
|
||||
(err) => console.error("[managed-server] termination failed:", err),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
reconnect: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (record.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is already linked",
|
||||
});
|
||||
}
|
||||
if (!record.ipAddress) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No IP address available to reconnect",
|
||||
});
|
||||
}
|
||||
|
||||
if (!record.sshKeyId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No SSH key found for this managed server",
|
||||
});
|
||||
}
|
||||
|
||||
const name = record.hostname ?? record.managedServerId;
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${name}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: record.ipAddress,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: record.sshKeyId,
|
||||
},
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
|
||||
syncStatus: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
if (!upcloudUuid) return record;
|
||||
|
||||
const vm = await getUpCloudServer(upcloudUuid);
|
||||
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
ipAddress,
|
||||
hostname: vm.hostname ?? undefined,
|
||||
status:
|
||||
vm.state === "started"
|
||||
? record.serverId
|
||||
? "ready"
|
||||
: "configuring"
|
||||
: record.status,
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
});
|
||||
|
||||
async function provisionManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudPlan: string,
|
||||
zone: string,
|
||||
hostname: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const { publicKey, privateKey } = await generateSSHKey("rsa");
|
||||
|
||||
const vm = await createUpCloudServer({
|
||||
hostname,
|
||||
upcloudPlan,
|
||||
zone,
|
||||
sshKey: publicKey,
|
||||
});
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
providerVmId: vm.uuid,
|
||||
hostname: vm.hostname,
|
||||
status: "configuring",
|
||||
});
|
||||
|
||||
await waitForServerStarted(vm.uuid, managedServerId);
|
||||
|
||||
const finalVm = await getUpCloudServer(vm.uuid);
|
||||
const finalIp = getPublicIPv4(finalVm);
|
||||
|
||||
if (!finalIp) {
|
||||
throw new Error("VM is started but has no public IPv4 address");
|
||||
}
|
||||
|
||||
const sshKey = await createSshKey({
|
||||
name: hostname,
|
||||
publicKey,
|
||||
privateKey,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (!sshKey) throw new Error("Failed to create SSH key");
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${hostname}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: finalIp,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
},
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
ipAddress: finalIp,
|
||||
});
|
||||
|
||||
await serverSetup(serverRecord.serverId);
|
||||
await updateManagedServer(managedServerId, { status: "ready" });
|
||||
}
|
||||
|
||||
async function waitForServerStarted(
|
||||
uuid: string,
|
||||
_managedServerId: string,
|
||||
maxAttempts = 30,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "started") return;
|
||||
if (vm.state === "error") throw new Error("VM entered error state");
|
||||
}
|
||||
throw new Error("Timed out waiting for VM to start");
|
||||
}
|
||||
|
||||
async function waitForServerStopped(
|
||||
uuid: string,
|
||||
maxAttempts = 18,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "stopped") return;
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudUuid: string | null,
|
||||
) {
|
||||
if (upcloudUuid) {
|
||||
await stopUpCloudServer(upcloudUuid);
|
||||
await waitForServerStopped(upcloudUuid);
|
||||
await deleteUpCloudServer(upcloudUuid);
|
||||
}
|
||||
await deleteManagedServer(managedServerId);
|
||||
}
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
updateServerById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
findMemberByUserId,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
@@ -54,22 +51,6 @@ import {
|
||||
} from "@/server/db/schema";
|
||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||
|
||||
// Users without server.read only get what the server pickers render
|
||||
const toServerOptions = async (
|
||||
ctx: Parameters<typeof hasPermission>[0],
|
||||
servers: (typeof server.$inferSelect)[],
|
||||
) => {
|
||||
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
|
||||
if (await hasPermission(ctx, { server: ["read"] })) {
|
||||
return servers;
|
||||
}
|
||||
return servers.map(({ serverId, name, ipAddress }) => ({
|
||||
serverId,
|
||||
name,
|
||||
ipAddress,
|
||||
}));
|
||||
};
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: withPermission("server", "create")
|
||||
.input(apiCreateServer)
|
||||
@@ -235,7 +216,7 @@ export const serverRouter = createTRPCRouter({
|
||||
|
||||
return servers.length ?? 0;
|
||||
}),
|
||||
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
|
||||
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
const result = await db.query.server.findMany({
|
||||
@@ -253,12 +234,9 @@ export const serverRouter = createTRPCRouter({
|
||||
eq(server.serverType, "deploy"),
|
||||
),
|
||||
});
|
||||
return toServerOptions(
|
||||
ctx,
|
||||
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||
);
|
||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
||||
}),
|
||||
buildServers: protectedProcedure.query(async ({ ctx }) => {
|
||||
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
const result = await db.query.server.findMany({
|
||||
@@ -276,10 +254,7 @@ export const serverRouter = createTRPCRouter({
|
||||
eq(server.serverType, "build"),
|
||||
),
|
||||
});
|
||||
return toServerOptions(
|
||||
ctx,
|
||||
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||
);
|
||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
||||
}),
|
||||
setup: withPermission("server", "create")
|
||||
.input(apiFindOneServer)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateSSHKeyById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -57,6 +58,16 @@ export const sshRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const linkedManagedServer = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.sshKeyId, input.sshKeyId),
|
||||
});
|
||||
if (linkedManagedServer) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This SSH key is used by a managed server and cannot be deleted.",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "sshKey",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
ACCOUNT_DELETION_CODE_TTL_MINUTES,
|
||||
consumeAccountDeletionCode,
|
||||
createAccountDeletionCode,
|
||||
createApiKey,
|
||||
createOrganizationUserWithCredentials,
|
||||
findCredentialAccount,
|
||||
findNotificationById,
|
||||
findOrganizationById,
|
||||
findPasskeysByUserId,
|
||||
@@ -15,7 +11,6 @@ import {
|
||||
IS_CLOUD,
|
||||
removeUserById,
|
||||
renderInvitationEmail,
|
||||
sendAccountDeletionCodeEmail,
|
||||
sendEmailNotification,
|
||||
sendResendNotification,
|
||||
updateUser,
|
||||
@@ -29,7 +24,6 @@ import {
|
||||
apiUpdateUser,
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
session,
|
||||
user,
|
||||
} from "@dokploy/server/db/schema";
|
||||
@@ -44,7 +38,6 @@ 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,
|
||||
@@ -70,47 +63,6 @@ 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({
|
||||
@@ -226,7 +178,16 @@ export const userRouter = createTRPCRouter({
|
||||
return findPasskeysByUserId(ctx.user.id);
|
||||
}),
|
||||
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
|
||||
return hasRootAccess(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;
|
||||
}),
|
||||
getBackups: adminProcedure.query(async ({ ctx }) => {
|
||||
const memberResult = await db.query.member.findFirst({
|
||||
@@ -497,97 +458,6 @@ 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 }) => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -173,36 +173,3 @@ export const getBillingStatus = async (
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -33,11 +33,6 @@ 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) {
|
||||
@@ -59,33 +54,10 @@ 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;
|
||||
@@ -183,11 +155,30 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", stopTailProcess);
|
||||
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 {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up resources on error
|
||||
stopTailProcess();
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGKILL");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"hostinger-api-sdk": "^0.0.17",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.18",
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./managed-server";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
|
||||
@@ -35,8 +35,6 @@ import {
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
@@ -117,7 +115,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).max(63).regex(APP_NAME_REGEX, APP_NAME_MESSAGE),
|
||||
appName: z.string().min(1),
|
||||
createdAt: z.string(),
|
||||
databaseUser: z.string().min(1),
|
||||
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
|
||||
export const managedServerStatus = pgEnum("managedServerStatus", [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
export const managedServer = pgTable("managed_server", {
|
||||
managedServerId: text("managedServerId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** UpCloud plan name, e.g. "2xCPU-4GB" */
|
||||
plan: text("plan").notNull(),
|
||||
status: managedServerStatus("status").notNull().default("pending"),
|
||||
/** UpCloud server UUID */
|
||||
providerVmId: text("providerVmId"),
|
||||
/** UpCloud zone ID, e.g. "fi-hel1" */
|
||||
zone: text("zone").notNull(),
|
||||
/** SSH key used to access this server */
|
||||
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
ipAddress: text("ipAddress"),
|
||||
hostname: text("hostname"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
stripePriceId: text("stripePriceId"),
|
||||
errorMessage: text("errorMessage"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text("updatedAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const managedServerRelations = relations(managedServer, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [managedServer.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [managedServer.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apiCreateManagedServer = z.object({
|
||||
plan: z.string().min(1),
|
||||
zone: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindOneManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
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;
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -51,6 +50,7 @@ export * from "./services/registry";
|
||||
export * from "./services/rollbacks";
|
||||
export * from "./services/schedule";
|
||||
export * from "./services/security";
|
||||
export * from "./services/managed-server";
|
||||
export * from "./services/server";
|
||||
export * from "./services/server-health";
|
||||
export * from "./services/settings";
|
||||
|
||||
@@ -126,12 +126,6 @@ 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") ||
|
||||
|
||||
@@ -59,19 +59,6 @@ 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
|
||||
@@ -112,9 +99,14 @@ 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, partition and stacked devices
|
||||
if (stat.device && !isPhysicalDisk(stat.device)) {
|
||||
// Skip virtual devices
|
||||
if (
|
||||
stat.device &&
|
||||
excludePatterns.some((pattern) => pattern.test(stat.device))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// readBytes and writeBytes are DataSize objects with .toBytes() method
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type ManagedServer = typeof managedServer.$inferSelect;
|
||||
|
||||
export const createManagedServer = async (
|
||||
input: typeof managedServer.$inferInsert,
|
||||
) => {
|
||||
const record = await db
|
||||
.insert(managedServer)
|
||||
.values(input)
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServerById = async (managedServerId: string) => {
|
||||
const record = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.managedServerId, managedServerId),
|
||||
with: { server: true },
|
||||
});
|
||||
if (!record)
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServersByOrg = async (organizationId: string) => {
|
||||
return db.query.managedServer.findMany({
|
||||
where: eq(managedServer.organizationId, organizationId),
|
||||
with: { server: true },
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateManagedServer = async (
|
||||
managedServerId: string,
|
||||
data: Partial<typeof managedServer.$inferInsert>,
|
||||
) => {
|
||||
return db
|
||||
.update(managedServer)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(managedServer.managedServerId, managedServerId))
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
};
|
||||
|
||||
export const deleteManagedServer = async (managedServerId: string) => {
|
||||
return db
|
||||
.delete(managedServer)
|
||||
.where(eq(managedServer.managedServerId, managedServerId));
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
|
||||
await db.transaction(async (tx) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const sshKey = await tx
|
||||
.insert(sshKeys)
|
||||
.values(input)
|
||||
|
||||
@@ -566,13 +566,3 @@ 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,7 +65,6 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"X-Pplx-Integration": "dokploy",
|
||||
},
|
||||
});
|
||||
case "mistral":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user