diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 015095aa6..52fd7f2f8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -52,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
-We use Node v20.9.0
+We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
Go to http://localhost:3000 to see the development server
+Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
+
## Build
```bash
diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
index f33b37fd1..201aee1ed 100644
--- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
+++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
@@ -14,6 +14,7 @@ import {
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
+ https: false,
enablePaidFeatures: false,
metricsConfig: {
containers: {
@@ -73,7 +74,6 @@ beforeEach(() => {
test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
-
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
+ https: true,
certificateType: "letsencrypt",
},
"example.com",
diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts
new file mode 100644
index 000000000..c7bc310cf
--- /dev/null
+++ b/apps/dokploy/__test__/utils/backups.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "vitest";
+import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
+
+describe("normalizeS3Path", () => {
+ test("should handle empty and whitespace-only prefix", () => {
+ expect(normalizeS3Path("")).toBe("");
+ expect(normalizeS3Path("/")).toBe("");
+ expect(normalizeS3Path(" ")).toBe("");
+ expect(normalizeS3Path("\t")).toBe("");
+ expect(normalizeS3Path("\n")).toBe("");
+ expect(normalizeS3Path(" \n \t ")).toBe("");
+ });
+
+ test("should trim whitespace from prefix", () => {
+ expect(normalizeS3Path(" prefix")).toBe("prefix/");
+ expect(normalizeS3Path("prefix ")).toBe("prefix/");
+ expect(normalizeS3Path(" prefix ")).toBe("prefix/");
+ expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
+ expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
+ });
+
+ test("should remove leading slashes", () => {
+ expect(normalizeS3Path("/prefix")).toBe("prefix/");
+ expect(normalizeS3Path("///prefix")).toBe("prefix/");
+ });
+
+ test("should remove trailing slashes", () => {
+ expect(normalizeS3Path("prefix/")).toBe("prefix/");
+ expect(normalizeS3Path("prefix///")).toBe("prefix/");
+ });
+
+ test("should remove both leading and trailing slashes", () => {
+ expect(normalizeS3Path("/prefix/")).toBe("prefix/");
+ expect(normalizeS3Path("///prefix///")).toBe("prefix/");
+ });
+
+ test("should handle nested paths", () => {
+ expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
+ expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
+ expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
+ });
+
+ test("should preserve middle slashes", () => {
+ expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
+ expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
+ });
+
+ test("should handle special characters", () => {
+ expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
+ expect(normalizeS3Path("prefix_with_underscores")).toBe(
+ "prefix_with_underscores/",
+ );
+ expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
+ });
+
+ test("should handle the cases from the bug report", () => {
+ expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
+ expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
+ expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
+ });
+});
diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx
index 3f8854888..9b9a0ba05 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx
@@ -65,7 +65,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
setSab(e as TabState);
}}
>
-
+
{
})
.then(() => {
refetch();
- toast.success("Preview deployments enabled");
+ toast.success(
+ checked
+ ? "Preview deployments enabled"
+ : "Preview deployments disabled",
+ );
})
.catch((error) => {
toast.error(error.message);
diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
index 10ebbe083..797e1ca81 100644
--- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
+++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
@@ -84,6 +84,7 @@ export const RestoreBackup = ({
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const { data: destinations = [] } = api.destination.all.useQuery();
@@ -99,13 +100,18 @@ export const RestoreBackup = ({
const destionationId = form.watch("destinationId");
const debouncedSetSearch = debounce((value: string) => {
+ setDebouncedSearchTerm(value);
+ }, 150);
+
+ const handleSearchChange = (value: string) => {
setSearch(value);
- }, 300);
+ debouncedSetSearch(value);
+ };
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
- search,
+ search: debouncedSearchTerm,
serverId: serverId ?? "",
},
{
@@ -284,7 +290,8 @@ export const RestoreBackup = ({
{isLoading ? (
@@ -308,6 +315,8 @@ export const RestoreBackup = ({
key={file}
onSelect={() => {
form.setValue("backupFile", file);
+ setSearch(file);
+ setDebouncedSearchTerm(file);
}}
>
diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx
index 5dbbcd1da..8e9de54d9 100644
--- a/apps/dokploy/components/dashboard/project/add-template.tsx
+++ b/apps/dokploy/components/dashboard/project/add-template.tsx
@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
>
{templates?.map((template) => (
{
)}
>
- {template.version}
+ {template?.version}
{
)}
>
- {template.name}
+ {template?.name}
{viewMode === "detailed" &&
- template.tags.length > 0 && (
+ template?.tags?.length > 0 && (
- {template.tags.map((tag) => (
+ {template?.tags?.map((tag) => (
{
{viewMode === "detailed" && (
- {template.description}
+ {template?.description}
)}
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
>
{viewMode === "detailed" && (
-
-
-
- {template.links.website && (
+ {template?.links?.github && (
+
+
+ )}
+ {template?.links?.website && (
+
)}
- {template.links.docs && (
+ {template?.links?.docs && (
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
This will create an application from the{" "}
- {template.name} template and add it to your
+ {template?.name} template and add it to your
project.
diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx
index 85b8aea9a..dcb812419 100644
--- a/apps/dokploy/components/dashboard/projects/handle-project.tsx
+++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx
@@ -31,9 +31,14 @@ import { toast } from "sonner";
import { z } from "zod";
const AddProjectSchema = z.object({
- name: z.string().min(1, {
- message: "Name is required",
- }),
+ name: z
+ .string()
+ .min(1, {
+ message: "Name is required",
+ })
+ .regex(/^[a-zA-Z]/, {
+ message: "Project name cannot start with a number",
+ }),
description: z.string().optional(),
});
@@ -97,18 +102,6 @@ export const HandleProject = ({ projectId }: Props) => {
);
});
};
- // useEffect(() => {
- // const getUsers = async () => {
- // const users = await authClient.admin.listUsers({
- // query: {
- // limit: 100,
- // },
- // });
- // console.log(users);
- // };
-
- // getUsers();
- // });
return (
)}
-
+
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
diff --git a/apps/dokploy/components/dashboard/settings/ai-form.tsx b/apps/dokploy/components/dashboard/settings/ai-form.tsx
index 05ab93a4f..b1923918e 100644
--- a/apps/dokploy/components/dashboard/settings/ai-form.tsx
+++ b/apps/dokploy/components/dashboard/settings/ai-form.tsx
@@ -55,7 +55,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+
{config.name}
diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
index 6aaa25630..b80c7b549 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
+++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+
diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx
index 08cb03813..9ae595d6f 100644
--- a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx
+++ b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx
@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+
diff --git a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx
index 0639b0f75..014596ce3 100644
--- a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx
+++ b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx
@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+
{index + 1}. {destination.name}
diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx
index 4dd7da93c..023e46ed2 100644
--- a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx
@@ -248,7 +248,9 @@ export const AddGitlabProvider = () => {
name="groupName"
render={({ field }) => (
- Group Name (Optional)
+
+ Group Name (Optional, Comma-Separated List)
+
{
name="groupName"
render={({ field }) => (
- Group Name (Optional)
+
+ Group Name (Optional, Comma-Separated List)
+
{
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+
{notification.notificationType === "slack" && (
diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx
index 6cf2c6a53..afc859f41 100644
--- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx
+++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx
@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
+ issuer: z.string().optional(),
});
const PinSchema = z.object({
@@ -60,12 +61,86 @@ export const Enable2FA = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
+ const [otpValue, setOtpValue] = useState("");
+
+ const handleVerifySubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ const result = await authClient.twoFactor.verifyTotp({
+ code: otpValue,
+ });
+
+ if (result.error) {
+ if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
+ toast.error("Invalid verification code");
+ return;
+ }
+
+ throw result.error;
+ }
+
+ if (!result.data) {
+ throw new Error("No response received from server");
+ }
+
+ toast.success("2FA configured successfully");
+ utils.user.get.invalidate();
+ setIsDialogOpen(false);
+ } catch (error) {
+ if (error instanceof Error) {
+ const errorMessage =
+ error.message === "Failed to fetch"
+ ? "Connection error. Please check your internet connection."
+ : error.message;
+
+ toast.error(errorMessage);
+ } else {
+ toast.error("Error verifying 2FA code", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+ };
+
+ const passwordForm = useForm
({
+ resolver: zodResolver(PasswordSchema),
+ defaultValues: {
+ password: "",
+ },
+ });
+
+ const pinForm = useForm({
+ resolver: zodResolver(PinSchema),
+ defaultValues: {
+ pin: "",
+ },
+ });
+
+ useEffect(() => {
+ if (!isDialogOpen) {
+ setStep("password");
+ setData(null);
+ setBackupCodes([]);
+ setOtpValue("");
+ passwordForm.reset({
+ password: "",
+ issuer: "",
+ });
+ }
+ }, [isDialogOpen, passwordForm]);
+
+ useEffect(() => {
+ if (step === "verify") {
+ setOtpValue("");
+ }
+ }, [step]);
const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsPasswordLoading(true);
try {
const { data: enableData, error } = await authClient.twoFactor.enable({
password: formData.password,
+ issuer: formData.issuer,
});
if (!enableData) {
@@ -103,75 +178,6 @@ export const Enable2FA = () => {
}
};
- const handleVerifySubmit = async (formData: PinForm) => {
- try {
- const result = await authClient.twoFactor.verifyTotp({
- code: formData.pin,
- });
-
- if (result.error) {
- if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
- pinForm.setError("pin", {
- message: "Invalid code. Please try again.",
- });
- toast.error("Invalid verification code");
- return;
- }
-
- throw result.error;
- }
-
- if (!result.data) {
- throw new Error("No response received from server");
- }
-
- toast.success("2FA configured successfully");
- utils.user.get.invalidate();
- setIsDialogOpen(false);
- } catch (error) {
- if (error instanceof Error) {
- const errorMessage =
- error.message === "Failed to fetch"
- ? "Connection error. Please check your internet connection."
- : error.message;
-
- pinForm.setError("pin", {
- message: errorMessage,
- });
- toast.error(errorMessage);
- } else {
- pinForm.setError("pin", {
- message: "Error verifying code",
- });
- toast.error("Error verifying 2FA code");
- }
- }
- };
-
- const passwordForm = useForm({
- resolver: zodResolver(PasswordSchema),
- defaultValues: {
- password: "",
- },
- });
-
- const pinForm = useForm({
- resolver: zodResolver(PinSchema),
- defaultValues: {
- pin: "",
- },
- });
-
- useEffect(() => {
- if (!isDialogOpen) {
- setStep("password");
- setData(null);
- setBackupCodes([]);
- passwordForm.reset();
- pinForm.reset();
- }
- }, [isDialogOpen, passwordForm, pinForm]);
-
return (