diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts index b18d7b4b..9fa68b6b 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -121,6 +121,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + rollbackActive: false, }; describe("unzipDrop using real zip files", () => { diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 6c136b25..5cd033af 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -5,6 +5,7 @@ import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { + rollbackActive: false, applicationId: "", herokuVersion: "", giteaRepository: "", diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 3cb18f98..d095e0ef 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -10,11 +10,14 @@ import { CardTitle, } from "@/components/ui/card"; import { type RouterOutputs, api } from "@/utils/api"; -import { Clock, Loader2, RocketIcon } from "lucide-react"; +import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react"; import React, { useEffect, useState } from "react"; import { CancelQueues } from "./cancel-queues"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; +import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { toast } from "sonner"; interface Props { id: string; @@ -57,6 +60,9 @@ export const ShowDeployments = ({ }, ); + const { mutateAsync: rollback, isLoading: isRollingBack } = + api.rollback.rollback.useMutation(); + const [url, setUrl] = React.useState(""); useEffect(() => { setUrl(document.location.origin); @@ -71,9 +77,18 @@ export const ShowDeployments = ({ See all the 10 last deployments for this {type} - {(type === "application" || type === "compose") && ( - - )} +
+ {(type === "application" || type === "compose") && ( + + )} + {type === "application" && ( + + + + )} +
{refreshToken && ( @@ -154,13 +169,47 @@ export const ShowDeployments = ({ )} - +
+ + + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
))} diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 6f504959..35ddc51b 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -49,7 +49,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { currentBuildArgs !== (data?.buildArgs || ""); useEffect(() => { - if (data) { + if (data && !hasChanges) { form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx index 905fe711..13d3a6d8 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx @@ -16,9 +16,11 @@ import { api } from "@/utils/api"; import { GitBranch, Loader2, UploadCloud } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { toast } from "sonner"; import { SaveBitbucketProvider } from "./save-bitbucket-provider"; import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveGitlabProvider } from "./save-gitlab-provider"; +import { UnauthorizedGitProvider } from "./unauthorized-git-provider"; type TabState = | "github" @@ -43,12 +45,31 @@ export const ShowProviderForm = ({ applicationId }: Props) => { const { data: giteaProviders, isLoading: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); - const { data: application } = api.application.one.useQuery({ applicationId }); + const { data: application, refetch } = api.application.one.useQuery({ + applicationId, + }); + const { mutateAsync: disconnectGitProvider } = + api.application.disconnectGitProvider.useMutation(); + const [tab, setSab] = useState(application?.sourceType || "github"); const isLoading = isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; + const handleDisconnect = async () => { + try { + await disconnectGitProvider({ applicationId }); + toast.success("Repository disconnected successfully"); + await refetch(); + } catch (error) { + toast.error( + `Failed to disconnect repository: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + if (isLoading) { return ( @@ -77,6 +98,38 @@ export const ShowProviderForm = ({ applicationId }: Props) => { ); } + // Check if user doesn't have access to the current git provider + if ( + application && + !application.hasGitProviderAccess && + application.sourceType !== "docker" && + application.sourceType !== "drop" + ) { + return ( + + + +
+ Provider +

+ Repository connection through unauthorized provider +

+
+
+ +
+
+
+ + + +
+ ); + } + return ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx new file mode 100644 index 00000000..4dbdf7a6 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx @@ -0,0 +1,149 @@ +import { + BitbucketIcon, + GitIcon, + GiteaIcon, + GithubIcon, + GitlabIcon, +} from "@/components/icons/data-tools-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { RouterOutputs } from "@/utils/api"; +import { AlertCircle, GitBranch, Unlink } from "lucide-react"; + +interface Props { + service: + | RouterOutputs["application"]["one"] + | RouterOutputs["compose"]["one"]; + onDisconnect: () => void; +} + +export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => { + const getProviderIcon = (sourceType: string) => { + switch (sourceType) { + case "github": + return ; + case "gitlab": + return ; + case "bitbucket": + return ; + case "gitea": + return ; + case "git": + return ; + default: + return ; + } + }; + + const getRepositoryInfo = () => { + switch (service.sourceType) { + case "github": + return { + repo: service.repository, + branch: service.branch, + owner: service.owner, + }; + case "gitlab": + return { + repo: service.gitlabRepository, + branch: service.gitlabBranch, + owner: service.gitlabOwner, + }; + case "bitbucket": + return { + repo: service.bitbucketRepository, + branch: service.bitbucketBranch, + owner: service.bitbucketOwner, + }; + case "gitea": + return { + repo: service.giteaRepository, + branch: service.giteaBranch, + owner: service.giteaOwner, + }; + case "git": + return { + repo: service.customGitUrl, + branch: service.customGitBranch, + owner: null, + }; + default: + return { repo: null, branch: null, owner: null }; + } + }; + + const { repo, branch, owner } = getRepositoryInfo(); + + return ( +
+ + + + This application is connected to a {service.sourceType} repository + through a git provider that you don't have access to. You can see + basic repository information below, but cannot modify the + configuration. + + + + + + + {getProviderIcon(service.sourceType)} + + {service.sourceType} Repository + + + + + {owner && ( +
+ + Owner: + +

{owner}

+
+ )} + {repo && ( +
+ + Repository: + +

{repo}

+
+ )} + {branch && ( +
+ + Branch: + +

{branch}

+
+ )} + +
+ { + onDisconnect(); + }} + > + + +

+ Disconnecting will allow you to configure a new repository with + your own git providers. +

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx new file mode 100644 index 00000000..4b2edca0 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx @@ -0,0 +1,117 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const formSchema = z.object({ + rollbackActive: z.boolean(), +}); + +type FormValues = z.infer; + +interface Props { + applicationId: string; + children?: React.ReactNode; +} + +export const ShowRollbackSettings = ({ applicationId, children }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const { data: application, refetch } = api.application.one.useQuery( + { + applicationId, + }, + { + enabled: !!applicationId, + }, + ); + + const { mutateAsync: updateApplication, isLoading } = + api.application.update.useMutation(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + rollbackActive: application?.rollbackActive ?? false, + }, + }); + + const onSubmit = async (data: FormValues) => { + await updateApplication({ + applicationId, + rollbackActive: data.rollbackActive, + }) + .then(() => { + toast.success("Rollback settings updated"); + setIsOpen(false); + refetch(); + }) + .catch(() => { + toast.error("Failed to update rollback settings"); + }); + }; + + return ( + + {children} + + + Rollback Settings + + Configure how rollbacks work for this application + + + +
+ + ( + +
+ + Enable Rollbacks + + + Allow rolling back to previous deployments + +
+ + + +
+ )} + /> + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 50f0f4ab..63ff189d 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => { resolver: zodResolver(AddComposeFile), }); + const composeFile = form.watch("composeFile"); + useEffect(() => { - if (data) { + if (data && !composeFile) { form.reset({ composeFile: data.composeFile || "", }); diff --git a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx index afdfbfba..cd510ad6 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx @@ -18,6 +18,8 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose"; import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; +import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider"; +import { toast } from "sonner"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea"; interface Props { @@ -34,12 +36,29 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => { const { data: giteaProviders, isLoading: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); - const { data: compose } = api.compose.one.useQuery({ composeId }); + const { mutateAsync: disconnectGitProvider } = + api.compose.disconnectGitProvider.useMutation(); + + const { data: compose, refetch } = api.compose.one.useQuery({ composeId }); const [tab, setSab] = useState(compose?.sourceType || "github"); const isLoading = isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; + const handleDisconnect = async () => { + try { + await disconnectGitProvider({ composeId }); + toast.success("Repository disconnected successfully"); + await refetch(); + } catch (error) { + toast.error( + `Failed to disconnect repository: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + if (isLoading) { return ( @@ -68,6 +87,37 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => { ); } + // Check if user doesn't have access to the current git provider + if ( + compose && + !compose.hasGitProviderAccess && + compose.sourceType !== "raw" + ) { + return ( + + + +
+ Provider +

+ Repository connection through unauthorized provider +

+
+
+ +
+
+
+ + + +
+ ); + } + return ( diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index ddc1303e..01d66fba 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -38,7 +38,7 @@ const AddProjectSchema = z.object({ (name) => { const trimmedName = name.trim(); const validNameRegex = - /^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u; + /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; return validNameRegex.test(trimmedName); }, { diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index 80f332d8..e2aa59ef 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -1,80 +1,93 @@ +// @ts-nocheck + export const extractExpirationDate = (certData: string): Date | null => { try { - const match = certData.match( - /-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, - ); - if (!match?.[1]) return null; - - const base64Cert = match[1].replace(/\s/g, ""); - const binaryStr = window.atob(base64Cert); - const bytes = new Uint8Array(binaryStr.length); - - for (let i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i); + // Decode PEM base64 to DER binary + const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const binStr = atob(b64); + const der = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + der[i] = binStr.charCodeAt(i); } - // ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18 - // We need to find the second occurrence of either tag as it's the "not after" (expiration) date - let dateFound = false; - for (let i = 0; i < bytes.length - 2; i++) { - // Look for sequence containing validity period (0x30) - if (bytes[i] === 0x30) { - // Check next bytes for UTCTime or GeneralizedTime - let j = i + 1; - while (j < bytes.length - 2) { - if (bytes[j] === 0x17 || bytes[j] === 0x18) { - const dateType = bytes[j]; - const dateLength = bytes[j + 1]; - if (typeof dateLength === "undefined") break; + let offset = 0; - if (!dateFound) { - // Skip "not before" date - dateFound = true; - j += dateLength + 2; - continue; - } - - // Found "not after" date - let dateStr = ""; - for (let k = 0; k < dateLength; k++) { - const charCode = bytes[j + 2 + k]; - if (typeof charCode === "undefined") continue; - dateStr += String.fromCharCode(charCode); - } - - if (dateType === 0x17) { - // UTCTime (YYMMDDhhmmssZ) - const year = Number.parseInt(dateStr.slice(0, 2)); - const fullYear = year >= 50 ? 1900 + year : 2000 + year; - return new Date( - Date.UTC( - fullYear, - Number.parseInt(dateStr.slice(2, 4)) - 1, - Number.parseInt(dateStr.slice(4, 6)), - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - ), - ); - } - - // GeneralizedTime (YYYYMMDDhhmmssZ) - return new Date( - Date.UTC( - Number.parseInt(dateStr.slice(0, 4)), - Number.parseInt(dateStr.slice(4, 6)) - 1, - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - Number.parseInt(dateStr.slice(12, 14)), - ), - ); - } - j++; + // Helper: read ASN.1 length field + function readLength(pos: number): { length: number; offset: number } { + // biome-ignore lint/style/noParameterAssign: + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + // biome-ignore lint/style/noParameterAssign: + len = (len << 8) + der[pos++]; } } + return { length: len, offset: pos }; } - return null; + + // Skip the outer certificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected sequence"); + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); + ({ offset } = readLength(offset)); + + // Check for optional version field (context-specific tag [0]) + if (der[offset] === 0xa0) { + offset++; + const versionLen = readLength(offset); + offset = versionLen.offset + versionLen.length; + } + + // Skip serialNumber, signature, issuer + for (let i = 0; i < 3; i++) { + if (der[offset] !== 0x30 && der[offset] !== 0x02) + throw new Error("Unexpected structure"); + offset++; + const fieldLen = readLength(offset); + offset = fieldLen.offset + fieldLen.length; + } + + // Validity sequence (notBefore and notAfter) + if (der[offset++] !== 0x30) throw new Error("Expected validity sequence"); + const validityLen = readLength(offset); + offset = validityLen.offset; + + // notBefore + offset++; + const notBeforeLen = readLength(offset); + offset = notBeforeLen.offset + notBeforeLen.length; + + // notAfter + offset++; + const notAfterLen = readLength(offset); + const notAfterStr = new TextDecoder().decode( + der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length), + ); + + // Parse GeneralizedTime (15 chars) or UTCTime (13 chars) + function parseTime(str: string): Date { + if (str.length === 13) { + // UTCTime YYMMDDhhmmssZ + const year = Number.parseInt(str.slice(0, 2), 10); + const fullYear = year < 50 ? 2000 + year : 1900 + year; + return new Date( + `${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`, + ); + } + if (str.length === 15) { + // GeneralizedTime YYYYMMDDhhmmssZ + return new Date( + `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`, + ); + } + throw new Error("Invalid ASN.1 time format"); + } + + return parseTime(notAfterStr); } catch (error) { console.error("Error parsing certificate:", error); return null; diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 90cefe59..af7d5854 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from "react"; export const AddGithubProvider = () => { const [isOpen, setIsOpen] = useState(false); const { data: activeOrganization } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); const { data } = api.user.get.useQuery(); const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); @@ -27,7 +28,7 @@ export const AddGithubProvider = () => { const url = document.location.origin; const manifest = JSON.stringify( { - redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`, + redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`, url: origin, hook_attributes: { diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index d05409fb..24e8f34e 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -41,6 +41,7 @@ const addInvitation = z.object({ .min(1, "Email is required") .email({ message: "Invalid email" }), role: z.enum(["member", "admin"]), + notificationId: z.string().optional(), }); type AddInvitation = z.infer; @@ -49,6 +50,10 @@ export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); const [isLoading, setIsLoading] = useState(false); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: emailProviders } = + api.notification.getEmailProviders.useQuery(); + const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); const [error, setError] = useState(null); const { data: activeOrganization } = authClient.useActiveOrganization(); @@ -56,6 +61,7 @@ export const AddInvitation = () => { defaultValues: { email: "", role: "member", + notificationId: "", }, resolver: zodResolver(addInvitation), }); @@ -74,7 +80,20 @@ export const AddInvitation = () => { if (result.error) { setError(result.error.message || ""); } else { - toast.success("Invitation created"); + if (!isCloud && data.notificationId) { + await sendInvitation({ + invitationId: result.data.id, + notificationId: data.notificationId || "", + }) + .then(() => { + toast.success("Invitation created and email sent"); + }) + .catch((error: any) => { + toast.error(error.message); + }); + } else { + toast.success("Invitation created"); + } setError(null); setOpen(false); } @@ -149,6 +168,47 @@ export const AddInvitation = () => { ); }} /> + + {!isCloud && ( + { + return ( + + Email Provider + + + Select the email provider to send the invitation + + + + ); + }} + /> + )}