diff --git a/README.md b/README.md index 8faf22a35..d60962cff 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
Hostinger LX Aer + + + +
diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index b597b3aa4..cbfebbfac 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -48,6 +48,7 @@ const baseApp: ApplicationNested = { dockerBuildStage: "", isPreviewDeploymentsActive: false, previewBuildArgs: null, + previewBuildSecrets: null, previewCertificateType: "none", previewCustomCertResolver: null, previewEnv: null, @@ -73,6 +74,7 @@ const baseApp: ApplicationNested = { }, }, buildArgs: null, + buildSecrets: null, buildPath: "/", gitlabPathNamespace: "", buildType: "nixpacks", @@ -133,6 +135,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, rollbackActive: false, + stopGracePeriodSwarm: null, }; describe("unzipDrop using real zip files", () => { diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts new file mode 100644 index 000000000..6eb5d1831 --- /dev/null +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import { mechanizeDockerContainer } from "@dokploy/server/utils/builders"; + +type MockCreateServiceOptions = { + StopGracePeriod?: number; + [key: string]: unknown; +}; + +const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = + vi.hoisted(() => { + const inspect = vi.fn<[], Promise>(); + const getService = vi.fn(() => ({ inspect })); + const createService = vi.fn<[MockCreateServiceOptions], Promise>( + async () => undefined, + ); + const getRemoteDocker = vi.fn(async () => ({ + getService, + createService, + })); + return { + inspectMock: inspect, + getServiceMock: getService, + createServiceMock: createService, + getRemoteDockerMock: getRemoteDocker, + }; + }); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +const createApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + appName: "test-app", + buildType: "dockerfile", + env: null, + mounts: [], + cpuLimit: null, + memoryLimit: null, + memoryReservation: null, + cpuReservation: null, + command: null, + ports: [], + sourceType: "docker", + dockerImage: "example:latest", + registry: null, + environment: { + project: { env: null }, + env: null, + }, + replicas: 1, + stopGracePeriodSwarm: 0n, + serverId: "server-id", + ...overrides, + }) as unknown as ApplicationNested; + +describe("mechanizeDockerContainer", () => { + beforeEach(() => { + inspectMock.mockReset(); + inspectMock.mockRejectedValue(new Error("service not found")); + getServiceMock.mockClear(); + createServiceMock.mockClear(); + getRemoteDockerMock.mockClear(); + getRemoteDockerMock.mockResolvedValue({ + getService: getServiceMock, + createService: createServiceMock, + }); + }); + + it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { + const application = createApplication({ stopGracePeriodSwarm: 0n }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.StopGracePeriod).toBe(0); + expect(typeof settings.StopGracePeriod).toBe("number"); + }); + + it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => { + const application = createApplication({ stopGracePeriodSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings).not.toHaveProperty("StopGracePeriod"); + }); +}); diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts index 1144b65fe..3ae92ae20 100644 --- a/apps/dokploy/__test__/templates/helpers.template.test.ts +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -228,5 +228,58 @@ describe("helpers functions", () => { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", ); }); + + it("should handle JWT payload with newlines and whitespace by trimming them", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const payloadWithNewlines = `{ + "role": "anon", + "iss": "supabase", + "exp": ${expiry} +} +`; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: payloadWithNewlines, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("role"); + expect(decodedPayload.role).toEqual("anon"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("supabase"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + }); + + it("should handle JWT payload with leading and trailing whitespace", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: payloadWithWhitespace, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("role"); + expect(decodedPayload.role).toEqual("service_role"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("supabase"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + }); }); }); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 5be96e473..7b39eeb4f 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -25,8 +25,10 @@ const baseApp: ApplicationNested = { registryUrl: "", watchPaths: [], buildArgs: null, + buildSecrets: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, + previewBuildSecrets: null, triggerType: "push", previewCertificateType: "none", previewEnv: null, @@ -111,6 +113,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + stopGracePeriodSwarm: null, }; const baseDomain: Domain = { diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 9e10f43ec..4227eeb44 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -25,6 +25,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, @@ -176,10 +177,18 @@ const addSwarmSettings = z.object({ modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(), labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(), networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(), + stopGracePeriodSwarm: z.bigint().nullable(), }); type AddSwarmSettings = z.infer; +const hasStopGracePeriodSwarm = ( + value: unknown, +): value is { stopGracePeriodSwarm: bigint | number | string | null } => + typeof value === "object" && + value !== null && + "stopGracePeriodSwarm" in value; + interface Props { id: string; type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; @@ -224,12 +233,22 @@ export const AddSwarmSettings = ({ id, type }: Props) => { modeSwarm: null, labelsSwarm: null, networkSwarm: null, + stopGracePeriodSwarm: null, }, resolver: zodResolver(addSwarmSettings), }); useEffect(() => { if (data) { + const stopGracePeriodValue = hasStopGracePeriodSwarm(data) + ? data.stopGracePeriodSwarm + : null; + const normalizedStopGracePeriod = + stopGracePeriodValue === null || stopGracePeriodValue === undefined + ? null + : typeof stopGracePeriodValue === "bigint" + ? stopGracePeriodValue + : BigInt(stopGracePeriodValue); form.reset({ healthCheckSwarm: data.healthCheckSwarm ? JSON.stringify(data.healthCheckSwarm, null, 2) @@ -255,6 +274,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { networkSwarm: data.networkSwarm ? JSON.stringify(data.networkSwarm, null, 2) : null, + stopGracePeriodSwarm: normalizedStopGracePeriod, }); } }, [form, form.reset, data]); @@ -275,6 +295,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { modeSwarm: data.modeSwarm, labelsSwarm: data.labelsSwarm, networkSwarm: data.networkSwarm, + stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null, }) .then(async () => { toast.success("Swarm settings updated"); @@ -352,9 +373,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"], - "Interval" : 10000, - "Timeout" : 10000, - "StartPeriod" : 10000, + "Interval" : 10000000000, + "Timeout" : 10000000000, + "StartPeriod" : 10000000000, "Retries" : 10 }`} className="h-[12rem] font-mono" @@ -407,9 +428,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Condition" : "on-failure", - "Delay" : 10000, + "Delay" : 10000000000, "MaxAttempts" : 10, - "Window" : 10000 + "Window" : 10000000000 } `} className="h-[12rem] font-mono" {...field} @@ -529,9 +550,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Parallelism" : 1, - "Delay" : 10000, + "Delay" : 10000000000, "FailureAction" : "continue", - "Monitor" : 10000, + "Monitor" : 10000000000, "MaxFailureRatio" : 10, "Order" : "start-first" }`} @@ -587,9 +608,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Parallelism" : 1, - "Delay" : 10000, + "Delay" : 10000000000, "FailureAction" : "continue", - "Monitor" : 10000, + "Monitor" : 10000000000, "MaxFailureRatio" : 10, "Order" : "start-first" }`} @@ -774,7 +795,57 @@ export const AddSwarmSettings = ({ id, type }: Props) => { )} /> - + ( + + Stop Grace Period (nanoseconds) + + + + + Duration in nanoseconds + + + + + +
+														{`Enter duration in nanoseconds:
+														• 30000000000 - 30 seconds
+														• 120000000000 - 2 minutes  
+														• 3600000000000 - 1 hour
+														• 0 - no grace period`}
+													
+
+
+
+
+ + + field.onChange( + e.target.value ? BigInt(e.target.value) : null, + ) + } + /> + +
+										
+									
+
+ )} + /> + {serverId && (
{ }); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading]); + return (
diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 78edb1aaa..48e978880 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -12,6 +12,7 @@ import { api } from "@/utils/api"; const addEnvironmentSchema = z.object({ env: z.string(), buildArgs: z.string(), + buildSecrets: z.string(), }); type EnvironmentSchema = z.infer; @@ -37,6 +38,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { defaultValues: { env: "", buildArgs: "", + buildSecrets: "", }, resolver: zodResolver(addEnvironmentSchema), }); @@ -44,15 +46,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => { // Watch form values const currentEnv = form.watch("env"); const currentBuildArgs = form.watch("buildArgs"); + const currentBuildSecrets = form.watch("buildSecrets"); const hasChanges = currentEnv !== (data?.env || "") || - currentBuildArgs !== (data?.buildArgs || ""); + currentBuildArgs !== (data?.buildArgs || "") || + currentBuildSecrets !== (data?.buildSecrets || ""); useEffect(() => { if (data) { form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", + buildSecrets: data.buildSecrets || "", }); } }, [data, form]); @@ -61,6 +66,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { mutateAsync({ env: formData.env, buildArgs: formData.buildArgs, + buildSecrets: formData.buildSecrets, applicationId, }) .then(async () => { @@ -76,9 +82,25 @@ export const ShowEnvironment = ({ applicationId }: Props) => { form.reset({ env: data?.env || "", buildArgs: data?.buildArgs || "", + buildSecrets: data?.buildSecrets || "", }); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading]); + return (
@@ -104,13 +126,36 @@ export const ShowEnvironment = ({ applicationId }: Props) => { {data?.buildType === "dockerfile" && ( - Available only at build-time. See documentation  + Arguments are available only at build-time. See + documentation  + here + + . + + } + placeholder="NPM_TOKEN=xyz" + /> + )} + {data?.buildType === "dockerfile" && ( + + Secrets are specially designed for sensitive information and + are only available at build-time. See documentation  + diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index 6f6db5dd1..1f54ddd58 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules || false, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 61690e740..e9be3a2f5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => { const router = useRouter(); const { mutateAsync, isLoading } = - api.application.saveGitProdiver.useMutation(); + api.application.saveGitProvider.useMutation(); const form = useForm({ defaultValues: { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 9a4b92ce1..80d6850ca 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx index cb7209f8a..d6f65caf3 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx @@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index 16c916d93..862d5f87a 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -46,6 +46,7 @@ const schema = z .object({ env: z.string(), buildArgs: z.string(), + buildSecrets: z.string(), wildcardDomain: z.string(), port: z.number(), previewLimit: z.number(), @@ -109,6 +110,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { form.reset({ env: data.previewEnv || "", buildArgs: data.previewBuildArgs || "", + buildSecrets: data.previewBuildSecrets || "", wildcardDomain: data.previewWildcard || "*.traefik.me", port: data.previewPort || 3000, previewLabels: data.previewLabels || [], @@ -127,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { updateApplication({ previewEnv: formData.env, previewBuildArgs: formData.buildArgs, + previewBuildSecrets: formData.buildSecrets, previewWildcard: formData.wildcardDomain, previewPort: formData.port, previewLabels: formData.previewLabels, @@ -467,13 +470,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { {data?.buildType === "dockerfile" && ( - Available only at build-time. See documentation  + Arguments are available only at build-time. See + documentation  + here + + . + + } + placeholder="NPM_TOKEN=xyz" + /> + )} + {data?.buildType === "dockerfile" && ( + + Secrets are specially designed for sensitive information + and are only available at build-time. See + documentation  + diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 3209b6e03..26bfa9421 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -6,6 +6,7 @@ import { Terminal, Trash2, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; @@ -33,6 +34,9 @@ interface Props { } export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { + const [runningSchedules, setRunningSchedules] = useState>( + new Set(), + ); const { data: schedules, isLoading: isLoadingSchedules, @@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { enabled: !!id, }, ); - const utils = api.useUtils(); - const { mutateAsync: deleteSchedule, isLoading: isDeleting } = api.schedule.delete.useMutation(); + const { mutateAsync: runManually } = api.schedule.runManually.useMutation(); - const { mutateAsync: runManually, isLoading } = - api.schedule.runManually.useMutation(); + const handleRunManually = async (scheduleId: string) => { + setRunningSchedules((prev) => new Set(prev).add(scheduleId)); + try { + await runManually({ scheduleId }); + toast.success("Schedule run successfully"); + await refetchSchedules(); + } catch { + toast.error("Error running schedule"); + } finally { + setRunningSchedules((prev) => { + const newSet = new Set(prev); + newSet.delete(scheduleId); + return newSet; + }); + } + }; return ( @@ -67,7 +84,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { Schedule tasks to run automatically at specified intervals.
- {schedules && schedules.length > 0 && ( )} @@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { {isLoadingSchedules ? ( -
+
Loading scheduled tasks... @@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { return (
-
+
-
+

{schedule.name} @@ -132,16 +148,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { )}

{schedule.command && ( -
- - +
+ + {schedule.command}
)}
-
{ serverId={serverId || undefined} > - @@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { type="button" variant="ghost" size="icon" - isLoading={isLoading} - onClick={async () => { - toast.success("Schedule run successfully"); - - await runManually({ - scheduleId: schedule.scheduleId, - }) - .then(async () => { - await new Promise((resolve) => - setTimeout(resolve, 1500), - ); - refetchSchedules(); - }) - .catch(() => { - toast.error("Error running schedule"); - }); - }} + disabled={runningSchedules.has(schedule.scheduleId)} + onClick={() => + handleRunManually(schedule.scheduleId) + } > - + {runningSchedules.has(schedule.scheduleId) ? ( + + ) : ( + + )} Run Manual Schedule - - { diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx index c88dd92f5..092538150 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -5,6 +5,7 @@ import { Play, Trash2, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; @@ -38,6 +39,7 @@ export const ShowVolumeBackups = ({ type = "application", serverId, }: Props) => { + const [runningBackups, setRunningBackups] = useState>(new Set()); const { data: volumeBackups, isLoading: isLoadingVolumeBackups, @@ -51,19 +53,33 @@ export const ShowVolumeBackups = ({ enabled: !!id, }, ); - const utils = api.useUtils(); - const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = api.volumeBackups.delete.useMutation(); - - const { mutateAsync: runManually, isLoading } = + const { mutateAsync: runManually } = api.volumeBackups.runManually.useMutation(); + const handleRunManually = async (volumeBackupId: string) => { + setRunningBackups((prev) => new Set(prev).add(volumeBackupId)); + try { + await runManually({ volumeBackupId }); + toast.success("Volume backup run successfully"); + await refetchVolumeBackups(); + } catch { + toast.error("Error running volume backup"); + } finally { + setRunningBackups((prev) => { + const newSet = new Set(prev); + newSet.delete(volumeBackupId); + return newSet; + }); + } + }; + return ( -
+
Volume Backups @@ -73,12 +89,10 @@ export const ShowVolumeBackups = ({ intervals.
- -
+
{volumeBackups && volumeBackups.length > 0 && ( <> -
{isLoadingVolumeBackups ? ( -
+
Loading volume backups... @@ -113,13 +127,13 @@ export const ShowVolumeBackups = ({ return (
-
+
-
+

{volumeBackup.name} @@ -143,18 +157,16 @@ export const ShowVolumeBackups = ({

- -
+
- @@ -162,25 +174,18 @@ export const ShowVolumeBackups = ({ type="button" variant="ghost" size="icon" - isLoading={isLoading} - onClick={async () => { - toast.success("Volume backup run successfully"); - - await runManually({ - volumeBackupId: volumeBackup.volumeBackupId, - }) - .then(async () => { - await new Promise((resolve) => - setTimeout(resolve, 1500), - ); - refetchVolumeBackups(); - }) - .catch(() => { - toast.error("Error running volume backup"); - }); - }} + disabled={runningBackups.has( + volumeBackup.volumeBackupId, + )} + onClick={() => + handleRunManually(volumeBackup.volumeBackupId) + } > - + {runningBackups.has(volumeBackup.volumeBackupId) ? ( + + ) : ( + + )} @@ -188,13 +193,11 @@ export const ShowVolumeBackups = ({ - - @@ -230,7 +233,7 @@ export const ShowVolumeBackups = ({ })}
) : ( -
+

No volume backups diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index e75aad5e5..5c8577dff 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -104,7 +104,7 @@ export const DeleteService = ({ id, type }: Props) => { push( `/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`, ); - toast.success("deleted successfully"); + toast.success("Service deleted successfully"); setIsOpen(false); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index 870444be7..1bbeb880e 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -195,6 +195,7 @@ export const ComposeActions = ({ composeId }: Props) => { + - + */} {environment.name !== "production" && (

- + {canDeleteEnvironments && ( + + )}
)}
@@ -285,13 +294,15 @@ export const AdvancedEnvironmentSelector = ({ })} - setIsCreateDialogOpen(true)} - > - - Create Environment - + {canCreateEnvironments && ( + setIsCreateDialogOpen(true)} + > + + Create Environment + + )} diff --git a/apps/dokploy/components/dashboard/project/environment-variables.tsx b/apps/dokploy/components/dashboard/project/environment-variables.tsx index 8cafc8073..e833fa779 100644 --- a/apps/dokploy/components/dashboard/project/environment-variables.tsx +++ b/apps/dokploy/components/dashboard/project/environment-variables.tsx @@ -82,6 +82,21 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { .finally(() => {}); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading, isOpen]); + return ( diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx index 86dfd2433..cb6245f08 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx @@ -81,6 +81,21 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { .finally(() => {}); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading, isOpen]); + return ( diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index a0138b3c3..d36d53af2 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { {(isError || isErrorConnection) && ( - + {connectionError?.message || error?.message} )} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index 4e4171bee..e5fee3a9d 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1,17 +1,14 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { - AlertTriangle, - Mail, - MessageCircleMore, - PenBoxIcon, - PlusIcon, -} from "lucide-react"; +import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { DiscordIcon, + GotifyIcon, + LarkIcon, + NtfyIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -110,6 +107,12 @@ export const notificationSchema = z.discriminatedUnion("type", [ priority: z.number().min(1).max(5).default(3), }) .merge(notificationBaseSchema), + z + .object({ + type: z.literal("lark"), + webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), + }) + .merge(notificationBaseSchema), ]); export const notificationsMap = { @@ -125,16 +128,20 @@ export const notificationsMap = { icon: , label: "Discord", }, + lark: { + icon: , + label: "Lark", + }, email: { icon: , label: "Email", }, gotify: { - icon: , + icon: , label: "Gotify", }, ntfy: { - icon: , + icon: , label: "ntfy", }, }; @@ -170,6 +177,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { api.notification.testGotifyConnection.useMutation(); const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } = api.notification.testNtfyConnection.useMutation(); + const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } = + api.notification.testLarkConnection.useMutation(); const slackMutation = notificationId ? api.notification.updateSlack.useMutation() : api.notification.createSlack.useMutation(); @@ -188,6 +197,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { const ntfyMutation = notificationId ? api.notification.updateNtfy.useMutation() : api.notification.createNtfy.useMutation(); + const larkMutation = notificationId + ? api.notification.updateLark.useMutation() + : api.notification.createLark.useMutation(); const form = useForm({ defaultValues: { @@ -206,10 +218,10 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); useEffect(() => { - if (type === "email") { + if (type === "email" && fields.length === 0) { append(""); } - }, [type, append]); + }, [type, append, fields.length]); useEffect(() => { if (notification) { @@ -297,6 +309,19 @@ export const HandleNotifications = ({ notificationId }: Props) => { serverUrl: notification.ntfy?.serverUrl, name: notification.name, dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); + } else if (notification.notificationType === "lark") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + type: notification.notificationType, + webhookUrl: notification.lark?.webhookUrl, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, }); } } else { @@ -311,6 +336,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { email: emailMutation, gotify: gotifyMutation, ntfy: ntfyMutation, + lark: larkMutation, }; const onSubmit = async (data: NotificationSchema) => { @@ -414,6 +440,19 @@ export const HandleNotifications = ({ notificationId }: Props) => { notificationId: notificationId || "", ntfyId: notification?.ntfyId || "", }); + } else if (data.type === "lark") { + promise = larkMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + webhookUrl: data.webhookUrl, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + larkId: notification?.larkId || "", + serverThreshold: serverThreshold, + }); } if (promise) { @@ -502,7 +541,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { />
@@ -1150,7 +1210,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { isLoadingDiscord || isLoadingEmail || isLoadingGotify || - isLoadingNtfy + isLoadingNtfy || + isLoadingLark } variant="secondary" onClick={async () => { @@ -1194,6 +1255,10 @@ export const HandleNotifications = ({ notificationId }: Props) => { accessToken: form.getValues("accessToken"), priority: form.getValues("priority"), }); + } else if (type === "lark") { + await testLarkConnection({ + webhookUrl: form.getValues("webhookUrl"), + }); } toast.success("Connection Success"); } catch { diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index fe31acc4c..5b7a2220e 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -1,7 +1,10 @@ -import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react"; +import { Bell, Loader2, Mail, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DiscordIcon, + GotifyIcon, + LarkIcon, + NtfyIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -33,7 +36,7 @@ export const ShowNotifications = () => { Add your providers to receive notifications, like Discord, Slack, - Telegram, Email. + Telegram, Email, Lark. @@ -85,12 +88,17 @@ export const ShowNotifications = () => { )} {notification.notificationType === "gotify" && (
- +
)} {notification.notificationType === "ntfy" && (
- + +
+ )} + {notification.notificationType === "lark" && ( +
+
)} diff --git a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx new file mode 100644 index 000000000..17220cd11 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx @@ -0,0 +1,429 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import copy from "copy-to-clipboard"; +import { + CopyIcon, + DownloadIcon, + KeyRound, + RefreshCw, + ShieldOff, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; +import { + BACKUP_CODES_PLACEHOLDER, + backupCodeTemplate, + DATE_PLACEHOLDER, + USERNAME_PLACEHOLDER, +} from "./enable-2fa"; + +const PasswordSchema = z.object({ + password: z.string().min(8, { + message: "Password is required", + }), +}); + +type PasswordForm = z.infer; +type Step = "password" | "actions" | "backup-codes"; + +export const Configure2FA = () => { + const utils = api.useUtils(); + const { data: currentUser } = api.user.get.useQuery(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [step, setStep] = useState("password"); + const [password, setPassword] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [showDisableConfirm, setShowDisableConfirm] = useState(false); + const [isDisabling, setIsDisabling] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + + const form = useForm({ + resolver: zodResolver(PasswordSchema), + defaultValues: { + password: "", + }, + }); + + useEffect(() => { + if (!isDialogOpen) { + setStep("password"); + setPassword(""); + setBackupCodes([]); + form.reset(); + } + }, [isDialogOpen, form]); + + const handlePasswordSubmit = async (formData: PasswordForm) => { + setIsRegenerating(true); + try { + // Verify password by attempting to generate backup codes + // This validates the password and checks if 2FA is enabled + const result = await authClient.twoFactor.generateBackupCodes({ + password: formData.password, + }); + + if (result.error) { + form.setError("password", { message: result.error.message }); + toast.error(result.error.message); + return; + } + + // If we get here, password is correct + setPassword(formData.password); + setStep("actions"); + } catch (error) { + form.setError("password", { + message: error instanceof Error ? error.message : "Incorrect password", + }); + toast.error("Incorrect password"); + } finally { + setIsRegenerating(false); + } + }; + + const handleRegenerateBackupCodes = async () => { + setIsRegenerating(true); + try { + const result = await authClient.twoFactor.generateBackupCodes({ + password, + }); + + if (result.error) { + toast.error(result.error.message); + return; + } + + if (result.data?.backupCodes) { + setBackupCodes(result.data.backupCodes); + setStep("backup-codes"); + toast.success("Backup codes regenerated successfully"); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to regenerate backup codes", + ); + } finally { + setIsRegenerating(false); + } + }; + + const handleDisable2FA = async () => { + setIsDisabling(true); + try { + const result = await authClient.twoFactor.disable({ + password, + }); + + if (result.error) { + toast.error(result.error.message); + return; + } + + toast.success("2FA disabled successfully"); + utils.user.get.invalidate(); + setIsDialogOpen(false); + setShowDisableConfirm(false); + } catch (error) { + toast.error("Failed to disable 2FA. Please try again."); + } finally { + setIsDisabling(false); + } + }; + + const handleCloseDialog = () => { + if (step === "backup-codes") { + setStep("actions"); + } else { + setIsDialogOpen(false); + } + }; + + const handleDownloadBackupCodes = () => { + if (!backupCodes || backupCodes.length === 0) { + toast.error("No backup codes to download."); + return; + } + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`; + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + const blob = new Blob([backupCodesText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleCopyBackupCodes = () => { + const date = new Date(); + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + copy(backupCodesText); + toast.success("Backup codes copied to clipboard"); + }; + + return ( + <> + + + + + + + + {step === "password" && "Verify Your Identity"} + {step === "actions" && "2FA Configuration"} + {step === "backup-codes" && "New Backup Codes"} + + + {step === "password" && + "Enter your password to manage your 2FA settings"} + {step === "actions" && + "Choose an action to manage your two-factor authentication"} + {step === "backup-codes" && + "Save these backup codes in a secure place"} + + + + {step === "password" && ( + + + ( + + Password + + + + + Enter your password to continue + + + + )} + /> +
+ + +
+ + + )} + + {step === "actions" && ( +
+
+
+
+
+

+ + Regenerate Backup Codes +

+

+ Generate new backup codes to replace your existing ones. + This will invalidate all previous backup codes. +

+
+
+ +
+ +
+
+
+

+ + Disable 2FA +

+

+ Completely disable two-factor authentication for your + account. This will make your account less secure. +

+
+
+ +
+
+ +
+ +
+
+ )} + + {step === "backup-codes" && ( +
+
+
+ {backupCodes.map((code, index) => ( + + {code} + + ))} +
+

+ Save these backup codes in a secure place. You can use them to + access your account if you lose access to your authenticator + device. Each code can only be used once. +

+
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+ + + + + Are you absolutely sure? + + This will permanently disable Two-Factor Authentication for your + account. Your account will be less secure without 2FA enabled. + + + + Cancel + + {isDisabling ? "Disabling..." : "Disable 2FA"} + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx deleted file mode 100644 index 4055d4079..000000000 --- a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth-client"; -import { api } from "@/utils/api"; - -const PasswordSchema = z.object({ - password: z.string().min(8, { - message: "Password is required", - }), -}); - -type PasswordForm = z.infer; - -export const Disable2FA = () => { - const utils = api.useUtils(); - const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const form = useForm({ - resolver: zodResolver(PasswordSchema), - defaultValues: { - password: "", - }, - }); - - const handleSubmit = async (formData: PasswordForm) => { - setIsLoading(true); - try { - const result = await authClient.twoFactor.disable({ - password: formData.password, - }); - - if (result.error) { - form.setError("password", { - message: result.error.message, - }); - toast.error(result.error.message); - return; - } - - toast.success("2FA disabled successfully"); - utils.user.get.invalidate(); - setIsOpen(false); - } catch { - form.setError("password", { - message: "Connection error. Please try again.", - }); - toast.error("Connection error. Please try again."); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently disable - Two-Factor Authentication for your account. - - - -
- - ( - - Password - - - - - Enter your password to disable 2FA - - - - )} - /> -
- - -
- - -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index e630ec4f8..656b27401 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Fingerprint, QrCode } from "lucide-react"; +import copy from "copy-to-clipboard"; +import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react"; import QRCode from "qrcode"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -29,6 +30,12 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; @@ -54,6 +61,26 @@ type TwoFactorSetupData = { type PasswordForm = z.infer; type PinForm = z.infer; +export const USERNAME_PLACEHOLDER = "%username%"; +export const DATE_PLACEHOLDER = "%date%"; +export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%"; + +export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES + +Points to note +-------------- +# Each code can be used only once. +# Do not share these codes with anyone. + +Generated codes +--------------- +Username: ${USERNAME_PLACEHOLDER} +Generated on: ${DATE_PLACEHOLDER} + + +${BACKUP_CODES_PLACEHOLDER} +`; + export const Enable2FA = () => { const utils = api.useUtils(); const [data, setData] = useState(null); @@ -62,6 +89,7 @@ export const Enable2FA = () => { const [step, setStep] = useState<"password" | "verify">("password"); const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [otpValue, setOtpValue] = useState(""); + const { data: currentUser } = api.user.get.useQuery(); const handleVerifySubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -178,6 +206,54 @@ export const Enable2FA = () => { } }; + const handleDownloadBackupCodes = () => { + if (!backupCodes || backupCodes.length === 0) { + toast.error("No backup codes to download."); + return; + } + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`; + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + const blob = new Blob([backupCodesText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleCopyBackupCodes = () => { + const date = new Date(); + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + copy(backupCodesText); + toast.success("Backup codes copied to clipboard"); + }; + return ( @@ -264,6 +340,7 @@ export const Enable2FA = () => { Scan this QR code with your authenticator app + {/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */} 2FA QR Code { {backupCodes && backupCodes.length > 0 && (
-

Backup Codes

+
+

Backup Codes

+
+ + + + + + +

Copy

+
+
+
+ + + + + + + +

Download

+
+
+
+
+
{backupCodes.map((code, index) => ( { - const _utils = api.useUtils(); const { data, refetch, isLoading } = api.user.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -120,28 +119,27 @@ export const ProfileForm = () => { }, [form, data]); const onSubmit = async (values: Profile) => { - await mutateAsync({ - email: values.email.toLowerCase(), - password: values.password || undefined, - image: values.image, - currentPassword: values.currentPassword || undefined, - allowImpersonation: values.allowImpersonation, - name: values.name || undefined, - }) - .then(async () => { - await refetch(); - toast.success("Profile Updated"); - form.reset({ - email: values.email, - password: "", - image: values.image, - currentPassword: "", - name: values.name || "", - }); - }) - .catch(() => { - toast.error("Error updating the profile"); + try { + await mutateAsync({ + email: values.email.toLowerCase(), + password: values.password || undefined, + image: values.image, + currentPassword: values.currentPassword || undefined, + allowImpersonation: values.allowImpersonation, + name: values.name || undefined, }); + await refetch(); + toast.success("Profile Updated"); + form.reset({ + email: values.email, + password: "", + image: values.image, + currentPassword: "", + name: values.name || "", + }); + } catch (error) { + toast.error("Error updating the profile"); + } }; return ( @@ -158,7 +156,8 @@ export const ProfileForm = () => { {t("settings.profile.description")}
- {!data?.user.twoFactorEnabled ? : } + + {!data?.user.twoFactorEnabled ? : } @@ -257,8 +256,16 @@ export const ProfileForm = () => { onValueChange={(e) => { field.onChange(e); }} - defaultValue={field.value} - value={field.value} + defaultValue={ + field.value?.startsWith("data:") + ? "upload" + : field.value + } + value={ + field.value?.startsWith("data:") + ? "upload" + : field.value + } className="flex flex-row flex-wrap gap-2 max-xl:justify-center" > @@ -279,6 +286,72 @@ export const ProfileForm = () => { + + + + + +
+ document + .getElementById("avatar-upload") + ?.click() + } + > + {field.value?.startsWith("data:") ? ( + // biome-ignore lint/performance/noImgElement: this is an justified use of img element + Custom avatar + ) : ( + + + + )} +
+ { + const file = e.target.files?.[0]; + if (file) { + // max file size 2mb + if (file.size > 2 * 1024 * 1024) { + toast.error( + "Image size must be less than 2MB", + ); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target + ?.result as string; + field.onChange(result); + }; + reader.readAsDataURL(file); + } + }} + /> +
+
{availableAvatars.map((image) => ( @@ -289,6 +362,7 @@ export const ProfileForm = () => { /> + {/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */} ; @@ -175,6 +177,7 @@ interface Props { } export const AddUserPermissions = ({ userId }: Props) => { + const [isOpen, setIsOpen] = useState(false); const { data: projects } = api.project.all.useQuery(); const { data, refetch } = api.user.one.useQuery( @@ -192,13 +195,25 @@ export const AddUserPermissions = ({ userId }: Props) => { const form = useForm({ defaultValues: { accessedProjects: [], + accessedEnvironments: [], accessedServices: [], + canDeleteEnvironments: false, + canCreateProjects: false, + canCreateServices: false, + canDeleteProjects: false, + canDeleteServices: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + canCreateEnvironments: false, }, resolver: zodResolver(addPermissions), }); useEffect(() => { - if (data) { + if (data && isOpen) { form.reset({ accessedProjects: data.accessedProjects || [], accessedEnvironments: data.accessedEnvironments || [], @@ -207,14 +222,16 @@ export const AddUserPermissions = ({ userId }: Props) => { canCreateServices: data.canCreateServices, canDeleteProjects: data.canDeleteProjects, canDeleteServices: data.canDeleteServices, + canDeleteEnvironments: data.canDeleteEnvironments || false, canAccessToTraefikFiles: data.canAccessToTraefikFiles, canAccessToDocker: data.canAccessToDocker, canAccessToAPI: data.canAccessToAPI, canAccessToSSHKeys: data.canAccessToSSHKeys, canAccessToGitProviders: data.canAccessToGitProviders, + canCreateEnvironments: data.canCreateEnvironments, }); } - }, [form, form.formState.isSubmitSuccessful, form.reset, data]); + }, [form, form.reset, data, isOpen]); const onSubmit = async (data: AddPermissions) => { await mutateAsync({ @@ -223,6 +240,7 @@ export const AddUserPermissions = ({ userId }: Props) => { canCreateProjects: data.canCreateProjects, canDeleteServices: data.canDeleteServices, canDeleteProjects: data.canDeleteProjects, + canDeleteEnvironments: data.canDeleteEnvironments, canAccessToTraefikFiles: data.canAccessToTraefikFiles, accessedProjects: data.accessedProjects || [], accessedEnvironments: data.accessedEnvironments || [], @@ -231,17 +249,19 @@ export const AddUserPermissions = ({ userId }: Props) => { canAccessToAPI: data.canAccessToAPI, canAccessToSSHKeys: data.canAccessToSSHKeys, canAccessToGitProviders: data.canAccessToGitProviders, + canCreateEnvironments: data.canCreateEnvironments, }) .then(async () => { toast.success("Permissions updated"); refetch(); + setIsOpen(false); }) .catch(() => { toast.error("Error updating the permissions"); }); }; return ( - + { )} /> + ( + +
+ Create Environments + + Allow the user to create environments + +
+ + + +
+ )} + /> + ( + +
+ Delete Environments + + Allow the user to delete environments + +
+ + + +
+ )} + /> { resolver: zodResolver(addServerDomain), }); const https = form.watch("https"); + const domain = form.watch("domain") || ""; + const host = data?.user?.host || ""; + const hasChanged = domain !== host; useEffect(() => { if (data) { form.reset({ @@ -119,6 +123,19 @@ export const WebDomain = () => {
+ {/* Warning for GitHub webhook URL changes */} + {hasChanged && ( + +
+

⚠️ Important: URL Change Impact

+

+ If you change the Dokploy Server URL make sure to update + your Github Apps to keep the auto-deploy working and preview + deployments working. +

+
+
+ )}
{ +export const DockerTerminalModal = ({ + children, + appName, + serverId, + appType, +}: Props) => { const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, + appType, serverId, }, { enabled: !!appName, }, ); + const [containerId, setContainerId] = useState(); const [mainDialogOpen, setMainDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); @@ -83,7 +90,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { {children} event.preventDefault()} > @@ -92,7 +99,6 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { Easy way to access to docker container -