diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md
index 226083304..9418a626b 100644
--- a/.claude/skills/fix-issue/SKILL.md
+++ b/.claude/skills/fix-issue/SKILL.md
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
-Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
+Note: `mcp__dokploy__*` resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index daf538b1a..011d3e2d6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -123,13 +123,13 @@ pnpm run docker:push
In the case you lost your password, you can reset the owner's password using the following command
```bash
-pnpm run reset-password
+pnpm --filter=dokploy run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
-pnpm run reset-password -- user@example.com
+pnpm --filter=dokploy run reset-password user@example.com
```
Both commands print the new randomly generated password to the console.
diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts
index b7223ca4a..1c7ee65b0 100644
--- a/apps/dokploy/__test__/compose/env-file-literals.test.ts
+++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts
@@ -54,7 +54,16 @@ const inputEncoding: Record = {
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
-describe("getCreateEnvFileCommand", () => {
+const hasDocker = () => {
+ try {
+ execFileSync("docker", ["info"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts
index f27e7bc60..8edc07885 100644
--- a/apps/dokploy/__test__/dns/cloudflare.test.ts
+++ b/apps/dokploy/__test__/dns/cloudflare.test.ts
@@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => {
expect(createInit.method).toBe("POST");
});
- it("updates the existing record instead of creating a duplicate", async () => {
+ it("updates the existing record when content matches", async () => {
mockFetch
- .mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
+ .mockResolvedValueOnce(
+ cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
+ )
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
@@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => {
expect(updateInit.method).toBe("PUT");
});
+ it("creates a new record when content differs from existing", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
+ )
+ .mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
+
+ const result = await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "A",
+ name: "app.example.com",
+ content: "5.6.7.8",
+ });
+
+ expect(result).toEqual({ id: "new-2" });
+ const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
+ expect(createInit.method).toBe("POST");
+ });
+
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts
new file mode 100644
index 000000000..f5ba70027
--- /dev/null
+++ b/apps/dokploy/__test__/dns/infomaniak.test.ts
@@ -0,0 +1,486 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockFetch = vi.fn();
+global.fetch = mockFetch as typeof fetch;
+
+import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
+
+const jsonResponse = (body: unknown, ok = true, status = 200) =>
+ ({
+ ok,
+ status,
+ json: async () => body,
+ }) as Response;
+
+const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
+
+const ikPage = (data: unknown, page: number, pages: number) =>
+ jsonResponse({ result: "success", data, page, pages });
+
+const ikError = (description: string, status = 400) =>
+ jsonResponse(
+ { result: "error", error: { code: "not_authorized", description } },
+ false,
+ status,
+ );
+
+const config = {
+ providerType: "infomaniak" as const,
+ apiToken: "ik_test_token",
+};
+
+const lastCall = () =>
+ mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
+
+const lastBody = () => JSON.parse(lastCall()[1].body as string);
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("infomaniakClient.listZones", () => {
+ it("exposes each domain product as a zone keyed by its name", async () => {
+ mockFetch.mockResolvedValue(
+ ikPage(
+ [
+ { id: 1, customer_name: "example.com" },
+ { id: 2, customer_name: "example.ch" },
+ ],
+ 1,
+ 1,
+ ),
+ );
+
+ const zones = await infomaniakClient.listZones(config);
+
+ expect(zones).toEqual([
+ { id: "example.com", name: "example.com" },
+ { id: "example.ch", name: "example.ch" },
+ ]);
+ const [url, init] = lastCall();
+ // The documented endpoint is the plural one; the singular is legacy and
+ // returns no pagination metadata at all.
+ expect(url).toContain("/1/products?service_name=domain");
+ expect(url).toContain("page=1");
+ expect(init.headers).toMatchObject({
+ Authorization: "Bearer ik_test_token",
+ });
+ });
+
+ it("walks every page so accounts with many domains keep all their zones", async () => {
+ mockFetch
+ .mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
+ .mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
+ .mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
+
+ const zones = await infomaniakClient.listZones(config);
+
+ expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
+ expect(mockFetch).toHaveBeenCalledTimes(3);
+ expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
+ });
+
+ it("stops after a single page when the response has no pagination", async () => {
+ mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
+
+ const zones = await infomaniakClient.listZones(config);
+
+ expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("propagates the API error description", async () => {
+ mockFetch.mockResolvedValue(ikError("Authorization required", 401));
+
+ await expect(infomaniakClient.listZones(config)).rejects.toThrow(
+ "Authorization required",
+ );
+ });
+});
+
+describe("infomaniakClient.listRecords", () => {
+ it("rebuilds the fqdn from the relative source", async () => {
+ mockFetch.mockResolvedValue(
+ ikSuccess([
+ { id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
+ { id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
+ ]),
+ );
+
+ const records = await infomaniakClient.listRecords(config, "example.com");
+
+ expect(records).toEqual([
+ {
+ id: "10",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ ttl: 300,
+ },
+ {
+ id: "11",
+ type: "A",
+ name: "example.com",
+ content: "5.6.7.8",
+ ttl: 600,
+ },
+ ]);
+ expect(lastCall()[0]).toBe(
+ "https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
+ );
+ });
+
+ it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
+ mockFetch.mockResolvedValue(
+ ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
+ );
+
+ const records = await infomaniakClient.listRecords(config, "example.com");
+
+ expect(records[0]?.name).toBe("example.com");
+ });
+
+ it("unquotes TXT targets", async () => {
+ mockFetch.mockResolvedValue(
+ ikSuccess([
+ {
+ id: 13,
+ type: "TXT",
+ source: "_acme-challenge",
+ target: '"token-value"',
+ ttl: 300,
+ },
+ ]),
+ );
+
+ const records = await infomaniakClient.listRecords(config, "example.com");
+
+ expect(records[0]?.content).toBe("token-value");
+ });
+
+ it("leaves a CAA target untouched", async () => {
+ mockFetch.mockResolvedValue(
+ ikSuccess([
+ {
+ id: 14,
+ type: "CAA",
+ source: "",
+ target: '0 issue "letsencrypt.org"',
+ ttl: 300,
+ },
+ ]),
+ );
+
+ const records = await infomaniakClient.listRecords(config, "example.com");
+
+ expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
+ });
+});
+
+describe("infomaniakClient.upsertRecord", () => {
+ it("creates the record when no matching source and type exists", async () => {
+ mockFetch
+ .mockResolvedValueOnce(ikSuccess([]))
+ .mockResolvedValueOnce(ikSuccess({ id: 42 }));
+
+ const result = await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ ttl: 600,
+ });
+
+ expect(result).toEqual({ id: "42" });
+ const [url, init] = lastCall();
+ expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
+ expect(init.method).toBe("POST");
+ expect(lastBody()).toEqual({
+ type: "A",
+ source: "app",
+ target: "1.2.3.4",
+ ttl: 600,
+ });
+ });
+
+ it("updates the existing record when content matches", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ ikSuccess([
+ { id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
+ ]),
+ )
+ .mockResolvedValueOnce(ikSuccess({ id: 7 }));
+
+ const result = await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ expect(result).toEqual({ id: "7" });
+ const [url, init] = lastCall();
+ expect(url).toBe(
+ "https://api.infomaniak.com/2/zones/example.com/records/7",
+ );
+ expect(init.method).toBe("PUT");
+ expect(lastBody().ttl).toBe(300);
+ });
+
+ it("creates a new record when content differs from existing", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ ikSuccess([
+ { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
+ ]),
+ )
+ .mockResolvedValueOnce(ikSuccess({ id: 50 }));
+
+ const result = await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ expect(result).toEqual({ id: "50" });
+ const [url, init] = lastCall();
+ expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
+ expect(init.method).toBe("POST");
+ });
+
+ it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
+ mockFetch
+ .mockResolvedValueOnce(ikSuccess([]))
+ .mockResolvedValueOnce(ikSuccess({ id: 43 }));
+
+ await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "example.com.",
+ content: "1.2.3.4",
+ });
+
+ expect(lastBody().source).toBe(".");
+ });
+
+ it.each([".", "", "@"])(
+ "matches an existing apex record stored with a %s source",
+ async (source) => {
+ mockFetch
+ .mockResolvedValueOnce(
+ ikSuccess([
+ { id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
+ ]),
+ )
+ .mockResolvedValueOnce(ikSuccess({ id: 8 }));
+
+ const result = await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "example.com",
+ content: "1.2.3.4",
+ });
+
+ expect(result).toEqual({ id: "8" });
+ expect(lastCall()[1].method).toBe("PUT");
+ },
+ );
+
+ it("matches the existing apex record instead of creating a duplicate", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ ikSuccess([
+ {
+ id: 8,
+ type: "TXT",
+ source: ".",
+ target: '"v=spf1 -all"',
+ ttl: 3600,
+ },
+ ]),
+ )
+ .mockResolvedValueOnce(ikSuccess({ id: 8 }));
+
+ const result = await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "TXT",
+ name: "example.com",
+ content: "v=spf1 -all",
+ });
+
+ expect(result).toEqual({ id: "8" });
+ const [url, init] = lastCall();
+ expect(init.method).toBe("PUT");
+ expect(url).toBe(
+ "https://api.infomaniak.com/2/zones/example.com/records/8",
+ );
+ expect(lastBody().target).toBe('"v=spf1 -all"');
+ });
+
+ it("quotes a TXT target on write", async () => {
+ mockFetch
+ .mockResolvedValueOnce(ikSuccess([]))
+ .mockResolvedValueOnce(ikSuccess({ id: 44 }));
+
+ await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "TXT",
+ name: "_acme-challenge.example.com",
+ content: "token-value",
+ });
+
+ expect(lastBody().target).toBe('"token-value"');
+ });
+
+ it("does not double-quote a TXT target that is already quoted", async () => {
+ mockFetch
+ .mockResolvedValueOnce(ikSuccess([]))
+ .mockResolvedValueOnce(ikSuccess({ id: 45 }));
+
+ await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "TXT",
+ name: "_acme-challenge.example.com",
+ content: '"token-value"',
+ });
+
+ expect(lastBody().target).toBe('"token-value"');
+ });
+
+ it("queries the API with a source and type filter instead of the whole zone", async () => {
+ mockFetch
+ .mockResolvedValueOnce(ikSuccess([]))
+ .mockResolvedValueOnce(ikSuccess({ id: 50 }));
+
+ await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ const [url] = mockFetch.mock.calls[0] as [string];
+ expect(url).toContain("filter%5Bsource%5D=app");
+ expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
+ });
+
+ it("ignores a partial filter hit rather than overwriting a different record", async () => {
+ // filter[source] matches substrings: asking for "auto" also returns
+ // "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
+ mockFetch
+ .mockResolvedValueOnce(
+ ikSuccess([
+ {
+ id: 61,
+ type: "CNAME",
+ source: "autoconfig",
+ target: "a.example.net",
+ ttl: 300,
+ },
+ {
+ id: 62,
+ type: "CNAME",
+ source: "autodiscover",
+ target: "b.example.net",
+ ttl: 300,
+ },
+ ]),
+ )
+ .mockResolvedValueOnce(ikSuccess({ id: 63 }));
+
+ const result = await infomaniakClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "CNAME",
+ name: "auto.example.com",
+ content: "c.example.net",
+ });
+
+ expect(result).toEqual({ id: "63" });
+ expect(lastCall()[1].method).toBe("POST");
+ });
+});
+
+describe("infomaniakClient.updateRecord", () => {
+ it("updates the record and keeps its id", async () => {
+ mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
+
+ const result = await infomaniakClient.updateRecord(
+ config,
+ "example.com",
+ "7",
+ {
+ type: "CNAME",
+ name: "www.example.com",
+ content: "example.com",
+ ttl: 900,
+ },
+ );
+
+ expect(result).toEqual({ id: "7" });
+ const [url, init] = lastCall();
+ expect(url).toBe(
+ "https://api.infomaniak.com/2/zones/example.com/records/7",
+ );
+ expect(init.method).toBe("PUT");
+ expect(lastBody()).toEqual({
+ type: "CNAME",
+ source: "www",
+ target: "example.com",
+ ttl: 900,
+ });
+ });
+
+ it("falls back to the default ttl when none is provided", async () => {
+ mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
+
+ await infomaniakClient.updateRecord(config, "example.com", "7", {
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ expect(lastBody().ttl).toBe(300);
+ });
+});
+
+describe("infomaniakClient.deleteRecord", () => {
+ it("deletes the record", async () => {
+ mockFetch.mockResolvedValue(ikSuccess(null));
+
+ await infomaniakClient.deleteRecord(config, "example.com", "7");
+
+ const [url, init] = lastCall();
+ expect(url).toBe(
+ "https://api.infomaniak.com/2/zones/example.com/records/7",
+ );
+ expect(init.method).toBe("DELETE");
+ });
+
+ it("propagates a delete failure", async () => {
+ mockFetch.mockResolvedValue(ikError("Record not found", 404));
+
+ await expect(
+ infomaniakClient.deleteRecord(config, "example.com", "7"),
+ ).rejects.toThrow("Record not found");
+ });
+});
+
+describe("infomaniakClient.testConnection", () => {
+ it("resolves when the domain listing succeeds", async () => {
+ mockFetch.mockResolvedValue(ikSuccess([]));
+
+ await expect(
+ infomaniakClient.testConnection(config),
+ ).resolves.toBeUndefined();
+ });
+
+ it("rejects on an invalid token", async () => {
+ mockFetch.mockResolvedValue(ikError("Authorization required", 401));
+
+ await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
+ "Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts
new file mode 100644
index 000000000..7e08d7f99
--- /dev/null
+++ b/apps/dokploy/__test__/dns/ovh.test.ts
@@ -0,0 +1,581 @@
+import { createHash } from "node:crypto";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockFetch = vi.fn();
+global.fetch = mockFetch as typeof fetch;
+
+import { ovhClient } from "@dokploy/server/utils/dns/ovh";
+
+const textResponse = (body: string, ok = true, status = 200) =>
+ ({
+ ok,
+ status,
+ text: async () => body,
+ }) as Response;
+
+const ovhSuccess = (data: unknown) =>
+ textResponse(data === undefined ? "" : JSON.stringify(data));
+
+const ovhError = (message: string, status = 403) =>
+ textResponse(JSON.stringify({ message }), false, status);
+
+const SERVER_TIME = 1788268225;
+
+const config = {
+ providerType: "ovh" as const,
+ endpoint: "ovh-eu" as const,
+ applicationKey: "app-key",
+ applicationSecret: "app-secret",
+ consumerKey: "consumer-key",
+};
+
+// Each test uses a distinct endpoint so the module-level clock-skew cache, which
+// is keyed by base url, never leaks a measurement between them.
+let endpointCursor = 0;
+const endpoints = [
+ "ovh-eu",
+ "ovh-ca",
+ "ovh-us",
+ "kimsufi-eu",
+ "kimsufi-ca",
+ "soyoustart-eu",
+ "soyoustart-ca",
+] as const;
+const baseUrls: Record<(typeof endpoints)[number], string> = {
+ "ovh-eu": "https://eu.api.ovh.com/1.0",
+ "ovh-ca": "https://ca.api.ovh.com/1.0",
+ "ovh-us": "https://api.us.ovhcloud.com/1.0",
+ "kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
+ "kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
+ "soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
+ "soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
+};
+
+/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
+const freshConfig = () => {
+ const endpoint = endpoints[
+ endpointCursor % endpoints.length
+ ] as (typeof endpoints)[number];
+ endpointCursor += 1;
+ return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
+};
+
+/** Replies to /auth/time, then to each queued API response in order. */
+const mockApi = (...responses: Response[]) => {
+ let call = 0;
+ mockFetch.mockImplementation((url: string) => {
+ if (url.endsWith("/auth/time")) {
+ return Promise.resolve(textResponse(String(SERVER_TIME)));
+ }
+ const response = responses[call];
+ call += 1;
+ return Promise.resolve(response ?? ovhSuccess(null));
+ });
+};
+
+const apiCalls = () =>
+ mockFetch.mock.calls.filter(
+ ([url]) => !(url as string).endsWith("/auth/time"),
+ ) as [string, RequestInit][];
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("ovhClient request signing", () => {
+ it("signs the request with the API server clock, not the local one", async () => {
+ const { baseUrl, ...cfg } = freshConfig();
+ mockApi(ovhSuccess(["example.com"]));
+ vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
+
+ await ovhClient.listZones(cfg);
+
+ const [url, init] = apiCalls()[0] as [string, RequestInit];
+ const headers = init.headers as Record;
+ expect(url).toBe(`${baseUrl}/domain/zone`);
+ expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
+ expect(headers["X-Ovh-Application"]).toBe("app-key");
+ expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
+
+ const expected = createHash("sha1")
+ .update(
+ ["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
+ )
+ .digest("hex");
+ expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
+
+ vi.restoreAllMocks();
+ });
+
+ it("signs a request body when one is sent", async () => {
+ const { baseUrl, ...cfg } = freshConfig();
+ mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
+ vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
+
+ await ovhClient.upsertRecord(cfg, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ const [url, init] = apiCalls()[1] as [string, RequestInit];
+ const headers = init.headers as Record;
+ const body = init.body as string;
+ expect(body).not.toBe("");
+ const expected = createHash("sha1")
+ .update(
+ ["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
+ "+",
+ ),
+ )
+ .digest("hex");
+ expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
+
+ vi.restoreAllMocks();
+ });
+});
+
+describe("ovhClient.listZones", () => {
+ it("maps each zone name to a zone", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhSuccess(["example.com", "example.fr"]));
+
+ const zones = await ovhClient.listZones(cfg);
+
+ expect(zones).toEqual([
+ { id: "example.com", name: "example.com" },
+ { id: "example.fr", name: "example.fr" },
+ ]);
+ });
+
+ it("names the missing root right when OVH refuses the zone listing", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhError("This call has not been granted", 403));
+
+ // A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
+ // so the raw OVH message would send users looking in the wrong place.
+ await expect(ovhClient.listZones(cfg)).rejects.toThrow(
+ /missing the `GET \/domain\/zone` right/,
+ );
+ });
+
+ it("propagates the API error message", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhError("Invalid signature", 403));
+
+ await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
+ });
+});
+
+describe("ovhClient.listRecords", () => {
+ it("resolves each id into a full record", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess([1, 2]),
+ ovhSuccess({
+ id: 1,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.2.3.4",
+ ttl: 600,
+ }),
+ ovhSuccess({
+ id: 2,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: null,
+ target: "5.6.7.8",
+ ttl: null,
+ }),
+ );
+
+ const records = await ovhClient.listRecords(cfg, "example.com");
+
+ expect(records).toEqual([
+ {
+ id: "1",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ ttl: 600,
+ },
+ {
+ id: "2",
+ type: "A",
+ name: "example.com",
+ content: "5.6.7.8",
+ ttl: 0,
+ },
+ ]);
+ });
+
+ it("keeps the records in the order of the returned ids", async () => {
+ const cfg = freshConfig();
+ const record = (id: number, subDomain: string) => ({
+ id,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain,
+ target: `10.0.0.${id}`,
+ ttl: 60,
+ });
+ mockApi(
+ ovhSuccess([1, 2, 3, 4, 5]),
+ ...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
+ );
+
+ const records = await ovhClient.listRecords(cfg, "example.com");
+
+ expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
+ });
+});
+
+describe("ovhClient.upsertRecord", () => {
+ it("creates the record then refreshes the zone", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
+
+ const result = await ovhClient.upsertRecord(cfg, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ ttl: 600,
+ });
+
+ expect(result).toEqual({ id: "9" });
+ const calls = apiCalls();
+ expect(calls[0]?.[0]).toContain(
+ "/domain/zone/example.com/record?fieldType=A&subDomain=app",
+ );
+ expect(calls[1]?.[1].method).toBe("POST");
+ expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.2.3.4",
+ ttl: 600,
+ });
+ expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
+ expect(calls[2]?.[1].method).toBe("POST");
+ });
+
+ it("updates the existing record when content matches", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess([4]),
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.2.3.4",
+ ttl: 60,
+ }),
+ ovhSuccess(null),
+ ovhSuccess(null),
+ );
+
+ const result = await ovhClient.upsertRecord(cfg, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ expect(result).toEqual({ id: "4" });
+ const calls = apiCalls();
+ expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
+ expect(calls[2]?.[1].method).toBe("PUT");
+ expect(calls[3]?.[0]).toContain("/refresh");
+ });
+
+ it("creates a new record when content differs from existing", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess([4]),
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ }),
+ ovhSuccess({ id: 10 }),
+ ovhSuccess(null),
+ );
+
+ const result = await ovhClient.upsertRecord(cfg, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "5.6.7.8",
+ });
+
+ expect(result).toEqual({ id: "10" });
+ const calls = apiCalls();
+ expect(calls[2]?.[1].method).toBe("POST");
+ expect(calls[3]?.[0]).toContain("/refresh");
+ });
+
+ it("omits the ttl so OVH applies the zone default", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
+
+ await ovhClient.upsertRecord(cfg, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
+ "ttl",
+ );
+ });
+
+ it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
+
+ await ovhClient.upsertRecord(cfg, {
+ zoneId: "example.com",
+ type: "A",
+ name: "example.com.",
+ content: "1.2.3.4",
+ });
+
+ expect(apiCalls()[0]?.[0]).toContain("subDomain=");
+ expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
+ });
+});
+
+describe("ovhClient.updateRecord", () => {
+ it("updates in place when the type is unchanged", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ }),
+ ovhSuccess(null),
+ ovhSuccess(null),
+ );
+
+ const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ ttl: 300,
+ });
+
+ expect(result).toEqual({ id: "4" });
+ const calls = apiCalls();
+ expect(calls[1]?.[1].method).toBe("PUT");
+ expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
+ subDomain: "app",
+ target: "1.2.3.4",
+ ttl: 300,
+ });
+ expect(calls[2]?.[0]).toContain("/refresh");
+ });
+
+ it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ }),
+ ovhSuccess(null),
+ ovhSuccess({ id: 11 }),
+ ovhSuccess(null),
+ );
+
+ const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
+ type: "CNAME",
+ name: "app.example.com",
+ content: "example.com",
+ });
+
+ expect(result).toEqual({ id: "11" });
+ const calls = apiCalls();
+ expect(calls[1]?.[1].method).toBe("DELETE");
+ expect(calls[2]?.[1].method).toBe("POST");
+ expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
+ expect(calls[3]?.[0]).toContain("/refresh");
+ });
+
+ it("restores the original record when the replacement fails", async () => {
+ const cfg = freshConfig();
+ const original = {
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ };
+ mockApi(
+ ovhSuccess(original),
+ ovhSuccess(null),
+ ovhError("Invalid target", 400),
+ ovhSuccess({ id: 12 }),
+ ovhSuccess(null),
+ );
+
+ await expect(
+ ovhClient.updateRecord(cfg, "example.com", "4", {
+ type: "CNAME",
+ name: "app.example.com",
+ content: "not a valid target",
+ }),
+ ).rejects.toThrow("Invalid target");
+
+ const calls = apiCalls();
+ expect(calls[1]?.[1].method).toBe("DELETE");
+ expect(calls[2]?.[1].method).toBe("POST");
+ // The original record is put back with its own type, target and ttl.
+ expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ });
+ expect(calls[4]?.[0]).toContain("/refresh");
+ });
+
+ it("reports the lost record when the restore also fails", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ }),
+ ovhSuccess(null),
+ ovhError("Invalid target", 400),
+ ovhError("Service unavailable", 503),
+ );
+
+ await expect(
+ ovhClient.updateRecord(cfg, "example.com", "4", {
+ type: "CNAME",
+ name: "app.example.com",
+ content: "not a valid target",
+ }),
+ ).rejects.toThrow(
+ /Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
+ );
+ });
+
+ it("says the change was applied when only the zone refresh fails", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ }),
+ ovhSuccess(null),
+ ovhSuccess({ id: 11 }),
+ ovhError("Service unavailable", 503),
+ );
+
+ // The replacement succeeded, so the record exists at the provider — only
+ // publishing failed. Rolling back would destroy correct state.
+ await expect(
+ ovhClient.updateRecord(cfg, "example.com", "4", {
+ type: "CNAME",
+ name: "app.example.com",
+ content: "example.com",
+ }),
+ ).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
+ });
+
+ it("does not tell the user to recreate a record that was restored but not published", async () => {
+ const cfg = freshConfig();
+ mockApi(
+ ovhSuccess({
+ id: 4,
+ zone: "example.com",
+ fieldType: "A",
+ subDomain: "app",
+ target: "1.1.1.1",
+ ttl: 60,
+ }),
+ ovhSuccess(null), // DELETE de l'ancien
+ ovhError("Invalid target", 400), // POST de remplacement -> échec
+ ovhSuccess({ id: 12 }), // POST de restauration -> succès
+ ovhError("Service unavailable", 503), // refresh -> échec
+ );
+
+ const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
+ type: "CNAME",
+ name: "app.example.com",
+ content: "not a valid target",
+ });
+
+ // L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
+ await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
+ await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
+ });
+});
+
+describe("ovhClient.deleteRecord", () => {
+ it("deletes the record then refreshes the zone", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhSuccess(null), ovhSuccess(null));
+
+ await ovhClient.deleteRecord(cfg, "example.com", "4");
+
+ const calls = apiCalls();
+ expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
+ expect(calls[0]?.[1].method).toBe("DELETE");
+ expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
+ });
+
+ it("does not refresh the zone when the delete fails", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhError("This object does not exist", 404));
+
+ await expect(
+ ovhClient.deleteRecord(cfg, "example.com", "4"),
+ ).rejects.toThrow("This object does not exist");
+ expect(apiCalls()).toHaveLength(1);
+ });
+});
+
+describe("ovhClient.testConnection", () => {
+ it("resolves when the zone listing succeeds", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhSuccess([]));
+
+ await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
+ });
+
+ it("rejects on invalid credentials", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhError("Invalid signature", 403));
+
+ await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
+ "Invalid signature",
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts
index 2275e9c5f..a72b882ba 100644
--- a/apps/dokploy/__test__/dns/porkbun.test.ts
+++ b/apps/dokploy/__test__/dns/porkbun.test.ts
@@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => {
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
- it("edits the existing record instead of creating a duplicate", async () => {
+ it("edits the existing record when content matches", async () => {
mockFetch
- .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
+ .mockResolvedValueOnce(
+ pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
+ )
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
@@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => {
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
+ it("creates a new record when content differs from existing", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
+ )
+ .mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
+
+ const result = await porkbunClient.upsertRecord(config, {
+ zoneId: "example.com",
+ type: "A",
+ name: "app.example.com",
+ content: "5.6.7.8",
+ });
+
+ expect(result).toEqual({ id: "new-2" });
+ const [createUrl, createInit] = mockFetch.mock.calls[1] as [
+ string,
+ RequestInit,
+ ];
+ expect(createUrl).toContain("/dns/create/example.com");
+ const body = JSON.parse(createInit.body as string);
+ expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
+ });
+
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
diff --git a/apps/dokploy/__test__/domains/domain-validation.test.ts b/apps/dokploy/__test__/domains/domain-validation.test.ts
new file mode 100644
index 000000000..d9e3560c0
--- /dev/null
+++ b/apps/dokploy/__test__/domains/domain-validation.test.ts
@@ -0,0 +1,149 @@
+import os from "node:os";
+import {
+ getServerIpCandidates,
+ validateDomain,
+} from "@dokploy/server/services/domain";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ execAsyncRemote: vi.fn(),
+ findServerById: vi.fn(),
+ getPublicIpWithFallback: vi.fn(),
+ getWebServerSettings: vi.fn(),
+ resolve4: vi.fn(),
+ resolve6: vi.fn(),
+}));
+
+vi.mock("node:dns", () => ({
+ default: {
+ resolve4: mocks.resolve4,
+ resolve6: mocks.resolve6,
+ },
+}));
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsyncRemote: mocks.execAsyncRemote,
+}));
+
+vi.mock("@dokploy/server/services/server", () => ({
+ findServerById: mocks.findServerById,
+}));
+
+vi.mock("@dokploy/server/services/web-server-settings", () => ({
+ getWebServerSettings: mocks.getWebServerSettings,
+}));
+
+vi.mock("@dokploy/server/wss/utils", () => ({
+ getPublicIpWithFallback: mocks.getPublicIpWithFallback,
+}));
+
+describe("getServerIpCandidates", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it("includes every address reported by a multi-homed remote server", async () => {
+ mocks.findServerById.mockResolvedValue({
+ ipAddress: "10.0.0.10",
+ });
+ mocks.execAsyncRemote.mockResolvedValue({
+ stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
+ stderr: "",
+ });
+
+ await expect(getServerIpCandidates("server-id")).resolves.toEqual([
+ "10.0.0.10",
+ "192.0.2.10",
+ "2001:db8::10",
+ ]);
+ expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
+ "server-id",
+ expect.stringContaining("ip -o addr show scope global"),
+ );
+ });
+
+ it("includes every address assigned to the local Dokploy host", async () => {
+ mocks.getWebServerSettings.mockResolvedValue({
+ serverIp: "10.0.0.10",
+ });
+ mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
+ vi.spyOn(os, "networkInterfaces").mockReturnValue({
+ eth0: [
+ {
+ address: "192.0.2.10",
+ netmask: "255.255.255.0",
+ family: "IPv4",
+ mac: "00:00:00:00:00:00",
+ internal: false,
+ cidr: "192.0.2.10/24",
+ },
+ ],
+ });
+
+ await expect(getServerIpCandidates()).resolves.toEqual([
+ "10.0.0.10",
+ "192.0.2.10",
+ "2001:db8::10",
+ ]);
+ });
+
+ it("retains remote interface addresses when public IP detection times out", async () => {
+ vi.useFakeTimers();
+ mocks.findServerById.mockResolvedValue({
+ ipAddress: "10.0.0.10",
+ });
+ mocks.execAsyncRemote.mockImplementation(
+ (_serverId: string, command: string) => {
+ if (command.includes("curl")) {
+ return new Promise(() => undefined);
+ }
+ return Promise.resolve({
+ stdout: "192.0.2.10\n",
+ stderr: "",
+ });
+ },
+ );
+
+ const candidatesPromise = getServerIpCandidates("server-id");
+ await vi.advanceTimersByTimeAsync(7000);
+
+ await expect(candidatesPromise).resolves.toEqual([
+ "10.0.0.10",
+ "192.0.2.10",
+ ]);
+ });
+});
+
+describe("validateDomain", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("validates an IPv6-only domain against an IPv6 server address", async () => {
+ const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
+ code: "ENODATA",
+ });
+ mocks.resolve4.mockImplementation(
+ (_domain: string, callback: (error: Error | null) => void) =>
+ callback(noIpv4),
+ );
+ mocks.resolve6.mockImplementation(
+ (
+ _domain: string,
+ callback: (error: Error | null, addresses?: string[]) => void,
+ ) => callback(null, ["2001:db8::10"]),
+ );
+
+ await expect(
+ validateDomain("example.com", ["2001:db8::10"]),
+ ).resolves.toMatchObject({
+ isValid: true,
+ resolvedIp: "2001:db8::10",
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/env/aws-parameter-store.test.ts b/apps/dokploy/__test__/env/aws-parameter-store.test.ts
new file mode 100644
index 000000000..ffd772772
--- /dev/null
+++ b/apps/dokploy/__test__/env/aws-parameter-store.test.ts
@@ -0,0 +1,195 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+type HasInput = { input: Record };
+
+const findMany = vi.hoisted(() => vi.fn());
+
+const {
+ send,
+ paginate,
+ SSMClient,
+ GetParametersCommand,
+ DescribeParametersCommand,
+} = vi.hoisted(() => {
+ class FakeCommand {
+ input: Record;
+ constructor(input: Record) {
+ this.input = input;
+ }
+ }
+ const send = vi.fn();
+ const paginate = vi.fn();
+ class SSMClient {
+ send(command: unknown) {
+ return send(command);
+ }
+ }
+ return {
+ send,
+ paginate,
+ SSMClient,
+ GetParametersCommand: class extends FakeCommand {},
+ DescribeParametersCommand: class extends FakeCommand {},
+ };
+});
+
+vi.mock("@aws-sdk/client-ssm", () => ({
+ SSMClient,
+ GetParametersCommand,
+ DescribeParametersCommand,
+ paginateDescribeParameters: paginate,
+}));
+
+vi.mock("@dokploy/server/db", () => ({
+ db: {
+ query: {
+ vaultProvider: {
+ findMany: (...args: unknown[]) => findMany(...args),
+ },
+ },
+ },
+}));
+
+import { resolveVaultReferences } from "@dokploy/server/utils/vault";
+import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
+
+const config = {
+ providerType: "aws-parameter-store" as const,
+ region: "eu-central-1",
+ accessKeyId: "AKIA_TEST",
+ secretAccessKey: "secret",
+};
+
+beforeEach(() => {
+ send.mockReset();
+ paginate.mockReset();
+ findMany.mockReset();
+});
+
+describe("awsParameterStoreClient", () => {
+ it("decrypts parameters in batches of ten and preserves selectors", async () => {
+ const refs = [
+ "/prod/database:CURRENT",
+ ...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
+ ];
+ send.mockImplementation(async (command: HasInput) => ({
+ Parameters: (command.input.Names as string[]).map((ref) => {
+ if (ref === "/prod/database:CURRENT") {
+ return {
+ Name: "/prod/database",
+ Selector: ":CURRENT",
+ Value: "selected-value",
+ };
+ }
+ return { Name: ref, Value: `value-for-${ref}` };
+ }),
+ }));
+
+ const result = await awsParameterStoreClient.getSecrets(config, refs);
+
+ expect(send).toHaveBeenCalledTimes(2);
+ expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
+ WithDecryption: true,
+ });
+ expect(
+ ((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
+ ).toBe(10);
+ expect(
+ ((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
+ ).toBe(1);
+ expect(result["/prod/database:CURRENT"]).toBe("selected-value");
+ expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
+ });
+
+ it("reports a missing parameter without exposing other values", async () => {
+ send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
+
+ await expect(
+ awsParameterStoreClient.getSecrets(config, ["/missing"]),
+ ).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
+ });
+
+ it("tests the connection within the configured hierarchy", async () => {
+ send.mockResolvedValue({ Parameters: [] });
+
+ await awsParameterStoreClient.testConnection({
+ ...config,
+ parameterPath: "/production/my-app/",
+ });
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
+ ParameterFilters: [
+ {
+ Key: "Path",
+ Option: "Recursive",
+ Values: ["/production/my-app"],
+ },
+ ],
+ MaxResults: 1,
+ });
+ });
+
+ it("explains the discovery permission when connection testing is denied", async () => {
+ const error = new Error("not authorized");
+ error.name = "AccessDeniedException";
+ send.mockRejectedValue(error);
+
+ await expect(
+ awsParameterStoreClient.testConnection(config),
+ ).rejects.toThrow("ssm:DescribeParameters");
+ });
+
+ it("lists parameter names across pages within the configured hierarchy", async () => {
+ paginate.mockReturnValue(
+ (async function* () {
+ yield { Parameters: [{ Name: "/prod/db" }] };
+ yield { Parameters: [{ Name: "/prod/api" }] };
+ })(),
+ );
+
+ const names = await awsParameterStoreClient.listSecretNames?.({
+ ...config,
+ parameterPath: " /production/my-app/ ",
+ });
+
+ expect(names).toEqual(["/prod/db", "/prod/api"]);
+ expect(paginate).toHaveBeenCalledWith(
+ expect.objectContaining({ pageSize: 50 }),
+ {
+ ParameterFilters: [
+ {
+ Key: "Path",
+ Option: "Recursive",
+ Values: ["/production/my-app"],
+ },
+ ],
+ },
+ );
+ });
+
+ it("resolves a vault reference through the registered provider", async () => {
+ findMany.mockResolvedValue([
+ {
+ name: "ssm-prod",
+ providerType: "aws-parameter-store",
+ config,
+ assignments: [{ projectId: "project-1", environmentIds: [] }],
+ },
+ ]);
+ send.mockResolvedValue({
+ Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
+ });
+
+ const result = await resolveVaultReferences(
+ "DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
+ {
+ organizationId: "organization-1",
+ projectId: "project-1",
+ environmentId: "environment-1",
+ },
+ );
+
+ expect(result).toBe("DB_PASSWORD=resolved");
+ });
+});
diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts
index ea57b6800..98fd7e4d9 100644
--- a/apps/dokploy/__test__/env/vault.test.ts
+++ b/apps/dokploy/__test__/env/vault.test.ts
@@ -20,6 +20,7 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
+import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
@@ -431,6 +432,152 @@ describe("azure client", () => {
});
});
+describe("infisical client", () => {
+ const config = {
+ providerType: "infisical" as const,
+ siteUrl: "https://app.infisical.com",
+ clientId: "client-1",
+ clientSecret: "client-secret",
+ projectId: "workspace-1",
+ environmentSlug: "prod",
+ secretPath: "/frontend",
+ };
+
+ const loginResponse = () => jsonResponse({ accessToken: "token-1" });
+ const list = (secrets: Record) =>
+ jsonResponse({
+ secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
+ secretKey,
+ secretValue,
+ })),
+ });
+ const listPathOf = (callIndex: number) => {
+ const [url] = mockFetch.mock.calls[callIndex] as [string];
+ return new URL(url).searchParams.get("secretPath");
+ };
+
+ it("asks the list endpoint to expand secret references", async () => {
+ mockFetch
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
+
+ const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
+
+ expect(result).toEqual({ DB_URL: "postgres://real" });
+ const [listUrl] = mockFetch.mock.calls[1] as [string];
+ const params = new URL(listUrl).searchParams;
+ expect(params.get("expandSecretReferences")).toBe("true");
+ expect(params.get("workspaceId")).toBe("workspace-1");
+ expect(params.get("environment")).toBe("prod");
+ expect(params.get("secretPath")).toBe("/frontend");
+ });
+
+ it("throws a clear error for a missing secret", async () => {
+ mockFetch
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(jsonResponse({ secrets: [] }));
+
+ await expect(
+ infisicalClient.getSecrets(config, ["ABSENT"]),
+ ).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
+ });
+
+ it("propagates authentication failures with the status code", async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
+
+ await expect(
+ infisicalClient.getSecrets(config, ["DB_URL"]),
+ ).rejects.toThrow("authentication failed (status 401)");
+ });
+
+ it("resolves a relative : ref against the provider path", async () => {
+ mockFetch
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
+
+ const result = await infisicalClient.getSecrets(config, [
+ "shared/sentry:SENTRY_DSN",
+ ]);
+
+ expect(result).toEqual({
+ "shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
+ });
+ expect(listPathOf(1)).toBe("/frontend/shared/sentry");
+ });
+
+ it("treats a leading slash as an absolute path", async () => {
+ mockFetch
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
+
+ await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
+
+ expect(listPathOf(1)).toBe("/external/sentry");
+ });
+
+ it("keeps the root path clean when the provider sits at /", async () => {
+ mockFetch
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(list({ KEY: "value" }));
+
+ await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
+ "external/sentry:KEY",
+ ]);
+
+ expect(listPathOf(1)).toBe("/external/sentry");
+ });
+
+ it("logs in once and fetches each path once", async () => {
+ const byPath: Record> = {
+ "/frontend": { A: "a", B: "b" },
+ "/frontend/other": { C: "c" },
+ };
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes("/auth/universal-auth/login")) return loginResponse();
+ const path = new URL(url).searchParams.get("secretPath") as string;
+ return list(byPath[path] ?? {});
+ });
+
+ const result = await infisicalClient.getSecrets(config, [
+ "A",
+ "B",
+ "other:C",
+ ]);
+
+ expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
+
+ const urls = mockFetch.mock.calls.map(([url]) => url as string);
+ expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
+ expect(
+ urls
+ .filter((u) => u.includes("/secrets/raw?"))
+ .map((u) => new URL(u).searchParams.get("secretPath"))
+ .sort(),
+ ).toEqual(["/frontend", "/frontend/other"]);
+ });
+
+ it("names the path when a secret is missing from an explicit one", async () => {
+ mockFetch
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(list({}));
+
+ await expect(
+ infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
+ ).rejects.toThrow(
+ 'secret "ABSENT" not found at "/frontend/external/sentry"',
+ );
+ });
+
+ it("rejects a ref with an empty path or key", async () => {
+ await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
+ "expected format :",
+ );
+ await expect(
+ infisicalClient.getSecrets(config, ["external/sentry:"]),
+ ).rejects.toThrow("expected format :");
+ });
+});
+
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts
index 89b8b390d..590d67b21 100644
--- a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts
+++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts
@@ -154,7 +154,16 @@ const hasRealMonitoring = () => {
);
};
-describe.skipIf(hasRealMonitoring())(
+const hasDocker = () => {
+ try {
+ execSync("docker info", { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
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 ba09c2c80..d1a03fedc 100644
--- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
+++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
- metaTitle: null,
+ ogImageUrl: null,
footerText: null,
},
cleanupCacheApplications: false,
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts
index df972102d..8627f36fb 100644
--- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts
+++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts
@@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
-export { filterEmptyValues, hasValues } from "./utils";
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts
deleted file mode 100644
index 58793c02e..000000000
--- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Filters out undefined, null, and empty string values from form data
- * Only returns fields that have actual values
- */
-export const filterEmptyValues = (
- formData: Record,
-): Record => {
- return Object.entries(formData).reduce(
- (acc, [key, value]) => {
- // Keep arrays even if empty (they might be intentionally cleared)
- if (Array.isArray(value)) {
- if (value.length > 0) {
- acc[key] = value;
- }
- }
- // For other values, filter out undefined, null, and empty strings
- else if (value !== undefined && value !== null && value !== "") {
- acc[key] = value;
- }
- return acc;
- },
- {} as Record,
- );
-};
-
-/**
- * Checks if filtered data has any values to save
- */
-export const hasValues = (data: Record): boolean => {
- return Object.keys(data).length > 0;
-};
diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
index 8bb763add..a715381cb 100644
--- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
+++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainId,
},
{
- enabled: !!domainId,
+ enabled: isOpen && !!domainId,
},
);
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
applicationId: id,
},
{
- enabled: !!id,
+ enabled: isOpen && !!id,
},
)
: api.compose.one.useQuery(
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
},
{
- enabled: !!id,
+ enabled: isOpen && !!id,
},
);
@@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
- api.domain.canGenerateTraefikMeDomains.useQuery({
- serverId: application?.serverId || "",
- });
+ api.domain.canGenerateTraefikMeDomains.useQuery(
+ {
+ serverId: application?.serverId || "",
+ },
+ {
+ enabled: isOpen,
+ },
+ );
const {
data: services,
@@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
{
retry: false,
refetchOnWindowFocus: false,
- enabled: type === "compose" && !!id,
+ enabled: isOpen && type === "compose" && !!id,
},
);
diff --git a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx
index a51ab4902..4fb46a6d1 100644
--- a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx
+++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx
@@ -1,4 +1,3 @@
-import DOMPurify from "dompurify";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
+import { sanitizeSvg } from "@/utils/sanitize-svg";
interface ShowIconSettingsProps {
serviceId: string;
@@ -89,15 +89,6 @@ export const ShowIconSettings = ({
}
};
- const sanitizeSvg = (svgContent: string): string | null => {
- const clean = DOMPurify.sanitize(svgContent, {
- USE_PROFILES: { svg: true, svgFilters: true },
- ADD_TAGS: ["use"],
- });
- if (!clean) return null;
- return `data:image/svg+xml;base64,${btoa(clean)}`;
- };
-
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx
new file mode 100644
index 000000000..0c5bfdffb
--- /dev/null
+++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx
@@ -0,0 +1,34 @@
+import { Rocket } from "lucide-react";
+import { useRouter } from "next/router";
+import { Button } from "@/components/ui/button";
+import { api } from "@/utils/api";
+
+export const TrialBanner = () => {
+ const router = useRouter();
+ const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
+
+ if (!billingStatus?.isOnTrial) {
+ return null;
+ }
+
+ const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
+
+ return (
+
+
+
+ {daysRemaining > 0
+ ? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
+ : "Your free trial ends today."}
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx
index 3524332ca..9fe8a4947 100644
--- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx
+++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx
@@ -56,7 +56,7 @@ export const PlanStep = ({ onNext }: Props) => {
try {
await startFreeTrial();
await utils.project.onboardingStatus.invalidate();
- toast.success("Your 14-day trial has started");
+ toast.success("Your 7-day trial has started");
onNext();
} catch (error) {
toast.error(
@@ -90,14 +90,14 @@ export const PlanStep = ({ onNext }: Props) => {
Recommended
- 14-day free trial
+ 7-day free trial
No card required — cancel anytime.
{[
- "1 server included",
+ "Setup 1 server",
"Unlimited apps & databases",
"Community support",
].map((f) => (
@@ -140,7 +140,7 @@ export const PlanStep = ({ onNext }: Props) => {
{[
- "1 server included",
+ "Setup 1 server",
"Unlimited apps & databases",
"2 environments",
"Community support",
@@ -183,7 +183,7 @@ export const PlanStep = ({ onNext }: Props) => {
{[
- `${STARTUP_SERVERS_INCLUDED} servers included`,
+ `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx
index ff0b18a30..b09e638fd 100644
--- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx
+++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx
@@ -1,9 +1,11 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
-import { PenBoxIcon, Plus } from "lucide-react";
-import { useEffect, useState } from "react";
+
+import { PenBoxIcon, Plus, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
+import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -14,6 +16,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
+import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
@@ -24,6 +27,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
+import { resizeImage } from "@/utils/image-processing";
+import { sanitizeSvg } from "@/utils/sanitize-svg";
const organizationSchema = z.object({
name: z.string().min(1, {
@@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer;
interface Props {
organizationId?: string;
children?: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
}
-export function AddOrganization({ organizationId }: Props) {
- const [open, setOpen] = useState(false);
+export function AddOrganization({
+ organizationId,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+}: Props) {
+ const [internalOpen, setInternalOpen] = useState(false);
+ const [uploadedFileName, setUploadedFileName] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const uploadCounter = useRef(0);
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : internalOpen;
+ const setOpen = isControlled
+ ? controlledOnOpenChange || (() => {})
+ : setInternalOpen;
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
@@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) {
useEffect(() => {
if (organization) {
+ uploadCounter.current++;
+ setIsUploading(false);
form.reset({
name: organization.name,
logo: organization.logo || "",
});
+ setUploadedFileName(null);
}
}, [organization, form]);
const onSubmit = async (values: OrganizationFormValues) => {
+ if (isUploading) return;
await mutateAsync({
name: values.name,
logo: values.logo,
@@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
})
.then(() => {
form.reset();
+ setUploadedFileName(null);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
@@ -99,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) {
});
};
+ const handleFileUpload = async (files: FileList | null) => {
+ if (!files || files.length === 0) return;
+ const file = files[0];
+ if (!file) return;
+
+ const currentUploadId = ++uploadCounter.current;
+ setIsUploading(true);
+
+ const allowedTypes = [
+ "image/jpeg",
+ "image/jpg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp",
+ ];
+ const fileExtension = file.name.split(".").pop()?.toLowerCase();
+ const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
+
+ if (
+ !allowedTypes.includes(file.type) &&
+ !allowedExtensions.includes(fileExtension || "")
+ ) {
+ toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
+ setIsUploading(false);
+ return;
+ }
+
+ if (file.size > 2 * 1024 * 1024) {
+ toast.error("Image size must be less than 2MB");
+ setIsUploading(false);
+ return;
+ }
+
+ const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
+
+ if (isSvg) {
+ try {
+ const text = await file.text();
+ const sanitizedDataUrl = sanitizeSvg(text);
+ if (currentUploadId !== uploadCounter.current) return;
+ if (!sanitizedDataUrl) {
+ toast.error("Invalid SVG file");
+ return;
+ }
+ form.setValue("logo", sanitizedDataUrl);
+ form.trigger("logo");
+ setUploadedFileName(file.name);
+ } catch (error) {
+ if (currentUploadId === uploadCounter.current) {
+ toast.error("Error processing SVG");
+ }
+ } finally {
+ if (currentUploadId === uploadCounter.current) {
+ setIsUploading(false);
+ }
+ }
+ return;
+ }
+
+ // Resize raster images to max 256x256 and convert to WebP to save space
+ try {
+ const resizedDataUrl = await resizeImage(file, 256);
+ if (currentUploadId !== uploadCounter.current) return;
+ form.setValue("logo", resizedDataUrl);
+ form.trigger("logo");
+ setUploadedFileName(file.name);
+ } catch (error) {
+ if (currentUploadId === uploadCounter.current) {
+ toast.error("Error processing image");
+ }
+ } finally {
+ if (currentUploadId === uploadCounter.current) {
+ setIsUploading(false);
+ }
+ }
+ };
+
return (
-