Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
6b2eedefd7 Add scrolling to organization picker dropdown
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-12 15:49:50 +00:00
copilot-swe-agent[bot]
5c45cfcefe Initial plan 2026-02-12 15:46:36 +00:00
77 changed files with 3689 additions and 18179 deletions

View File

@@ -1,21 +0,0 @@
# Dockerfile for DevContainer
FROM node:20.16.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -1,53 +0,0 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

5
.gitignore vendored
View File

@@ -43,4 +43,7 @@ yarn-error.log*
*.pem
.db
.db
# Development environment
.devcontainer

View File

@@ -20,7 +20,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.76"
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -105,14 +105,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
const modeData =
formData.type === "Replicated"
? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
? { Replicated: { Replicas: formData.Replicas } }
: { Global: {} };
await mutateAsync({

View File

@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -24,7 +25,6 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";

View File

@@ -1,8 +1,8 @@
import { Loader2 } from "lucide-react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,

View File

@@ -19,9 +19,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -17,9 +17,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -19,9 +19,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -18,9 +18,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -18,7 +18,6 @@ import {
PushoverIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
@@ -165,12 +164,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("teams"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -190,10 +183,6 @@ export const notificationsMap = {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
teams: {
icon: <TeamsIcon className="text-muted-foreground" />,
label: "Microsoft Teams",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
@@ -255,8 +244,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
api.notification.testTeamsConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
@@ -291,9 +278,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const teamsMutation = notificationId
? api.notification.updateTeams.useMutation()
: api.notification.createTeams.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
@@ -369,7 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration ?? undefined,
decoration: notification.discord?.decoration || undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -416,7 +400,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration ?? undefined,
decoration: notification.gotify?.decoration || undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
@@ -451,19 +435,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "teams") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
@@ -517,7 +488,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
teams: teamsMutation,
custom: customMutation,
pushover: pushoverMutation,
};
@@ -660,20 +630,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "teams") {
promise = teamsMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
teamsId: notification?.teamsId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
@@ -1509,32 +1465,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "teams" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://xxx.webhook.office.com/webhookb2/..."
{...field}
/>
</FormControl>
<FormDescription>
Incoming Webhook URL from a Teams channel. Add an
Incoming Webhook in your channel settings to get the
URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "pushover" && (
<>
<FormField
@@ -1850,7 +1780,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingTeams ||
isLoadingCustom ||
isLoadingPushover
}
@@ -1912,10 +1841,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "teams") {
await testTeamsConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0

View File

@@ -7,7 +7,6 @@ import {
NtfyIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -38,7 +37,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Teams, Email, Resend, Lark.
Telegram, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -113,11 +112,6 @@ export const ShowNotifications = () => {
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.notificationType === "teams" && (
<div className="flex items-center justify-center rounded-lg">
<TeamsIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -72,6 +73,7 @@ export const ProfileForm = () => {
isError,
error,
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -155,10 +157,10 @@ export const ProfileForm = () => {
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<User className="size-6 text-muted-foreground self-center" />
Account
{t("settings.profile.title")}
</CardTitle>
<CardDescription>
Change the details of your profile here.
{t("settings.profile.description")}
</CardDescription>
</div>
@@ -211,9 +213,12 @@ export const ProfileForm = () => {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -228,7 +233,7 @@ export const ProfileForm = () => {
<FormControl>
<Input
type="password"
placeholder="Current Password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
@@ -242,11 +247,13 @@ export const ProfileForm = () => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>
{t("settings.profile.password")}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
@@ -261,7 +268,9 @@ export const ProfileForm = () => {
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormLabel>
{t("settings.profile.avatar")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
@@ -445,7 +454,7 @@ export const ProfileForm = () => {
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
Save
{t("settings.common.save")}
</Button>
</div>
</form>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
import { Button } from "@/components/ui/button";
@@ -16,6 +17,7 @@ import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation();
@@ -28,11 +30,13 @@ export const ShowDokployActions = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Button isLoading={isLoading} variant="outline">
Server
{t("settings.server.webServer.server.label")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -47,17 +51,17 @@ export const ShowDokployActions = () => {
}}
className="cursor-pointer"
>
<span>Reload</span>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
<TerminalModal serverId="local">
<span>Terminal</span>
<span>{t("settings.common.enterTerminal")}</span>
</TerminalModal>
<ShowModalLogs appName="dokploy">
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
{t("settings.server.webServer.watchLogs")}
</DropdownMenuItem>
</ShowModalLogs>
<GPUSupportModal />
@@ -66,7 +70,7 @@ export const ShowDokployActions = () => {
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Update Server IP
{t("settings.server.webServer.updateServerIp")}
</DropdownMenuItem>
</UpdateServerIp>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -15,6 +16,7 @@ interface Props {
serverId?: string;
}
export const ShowStorageActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
@@ -62,11 +64,13 @@ export const ShowStorageActions = ({ serverId }: Props) => {
}
variant="outline"
>
Space
{t("settings.server.webServer.storage.label")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -83,7 +87,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean unused images</span>
<span>
{t("settings.server.webServer.storage.cleanUnusedImages")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -99,7 +105,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean unused volumes</span>
<span>
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -116,7 +124,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean stopped containers</span>
<span>
{t("settings.server.webServer.storage.cleanStoppedContainers")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -133,7 +143,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean Docker Builder & System</span>
<span>
{t("settings.server.webServer.storage.cleanDockerBuilder")}
</span>
</DropdownMenuItem>
{!serverId && (
<DropdownMenuItem
@@ -148,7 +160,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean Monitoring</span>
<span>
{t("settings.server.webServer.storage.cleanMonitoring")}
</span>
</DropdownMenuItem>
)}
@@ -166,7 +180,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>Clean all</span>
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -21,6 +22,7 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
@@ -73,11 +75,13 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
}
variant="outline"
>
Traefik
{t("settings.server.webServer.traefik.label")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -96,7 +100,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>Reload</span>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
<ShowModalLogs
appName="dokploy-traefik"
@@ -107,7 +111,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
View Logs
{t("settings.server.webServer.watchLogs")}
</DropdownMenuItem>
</ShowModalLogs>
<EditTraefikEnv serverId={serverId}>
@@ -115,7 +119,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>Modify Environment</span>
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
</DropdownMenuItem>
</EditTraefikEnv>
@@ -172,7 +176,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>Additional Port Mappings</span>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup>

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -62,6 +63,8 @@ interface Props {
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -362,7 +365,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
@@ -376,7 +379,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormControl>
<Input
placeholder="22"
@@ -406,7 +409,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>

View File

@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -51,6 +52,7 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
const { data, refetch, isLoading } = api.server.all.useQuery();

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -65,6 +66,7 @@ const addServerDomain = z
type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -117,10 +119,10 @@ export const WebDomain = () => {
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex flex-row gap-2">
<GlobeIcon className="size-6 text-muted-foreground self-center" />
Server Domain
{t("settings.server.domain.title")}
</CardTitle>
<CardDescription>
Add a domain to your server application.
{t("settings.server.domain.description")}
</CardDescription>
</div>
</CardHeader>
@@ -149,7 +151,9 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -169,7 +173,9 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -210,20 +216,32 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
@@ -236,7 +254,7 @@ export const WebDomain = () => {
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
Save
{t("settings.common.save")}
</Button>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import {
Card,
CardContent,
@@ -14,6 +15,7 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
@@ -27,16 +29,18 @@ export const WebServer = () => {
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
Web Server
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>Reload or clean the web server.</CardDescription>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
Web Server
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>
Reload or clean the web server.
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -24,6 +23,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
const schema = z.object({

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -51,6 +52,8 @@ interface Props {
}
const LocalServerConfig = ({ onSave }: Props) => {
const { t } = useTranslation("settings");
const form = useForm<Schema>({
defaultValues: getLocalServerData(),
resolver: zodResolver(Schema),
@@ -74,7 +77,9 @@ const LocalServerConfig = ({ onSave }: Props) => {
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-row gap-2 items-center">
<Settings className="h-4 w-4" />
<span className="dark:hover:text-white">Connection settings</span>
<span className="dark:hover:text-white">
{t("settings.terminal.connectionSettings")}
</span>
</div>
</div>
</AccordionTrigger>
@@ -91,7 +96,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormControl>
<Input
{...field}
@@ -119,7 +124,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
@@ -137,7 +142,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
className="ml-auto"
disabled={!form.formState.isDirty}
>
Save
{t("settings.common.save")}
</Button>
</AccordionContent>
</AccordionItem>

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { useTranslation } from "next-i18next";
import type React from "react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
@@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
interface Props {
@@ -55,6 +56,7 @@ const TraefikPortsSchema = z.object({
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
@@ -82,7 +84,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: "Ports updated successfully",
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
onSuccess: () => {
refetchPorts();
setOpen(false);
@@ -127,12 +129,14 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
Additional Port Mappings
{t("settings.server.webServer.traefik.managePorts")}
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
Add or remove additional ports for Traefik
{t(
"settings.server.webServer.traefik.managePortsDescription",
)}
<span className="text-sm text-muted-foreground">
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
configured
@@ -175,7 +179,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
Target Port
{t(
"settings.server.webServer.traefik.targetPort",
)}
</FormLabel>
<FormControl>
<Input
@@ -204,7 +210,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
Published Port
{t(
"settings.server.webServer.traefik.publishedPort",
)}
</FormLabel>
<FormControl>
<Input

View File

@@ -88,35 +88,6 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const TeamsIcon = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="36"
viewBox="0 0 512 476"
className={cn("size-9", className)}
>
<g>
<rect x="116" y="50" width="280" height="276" rx="64" fill="#6264A7" />
<rect x="236" y="138" width="180" height="224" rx="60" fill="#5059C9" />
<circle cx="122" cy="332" r="80" fill="#B2B4D3" />
<circle cx="370" cy="364" r="64" fill="#A6A7DC" />
<text
x="180"
y="270"
fill="#fff"
font-family="Segoe UI, Arial, sans-serif"
font-size="110"
font-weight="bold"
>
T
</text>
</g>
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg

View File

@@ -630,15 +630,15 @@ function SidebarLogo() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
className="rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
<div className="max-h-[60vh] overflow-y-auto">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (

View File

@@ -10,9 +10,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
@@ -23,6 +32,7 @@ export const UserNav = () => {
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { locale, setLocale } = useLocale();
// const { mutateAsync } = api.auth.logout.useMutation();
return (
@@ -145,19 +155,39 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="flex items-center justify-between px-2 py-1.5">
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="w-32">
<Select
onValueChange={setLocale}
defaultValue={locale}
value={locale}
>
<SelectTrigger>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
{Object.values(Languages).map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -2,8 +2,8 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
export function SignInWithGithub() {
const [isLoading, setIsLoading] = useState(false);

View File

@@ -2,8 +2,8 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
export function SignInWithGoogle() {
const [isLoading, setIsLoading] = useState(false);

View File

@@ -2,9 +2,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,7 +28,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
@@ -59,7 +58,6 @@ const oidcProviderSchema = z.object({
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -72,86 +70,16 @@ const formDefaultValues = {
scopes: [...DEFAULT_SCOPES],
};
function parseOidcConfig(oidcConfig: string | null): {
clientId?: string;
clientSecret?: string;
scopes?: string[];
} | null {
if (!oidcConfig) return null;
try {
const parsed = JSON.parse(oidcConfig) as {
clientId?: string;
clientSecret?: string;
scopes?: string[];
};
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
};
} catch {
return null;
}
}
export function RegisterOidcDialog({
providerId,
children,
}: RegisterOidcDialogProps) {
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const baseURL = useUrl();
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const oidc = parseOidcConfig(data.oidcConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
clientId: oidc?.clientId ?? "",
clientSecret: oidc?.clientSecret ?? "",
scopes:
oidc?.scopes && oidc.scopes.length > 0
? oidc.scopes
: [...DEFAULT_SCOPES],
});
}, [data, open, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
@@ -202,11 +130,7 @@ export function RegisterOidcDialog({
},
});
toast.success(
isEdit
? "OIDC provider updated successfully"
: "OIDC provider registered successfully",
);
toast.success("OIDC provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -222,13 +146,11 @@ export function RegisterOidcDialog({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
</DialogTitle>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogDescription>
{isEdit
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -240,28 +162,11 @@ export function RegisterOidcDialog({
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input
placeholder="e.g. okta or my-idp"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
<Input placeholder="e.g. okta or my-idp" {...field} />
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
{isEdit && " Cannot be changed when editing."}
</FormDescription>
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -436,7 +341,7 @@ export function RegisterOidcDialog({
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
{isEdit ? "Update provider" : "Register provider"}
Register provider
</Button>
</DialogFooter>
</form>

View File

@@ -3,12 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
type FieldArrayPath,
useFieldArray,
useForm,
useWatch,
} from "react-hook-form";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -33,7 +28,6 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const domainsArraySchema = z
.array(z.string().trim())
@@ -64,7 +58,6 @@ const samlProviderSchema = z.object({
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -77,83 +70,24 @@ const formDefaultValues: SamlProviderForm = {
idpMetadataXml: "",
};
function parseSamlConfig(samlConfig: string | null): {
entryPoint?: string;
cert?: string;
idpMetadataXml?: string;
} | null {
if (!samlConfig) return null;
try {
const parsed = JSON.parse(samlConfig) as {
entryPoint?: string;
cert?: string;
idpMetadata?: { metadata?: string };
};
return {
entryPoint: parsed.entryPoint,
cert: parsed.cert,
idpMetadataXml: parsed.idpMetadata?.metadata,
};
} catch {
return null;
}
}
export function RegisterSamlDialog({
providerId,
children,
}: RegisterSamlDialogProps) {
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const [baseURL, setBaseURL] = useState("");
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const baseURL = useUrl();
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const saml = parseSamlConfig(data.samlConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
entryPoint: saml?.entryPoint ?? "",
cert: saml?.cert ?? "",
idpMetadataXml: saml?.idpMetadataXml ?? "",
});
}, [data, open, form]);
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -199,11 +133,7 @@ export function RegisterSamlDialog({
},
});
toast.success(
isEdit
? "SAML provider updated successfully"
: "SAML provider registered successfully",
);
toast.success("SAML provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -219,13 +149,10 @@ export function RegisterSamlDialog({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? "Update SAML provider" : "Register SAML provider"}
</DialogTitle>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogDescription>
{isEdit
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -240,26 +167,8 @@ export function RegisterSamlDialog({
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
{isEdit && (
<FormDescription>
Cannot be changed when editing.
</FormDescription>
)}
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/saml2/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -408,7 +317,7 @@ export function RegisterSamlDialog({
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
{isEdit ? "Update provider" : "Register provider"}
Register provider
</Button>
</DialogFooter>
</form>

View File

@@ -9,7 +9,7 @@ import {
Shield,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +31,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
@@ -77,12 +76,18 @@ export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const baseURL = useUrl();
const [baseURL, setBaseURL] = useState("");
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const [newOriginInput, setNewOriginInput] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: userData } = api.user.get.useQuery(undefined, {
enabled: manageOriginsOpen,
@@ -266,22 +271,6 @@ export const SSOSettings = () => {
<Eye className="mr-1 size-3" />
View details
</Button>
{isOidc && (
<RegisterOidcDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterOidcDialog>
)}
{isSaml && (
<RegisterSamlDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterSamlDialog>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -361,7 +350,8 @@ export const SSOSettings = () => {
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
Use Edit to change provider settings (OIDC or SAML).
View-only. To change settings, remove this provider and add it
again with the new values.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">

View File

@@ -1 +0,0 @@
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

View File

@@ -1,8 +0,0 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'teams';--> statement-breakpoint
CREATE TABLE "teams" (
"teamsId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "teamsId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_teamsId_teams_teamsId_fk" FOREIGN KEY ("teamsId") REFERENCES "public"."teams"("teamsId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1002,20 +1002,6 @@
"when": 1770615019498,
"tag": "0142_outstanding_tusk",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1771297084611,
"tag": "0144_odd_gunslinger",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,29 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = {
english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" },
persian: { code: "fa", name: "فارسی" },
dutch: { code: "nl", name: "Nederlands" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
kazakh: { code: "kz", name: "Қазақ" },
norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" },
malayalam: { code: "ml", name: "മലയാളം" },
};
export type Language = keyof typeof Languages;
export type LanguageCode = (typeof Languages)[keyof typeof Languages]["code"];

View File

@@ -10,6 +10,15 @@ const nextConfig = {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
async headers() {
return [
{

View File

@@ -41,13 +41,13 @@
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
@@ -95,8 +95,8 @@
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -113,6 +113,7 @@
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
@@ -120,6 +121,7 @@
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -137,6 +139,7 @@
"react-day-picker": "8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"slugify": "^1.6.6",
@@ -144,7 +147,7 @@
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.31.1",
"swagger-ui-react": "^5.22.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
@@ -153,7 +156,7 @@
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.76",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},

View File

@@ -4,11 +4,13 @@ import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
const inter = Inter({ subsets: ["latin"] });
@@ -56,4 +58,14 @@ const MyApp = ({
);
};
export default api.withTRPC(MyApp);
export default api.withTRPC(
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
}),
);

View File

@@ -6,6 +6,7 @@ import superjson from "superjson";
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -25,6 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -53,6 +55,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -7,6 +7,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -34,6 +35,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -68,6 +70,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -9,6 +9,7 @@ import { ProfileForm } from "@/components/dashboard/settings/profile/profile-for
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data } = api.user.get.useQuery();
@@ -36,6 +37,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = getLocale(req.cookies);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
@@ -65,6 +67,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -10,6 +10,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data: user } = api.user.get.useQuery();
@@ -41,6 +42,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
@@ -83,6 +85,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -6,6 +6,7 @@ import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -24,6 +25,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req);
if (!user) {
return {
@@ -59,6 +61,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -8,6 +8,7 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -42,6 +43,7 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -76,6 +78,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -22,12 +22,12 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";

View File

@@ -8,7 +8,6 @@ import {
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTeamsNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
@@ -24,7 +23,6 @@ import {
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
@@ -35,7 +33,6 @@ import {
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTeamsNotification,
updateTelegramNotification,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -58,7 +55,6 @@ import {
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTeams,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
@@ -70,7 +66,6 @@ import {
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTeamsConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
@@ -81,7 +76,6 @@ import {
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTeams,
apiUpdateTelegram,
notifications,
server,
@@ -419,7 +413,6 @@ export const notificationRouter = createTRPCRouter({
custom: true,
lark: true,
pushover: true,
teams: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -712,61 +705,6 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createTeams: adminProcedure
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamsNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateTeams: adminProcedure
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateTeamsNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testTeamsConnection: adminProcedure
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
await sendTeamsNotification(input, {
title: "🤚 Test Notification",
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createPushover: adminProcedure
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {

View File

@@ -55,128 +55,9 @@ export const ssoRouter = createTRPCRouter({
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to access it",
});
}
return provider;
}),
update: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const existing = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
},
});
if (!existing) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to update it",
});
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
});
for (const provider of providers) {
if (provider.providerId === input.providerId) continue;
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const issuerChanged =
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const trustedOrigins = currentUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
);
if (!isInTrustedOrigins) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.",
});
}
}
const domain = input.domains.join(",");
const updateBody: {
issuer: string;
domain: string;
oidcConfig?: (typeof input)["oidcConfig"];
samlConfig?: (typeof input)["samlConfig"];
} = {
issuer: input.issuer,
domain,
};
if (input.oidcConfig != null) {
updateBody.oidcConfig = input.oidcConfig;
}
if (input.samlConfig != null) {
updateBody.samlConfig = input.samlConfig;
}
await auth.updateSSOProvider({
params: { providerId: input.providerId },
body: updateBody,
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
@@ -221,6 +102,24 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -248,6 +147,25 @@ export const ssoRouter = createTRPCRouter({
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {

View File

@@ -1,8 +1,9 @@
import { exit } from "node:process";
import { exec } from "node:child_process";
import { exit } from "node:process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { setupDirectories } from "@dokploy/server/setup/config-paths";
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
import { initializeRedis } from "@dokploy/server/setup/redis-setup";

View File

@@ -39,7 +39,8 @@
"**/*.js",
".next/types/**/*.ts",
"env.js",
"next.config.mjs"
"next.config.mjs",
"next-i18next.config.mjs"
],
"exclude": [
"node_modules",

View File

@@ -0,0 +1,16 @@
import Cookies from "js-cookie";
import type { LanguageCode } from "@/lib/languages";
export default function useLocale() {
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as LanguageCode;
const setLocale = (locale: LanguageCode) => {
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
window.location.reload();
};
return {
locale: currentLocale,
setLocale,
};
}

View File

@@ -0,0 +1,23 @@
import type { NextApiRequestCookies } from "next/dist/server/api-utils";
export function getLocale(cookies: NextApiRequestCookies) {
const locale = cookies.DOKPLOY_LOCALE ?? "en";
return locale;
}
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
import { Languages } from "@/lib/languages";
export const serverSideTranslations = (
locale: string,
namespaces = ["common"],
) =>
originalServerSideTranslations(locale, namespaces, {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
});

View File

@@ -20,7 +20,7 @@
"pino-pretty": "11.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "^3.25.76"
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -43,10 +43,5 @@
"resolutions": {
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2"
}
}
}

View File

@@ -1,27 +0,0 @@
# Debug build OOM orden para probar
Ejecuta desde `packages/server` (o `pnpm --filter=@dokploy/server run <script>` desde la raíz).
1. **`pnpm run build:debug:noEmit`**
Solo typecheck, no escribe archivos.
- Si hace **OOM** → el problema es el análisis de tipos (ej. zod u otras libs).
- Si **pasa** → el problema está en emit (JS o `.d.ts`).
2. **`pnpm run build:debug:noEmit:8gb`**
Mismo que el anterior pero con 8GB de heap.
- Si con 8GB **pasa** y sin 8GB **no** → el typecheck necesita más memoria.
3. **`pnpm run build:debug:noDecl`**
Compila solo JS (sin `declaration`).
- Si hace **OOM** → el problema es emitir JS.
- Si **pasa** → el problema es generar `.d.ts`.
4. **`pnpm run build:debug:declOnly`**
Solo genera declaraciones (`.d.ts`).
- Si hace **OOM** → el cuello de botella son las declaraciones.
5. **`pnpm run build:debug:full`**
Build completo con `--extendedDiagnostics` (imprime estadísticas al final).
- Para ver en qué paso se va la memoria si no has localizado antes.
Con eso sabes si el OOM viene de: typecheck, emit JS o emit declarations, y puedes elegir fix (más memoria, esbuild para JS, o no emitir declarations).

View File

@@ -30,13 +30,13 @@
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@better-auth/utils": "0.3.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
@@ -44,11 +44,11 @@
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso": "1.4.18",
"@better-auth/sso":"1.4.18",
"@trpc/server": "^10.45.2",
"adm-zip": "^0.5.16",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -81,11 +81,11 @@
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"zod": "^3.25.76",
"zod": "^3.25.32",
"semver": "7.7.3"
},
"devDependencies": {
"@better-auth/cli": "1.4.18",
"@better-auth/cli": "1.4.18",
"@types/semver": "7.7.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
@@ -115,4 +115,4 @@
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}
}

View File

@@ -23,7 +23,6 @@ export const notificationType = pgEnum("notificationType", [
"pushover",
"custom",
"lark",
"teams",
]);
export const notifications = pgTable("notification", {
@@ -73,9 +72,6 @@ export const notifications = pgTable("notification", {
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
onDelete: "cascade",
}),
teamsId: text("teamsId").references(() => teams.teamsId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -183,14 +179,6 @@ export const pushover = pgTable("pushover", {
expire: integer("expire"),
});
export const teams = pgTable("teams", {
teamsId: text("teamsId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -232,10 +220,6 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.pushoverId],
references: [pushover.pushoverId],
}),
teams: one(teams, {
fields: [notifications.teamsId],
references: [teams.teamsId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -523,32 +507,6 @@ export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiCreateTeams = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().min(1),
})
.required();
export const apiUpdateTeams = apiCreateTeams.partial().extend({
notificationId: z.string().min(1),
teamsId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestTeamsConnection = apiCreateTeams.pick({
webhookUrl: true,
});
export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { pgTable, text } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@@ -15,7 +15,6 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({

View File

@@ -18,7 +18,9 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
const query = await db.query.ssoProvider.findMany();
const trustedProviders = query.map((provider) => provider.providerId);
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -347,7 +349,6 @@ export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {

View File

@@ -2,31 +2,13 @@ import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
import { generateObject } from "ai";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
interface SuggestionItem {
id: string;
name: string;
shortDescription: string;
description: string;
}
interface SuggestionsOutput {
suggestions: SuggestionItem[];
}
interface DockerOutput {
dockerCompose: string;
envVariables: Array<{ name: string; value: string }>;
domains: Array<{ host: string; port: number; serviceName: string }>;
configFiles?: Array<{ content: string; filePath: string }>;
}
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
where: eq(ai.organizationId, organizationId),
@@ -78,7 +60,7 @@ interface Props {
}
export const suggestVariants = async ({
organizationId: _organizationId,
organizationId,
aiId,
input,
serverId,
@@ -108,177 +90,173 @@ export const suggestVariants = async ({
ip = "127.0.0.1";
}
const suggestionsSchema = z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
});
const suggestionsResult = await generateText({
const { object } = await generateObject({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: suggestionsSchema }),
output: "object",
schema: z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
}),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
});
const object = suggestionsResult.output as SuggestionsOutput | undefined;
if (object?.suggestions?.length) {
const dockerSchema = z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
});
const result = [];
for (const suggestion of object.suggestions) {
try {
const dockerResult = await generateText({
const { object: docker } = await generateObject({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: dockerSchema }),
output: "object",
schema: z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
}),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Note: configFiles is optional - only include it if configuration files are absolutely required.
Follow these rules:
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Note: configFiles is optional - only include it if configuration files are absolutely required.
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Follow these rules:
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
});
const docker = dockerResult.output as DockerOutput | undefined;
if (docker?.dockerCompose) {
if (!!docker && !!docker.dockerCompose) {
result.push({
...suggestion,
...docker,

View File

@@ -395,14 +395,16 @@ export const removeCompose = async (
if (compose.composeType === "stack") {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
docker stack rm ${compose.appName};
rm -rf ${projectPath}`;
cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
}
await execAsync(command, {
cwd: projectPath,
});
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;

View File

@@ -101,20 +101,6 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
return projectEnvironments;
};
const environmentHasServices = (
env: Awaited<ReturnType<typeof findEnvironmentById>>,
) => {
return (
(env.applications?.length ?? 0) > 0 ||
(env.compose?.length ?? 0) > 0 ||
(env.mariadb?.length ?? 0) > 0 ||
(env.mongo?.length ?? 0) > 0 ||
(env.mysql?.length ?? 0) > 0 ||
(env.postgres?.length ?? 0) > 0 ||
(env.redis?.length ?? 0) > 0
);
};
export const deleteEnvironment = async (environmentId: string) => {
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault) {
@@ -123,13 +109,6 @@ export const deleteEnvironment = async (environmentId: string) => {
message: "You cannot delete the default environment",
});
}
if (environmentHasServices(currentEnvironment)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot delete environment: it has active services. Delete all services first.",
});
}
const deletedEnvironment = await db
.delete(environments)
.where(eq(environments.environmentId, environmentId))

View File

@@ -9,7 +9,6 @@ import {
type apiCreatePushover,
type apiCreateResend,
type apiCreateSlack,
type apiCreateTeams,
type apiCreateTelegram,
type apiUpdateCustom,
type apiUpdateDiscord,
@@ -20,7 +19,6 @@ import {
type apiUpdatePushover,
type apiUpdateResend,
type apiUpdateSlack,
type apiUpdateTeams,
type apiUpdateTelegram,
custom,
discord,
@@ -32,7 +30,6 @@ import {
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -799,7 +796,6 @@ export const findNotificationById = async (notificationId: string) => {
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
if (!notification) {
@@ -909,96 +905,6 @@ export const updateLarkNotification = async (
});
};
export const createTeamsNotification = async (
input: typeof apiCreateTeams._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(teams)
.values({
webhookUrl: input.webhookUrl,
})
.returning()
.then((value) => value[0]);
if (!newTeams) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting teams",
});
}
const newDestination = await tx
.insert(notifications)
.values({
teamsId: newTeams.teamsId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "teams",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateTeamsNotification = async (
input: typeof apiUpdateTeams._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(teams)
.set({
webhookUrl: input.webhookUrl,
})
.where(eq(teams.teamsId, input.teamsId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const updateNotificationById = async (
notificationId: string,
notificationData: Partial<Notification>,

View File

@@ -88,7 +88,7 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
command = `compose -p ${appName} -f ${path} up -d --build --pull always --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
}

View File

@@ -14,7 +14,6 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -53,7 +52,6 @@ export const sendBuildErrorNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -69,7 +67,6 @@ export const sendBuildErrorNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -385,26 +382,6 @@ export const sendBuildErrorNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}\nError: ${errorMessage}`,
);
}
if (teams) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
await sendTeamsNotification(teams, {
title: "⚠️ Build Failed",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Error Message", value: truncatedErrorMessage },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -15,7 +15,6 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -56,7 +55,6 @@ export const sendBuildSuccessNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -72,7 +70,6 @@ export const sendBuildSuccessNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -399,24 +396,6 @@ export const sendBuildSuccessNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nEnvironment: ${environmentName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Build Success",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Environment", value: environmentName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,7 +14,6 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -53,7 +52,6 @@ export const sendDatabaseBackupNotifications = async ({
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -69,7 +67,6 @@ export const sendDatabaseBackupNotifications = async ({
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -413,30 +410,6 @@ export const sendDatabaseBackupNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nDatabase: ${databaseType}\nDatabase Name: ${databaseName}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Database Type", value: databaseType },
{ name: "Database Name", value: databaseName },
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
];
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Database Backup Successful"
: "❌ Database Backup Failed",
facts,
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,7 +14,6 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -40,7 +39,6 @@ export const sendDockerCleanupNotifications = async (
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -56,7 +54,6 @@ export const sendDockerCleanupNotifications = async (
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
@@ -265,16 +262,6 @@ export const sendDockerCleanupNotifications = async (
`Date: ${date.toLocaleString()}\nMessage: ${message}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Docker Cleanup",
facts: [
{ name: "Date", value: format(date, "PP pp") },
{ name: "Message", value: message },
],
});
}
} catch (error) {
console.log(error);
}

View File

@@ -14,7 +14,6 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -34,7 +33,6 @@ export const sendDokployRestartNotifications = async () => {
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -50,7 +48,6 @@ export const sendDokployRestartNotifications = async () => {
custom,
lark,
pushover,
teams,
} = notification;
try {
@@ -254,16 +251,6 @@ export const sendDokployRestartNotifications = async () => {
`Date: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Dokploy Server Restarted",
facts: [
{ name: "Status", value: "Successful" },
{ name: "Restart Time", value: format(date, "PP pp") },
],
});
}
} catch (error) {
console.log(error);
}

View File

@@ -7,7 +7,6 @@ import {
sendLarkNotification,
sendPushoverNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -41,7 +40,6 @@ export const sendServerThresholdNotifications = async (
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -49,8 +47,7 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) {
const { discord, telegram, slack, custom, lark, pushover, teams } =
notification;
const { discord, telegram, slack, custom, lark, pushover } = notification;
if (discord) {
const decorate = (decoration: string, text: string) =>
@@ -279,19 +276,5 @@ export const sendServerThresholdNotifications = async (
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{ name: "Current Value", value: `${payload.Value.toFixed(2)}%` },
{ name: "Threshold", value: `${payload.Threshold.toFixed(2)}%` },
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
}
};

View File

@@ -8,7 +8,6 @@ import type {
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import nodemailer from "nodemailer";
@@ -254,84 +253,6 @@ export const sendLarkNotification = async (
}
};
export interface TeamsAdaptiveCardMessage {
title: string;
themeColor?: string;
facts?: { name: string; value: string }[];
potentialAction?: { type: "Action.OpenUrl"; title: string; url: string };
}
export const sendTeamsNotification = async (
connection: typeof teams.$inferInsert,
message: TeamsAdaptiveCardMessage,
) => {
try {
const bodyElements: Record<string, unknown>[] = [
{
type: "TextBlock",
text: message.title,
size: "Medium",
weight: "Bolder",
wrap: true,
},
];
if (message.facts && message.facts.length > 0) {
bodyElements.push({
type: "FactSet",
facts: message.facts.map((f) => ({
title: f.name,
value: f.value,
})),
});
}
const cardContent: Record<string, unknown> = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: bodyElements,
};
if (message.potentialAction) {
cardContent.actions = [
{
type: "Action.OpenUrl",
title: message.potentialAction.title,
url: message.potentialAction.url,
},
];
}
const payload = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: cardContent,
},
],
};
const response = await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`Failed to send Teams notification: ${response.statusText}`,
);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send Teams notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
export const sendPushoverNotification = async (
connection: typeof pushover.$inferInsert,
title: string,

View File

@@ -12,7 +12,6 @@ import {
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -58,22 +57,12 @@ export const sendVolumeBackupNotifications = async ({
gotify: true,
ntfy: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
pushover,
teams,
} = notification;
const { email, resend, discord, telegram, slack, gotify, ntfy, pushover } =
notification;
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
@@ -299,29 +288,5 @@ export const sendVolumeBackupNotifications = async ({
`Project: ${projectName}\nApplication: ${applicationName}\nVolume: ${volumeName}\nService Type: ${serviceType}${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Volume Name", value: volumeName },
{ name: "Service Type", value: serviceType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Status", value: type === "success" ? "Successful" : "Failed" },
];
if (backupSize) {
facts.push({ name: "Backup Size", value: backupSize });
}
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
facts,
});
}
}
};

View File

@@ -34,7 +34,6 @@
"dokploy",
"config",
"dist",
".next",
"webpack.config.server.js",
"migration.ts",
"setup.ts"

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.server.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false
}
}

5316
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff