Compare commits

..

41 Commits

Author SHA1 Message Date
Mauricio Siu
b95dfed8fc chore(package): bump version to v0.24.4 2025-07-20 20:06:47 -06:00
Mauricio Siu
7fe3418d55 Merge pull request #2218 from Dokploy/2179-reloading-traefik-on-the-remote-server-will-cause-traefik-on-the-instance-to-change-accordingly
fix(traefik): remove duplicate file write operation in writeTraefikCo…
2025-07-20 20:05:48 -06:00
Mauricio Siu
288d86c73b fix(traefik): remove duplicate file write operation in writeTraefikConfigInPath function 2025-07-20 20:05:30 -06:00
Mauricio Siu
ffd5ccd386 Merge pull request #2202 from gentslava/feat/traefik-config
feat(config): Traefik
2025-07-20 19:45:53 -06:00
Mauricio Siu
98ddd096e5 Update packages/server/src/setup/traefik-setup.ts 2025-07-20 19:45:41 -06:00
Mauricio Siu
da6cc9fe72 Merge pull request #2190 from Marukome0743/format
chore: version up format.yml actions
2025-07-20 19:44:20 -06:00
Mauricio Siu
22d0af269e Merge pull request #2200 from Marukome0743/server
refactor: lint and sort imports on dokploy/server
2025-07-20 19:42:15 -06:00
Mauricio Siu
f0fdc46de5 Merge pull request #2187 from Marukome0743/v2
chore: upgrade to Biome v2
2025-07-20 19:41:49 -06:00
Mauricio Siu
9aea24115d Merge pull request #2199 from Marukome0743/lint
refactor: lint and sort import on dokploy application
2025-07-20 19:41:02 -06:00
Mauricio Siu
a9ee6c2393 Merge pull request #2194 from Marukome0743/pnpm
chore(package): version up pnpm to v9.12.0
2025-07-20 19:40:09 -06:00
Mauricio Siu
349717044c Merge pull request #2196 from Marukome0743/dispatch
ci: remove custom branch and add workflow_dispatch event
2025-07-20 19:37:27 -06:00
Mauricio Siu
f94f32695f Merge pull request #2195 from Marukome0743/monitoring
chore: remove `apps/monitoring` from `pnpm-workspace.yaml`
2025-07-20 19:37:07 -06:00
Mauricio Siu
37b78ea09c Merge pull request #2217 from Dokploy/2201-daily-docker-cleanup-not-working-on-remote-server
fix(dashboard): update Docker cleanup toggle logic to prioritize serv…
2025-07-20 19:01:46 -06:00
Mauricio Siu
9b89b4631f fix(dashboard): update Docker cleanup toggle logic to prioritize server settings 2025-07-20 19:01:20 -06:00
Mauricio Siu
7100095f2b Merge pull request #2216 from Dokploy/2209-update-s3-destination-form-loses-its-state-when-current-tab-loses-its-focus
fix(dashboard): disable refetch on window focus for destination handling
2025-07-20 18:57:33 -06:00
Mauricio Siu
a36ab65aa6 fix(dashboard): disable refetch on window focus for destination handling 2025-07-20 18:56:35 -06:00
Mauricio Siu
bf81ba20ff Merge pull request #2215 from Dokploy/2197-git-provider-api-undefined_value-error
refactor(auth): simplify user session structure in validateRequest fu…
2025-07-20 18:55:16 -06:00
Mauricio Siu
658a4a9b99 refactor(auth): simplify user session structure in validateRequest function
- Changed user object in mockSession to only include userId, removing email and name for a more streamlined session representation.
2025-07-20 18:54:57 -06:00
Mauricio Siu
47cb096cf3 Merge pull request #2214 from Dokploy/2203-identical-webhook-redeploy-url-after-duplicating-project
feat(project): add refreshToken to application and compose data retri…
2025-07-20 18:45:39 -06:00
Mauricio Siu
f3856722da feat(project): add refreshToken to application and compose data retrieval
- Included refreshToken in the data returned from findApplicationById and findComposeById functions to enhance application state management.
2025-07-20 18:45:18 -06:00
Vyacheslav Scherbinin
a67c3eb979 feat(conf): accessLog filePath 2025-07-16 16:46:47 +07:00
Vyacheslav Scherbinin
aaa205f104 feat(conf): disable sendAnonymousUsage 2025-07-16 12:29:31 +07:00
Marukome0743
cadea7ff28 refactor: lint and sort imports on dokploy/server 2025-07-15 14:22:37 +09:00
Marukome0743
9ab937f726 refactor: lint dokploy application 2025-07-15 14:13:32 +09:00
Marukome0743
d0af517eb7 ci: remove custom branch and add workflow_dispatch event 2025-07-14 19:03:41 +09:00
Marukome0743
66bdf9bf0a chore: remove apps/monitoring from pnpm-workspace.yaml 2025-07-14 18:24:26 +09:00
Marukome0743
d4a3af475a chore(package): version up pnpm to v9.12.0 2025-07-14 15:58:20 +09:00
autofix-ci[bot]
e92a8d7c98 [autofix.ci] apply automated fixes 2025-07-14 15:30:24 +09:00
Marukome0743
c4fec8cee5 chore: upgrade to Biome v2 2025-07-14 15:30:23 +09:00
Marukome0743
55f75bce53 chore: version up format.yml actions 2025-07-14 15:30:06 +09:00
Mauricio Siu
fdc524d79d fix(ui): adjust layout in UpdateServer component
- Removed unnecessary padding from DialogContent for a cleaner appearance.
- Added margin-top to the button container for improved spacing.
2025-07-13 23:37:05 -06:00
Mauricio Siu
93d6662466 docs(preview): update collaborator permission description in preview settings 2025-07-13 23:26:41 -06:00
Mauricio Siu
1977235d31 Merge pull request #2192 from Dokploy/fix/preview-deployments-public-repos
feat(preview): add collaborator permission requirement for preview de…
2025-07-13 23:20:51 -06:00
Mauricio Siu
1dd713a1d1 fix(deploy): change preview deployment limit check to be exclusive 2025-07-13 23:20:23 -06:00
Mauricio Siu
18b65f28f2 chore(package): bump version to v0.24.3 and comment out unused trustedOrigins function in auth.ts 2025-07-13 23:19:31 -06:00
Mauricio Siu
666db23b8e test: add previewRequireCollaboratorPermissions field to drop and traefik test cases 2025-07-13 23:17:32 -06:00
Mauricio Siu
2ca5321fdc feat(preview): add collaborator permission requirement for preview deployments
- Introduced a new boolean field `previewRequireCollaboratorPermissions` in the application schema to enforce permission checks for preview deployments.
- Updated the UI to include a toggle for this setting in the preview deployment settings.
- Enhanced GitHub deployment handler to validate PR authors against the required permissions, blocking unauthorized deployments and providing security notifications.
- Added SQL migration to update the database schema accordingly.
2025-07-13 23:12:09 -06:00
Mauricio Siu
3f3ff9670b chore(package): bump version to v0.24.2 2025-07-13 20:45:33 -06:00
Mauricio Siu
7fb902551e Merge pull request #2189 from jhon2c/fix/logs-overflow
fix(logs): Restore overflow classnames in logs components
2025-07-13 20:44:34 -06:00
Jhon
a201b3f979 fix(ui): regression of overflow-y-auto class in non dialog related componentes 2025-07-13 21:28:50 -03:00
Jhon
01d78e50fc fix(logs): adds back overflow classnames 2025-07-13 21:09:12 -03:00
154 changed files with 2108 additions and 2883 deletions

View File

@@ -2,7 +2,8 @@ name: Build Docker images
on:
push:
branches: ["canary", "main", "feat/monitoring"]
branches: [main, canary]
workflow_dispatch:
jobs:
build-and-push-cloud-image:

View File

@@ -2,7 +2,8 @@ name: Dokploy Docker Build
on:
push:
branches: [main, canary, "1061-custom-docker-service-hostname"]
branches: [main, canary]
workflow_dispatch:
env:
IMAGE_NAME: dokploy/dokploy

View File

@@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format . --write
run: biome format --write
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2

View File

@@ -29,5 +29,9 @@
"tsx": "^4.16.2",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@9.5.0"
"packageManager": "pnpm@9.12.0",
"engines": {
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}

View File

@@ -29,6 +29,7 @@ const baseApp: ApplicationNested = {
herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
giteaOwner: "",
giteaRepository: "",

View File

@@ -5,8 +5,7 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig } from "@dokploy/server";
import type { WebServer } from "@dokploy/server/db/schema";
import type { FileConfig, User } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
@@ -14,8 +13,11 @@ import {
} from "@dokploy/server";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: WebServer = {
const baseAdmin: User = {
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
metricsConfig: {
containers: {
refreshRate: 20,
@@ -38,6 +40,10 @@ const baseAdmin: WebServer = {
urlCallback: "",
},
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
@@ -45,7 +51,22 @@ const baseAdmin: WebServer = {
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
webServerId: "1",
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -64,6 +85,8 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
"example.com",
);

View File

@@ -18,6 +18,7 @@ const baseApp: ApplicationNested = {
appName: "",
autoDeploy: true,
enableSubmodules: false,
previewRequireCollaboratorPermissions: false,
serverId: "",
branch: null,
dockerBuildStage: "",

View File

@@ -48,7 +48,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
</div>
) : (
<div className="flex flex-col pt-2 relative">
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem]">
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
<CodeEditor
lineWrapping
value={data || "Empty"}

View File

@@ -158,7 +158,7 @@ export const ShowDeployment = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{" "}
{filteredLogs.length > 0 ? (

View File

@@ -1,4 +1,5 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -10,14 +11,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { DialogAction } from "@/components/shared/dialog-action";
import { toast } from "sonner";
interface Props {
id: string;

View File

@@ -71,7 +71,7 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: webServer } = api.webServer.get.useQuery();
const { data: ip } = api.settings.getIp.useQuery();
const {
data,
@@ -110,9 +110,7 @@ export const ShowDomains = ({ id, type }: Props) => {
const result = await validateDomain({
domain: host,
serverIp:
application?.server?.ipAddress?.toString() ||
webServer?.serverIp?.toString() ||
"",
application?.server?.ipAddress?.toString() || ip?.toString() || "",
});
setValidationStates((prev) => ({
@@ -212,7 +210,7 @@ export const ShowDomains = ({ id, type }: Props) => {
}}
serverIp={
application?.server?.ipAddress?.toString() ||
webServer?.serverIp?.toString()
ip?.toString()
}
/>
)}

View File

@@ -46,6 +46,7 @@ const schema = z
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
previewCustomCertResolver: z.string().optional(),
previewRequireCollaboratorPermissions: z.boolean(),
})
.superRefine((input, ctx) => {
if (
@@ -83,6 +84,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: false,
previewPath: "/",
previewCertificateType: "none",
previewRequireCollaboratorPermissions: true,
},
resolver: zodResolver(schema),
});
@@ -105,6 +107,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
previewRequireCollaboratorPermissions:
data.previewRequireCollaboratorPermissions || true,
});
}
}, [data]);
@@ -121,6 +125,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver,
previewRequireCollaboratorPermissions:
formData.previewRequireCollaboratorPermissions,
})
.then(() => {
toast.success("Preview Deployments settings updated");
@@ -312,6 +318,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<FormField
control={form.control}
name="previewRequireCollaboratorPermissions"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm col-span-2">
<div className="space-y-0.5">
<FormLabel>
Require Collaborator Permissions
</FormLabel>
<FormDescription>
Require collaborator permissions to preview
deployments, valid roles are:
<ul>
<li>Admin</li>
<li>Maintain</li>
<li>Write</li>
</ul>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="env"

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,9 +43,8 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { formatBytes } from "../../database/backups/restore-backup";
import { AlertBlock } from "@/components/shared/alert-block";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
id: string;

View File

@@ -23,8 +23,8 @@ import {
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { HandleVolumeBackups } from "./handle-volume-backups";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
import { HandleVolumeBackups } from "./handle-volume-backups";
import { RestoreVolumeBackups } from "./restore-volume-backups";
interface Props {

View File

@@ -1,3 +1,4 @@
import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider";
import {
BitbucketIcon,
GitIcon,
@@ -11,6 +12,7 @@ import { api } from "@/utils/api";
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ComposeFileEditor } from "../compose-file-editor";
import { ShowConvertedCompose } from "../show-converted-compose";
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
@@ -18,8 +20,6 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider";
import { toast } from "sonner";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
interface Props {

View File

@@ -42,7 +42,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
See in detail the config of this container
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm bg-card max-h-[80vh]">
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap break-words">
<CodeEditor

View File

@@ -274,7 +274,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (

View File

@@ -138,7 +138,7 @@ export function LineCountFilter({
}}
/>
</div>
<CommandPrimitive.List className="max-h-[300px] overflow-x-hidden">
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto overflow-x-hidden">
<CommandPrimitive.Group className="px-2 py-1.5">
{lineCountOptions.map((option) => {
const isSelected = value === option.value;

View File

@@ -46,11 +46,11 @@ interface Props {
mariadbId: string;
}
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data: webServer } = api.webServer.get.useQuery();
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),

View File

@@ -46,11 +46,11 @@ interface Props {
mongoId: string;
}
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const { data: webServer } = api.webServer.get.useQuery();
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),

View File

@@ -46,11 +46,11 @@ interface Props {
mysqlId: string;
}
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data: webServer } = api.webServer.get.useQuery();
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),

View File

@@ -46,11 +46,11 @@ interface Props {
postgresId: string;
}
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
const { data: webServer } = api.webServer.get.useQuery();
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
const { mutateAsync, isLoading } =
api.postgres.saveExternalPort.useMutation();
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const getIp = data?.server?.ipAddress || ip;
const [connectionUrl, setConnectionUrl] = useState("");
const form = useForm<DockerProvider>({

View File

@@ -167,7 +167,7 @@ export const DuplicateProject = ({
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] border rounded-md p-4">
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
{selectedServices.map((service) => (
<div key={service.id} className="flex items-center space-x-2">
<span className="text-sm">

View File

@@ -47,12 +47,11 @@ import { useMemo, useState } from "react";
import { toast } from "sonner";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { Permissions } from "../shared/Permissions";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState("");
@@ -84,11 +83,11 @@ export const ShowProjects = () => {
</CardDescription>
</CardHeader>
<Permissions permissions={[PERMISSIONS.PROJECT.CREATE.name]}>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
</Permissions>
)}
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
@@ -158,7 +157,7 @@ export const ShowProjects = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2 max-h-[400px]"
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}
>
{project.applications.length > 0 && (
@@ -266,7 +265,7 @@ export const ShowProjects = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2 max-h-[280px]"
className="w-[200px] space-y-2 overflow-y-auto max-h-[280px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuLabel className="font-normal">
@@ -290,11 +289,8 @@ export const ShowProjects = () => {
<div
onClick={(e) => e.stopPropagation()}
>
<Permissions
permissions={[
PERMISSIONS.PROJECT.DELETE.name,
]}
>
{(auth?.role === "owner" ||
auth?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
@@ -360,7 +356,7 @@ export const ShowProjects = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Permissions>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -46,11 +46,11 @@ interface Props {
redisId: string;
}
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const { data: webServer } = api.webServer.get.useQuery();
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.redis.one.useQuery({ redisId });
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({
defaultValues: {},

View File

@@ -14,7 +14,7 @@ export const ShowWelcomeDokploy = () => {
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
if (!isCloud || data?.role?.name !== "admin") {
if (!isCloud || data?.role !== "admin") {
return null;
}
@@ -23,14 +23,14 @@ export const ShowWelcomeDokploy = () => {
!isLoading &&
isCloud &&
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
data?.role?.name === "owner"
data?.role === "owner"
) {
setOpen(true);
}
}, [isCloud, isLoading]);
const handleClose = (isOpen: boolean) => {
if (data?.role?.name === "owner") {
if (data?.role === "owner") {
setOpen(isOpen);
if (!isOpen) {
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal

View File

@@ -70,6 +70,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
},
{
enabled: !!destinationId,
refetchOnWindowFocus: false,
},
);
const {

View File

@@ -32,7 +32,6 @@ import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
name: z.string(),
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
@@ -80,7 +79,6 @@ export const ProfileForm = () => {
const form = useForm<Profile>({
defaultValues: {
name: data?.user?.name || "",
email: data?.user?.email || "",
password: "",
image: data?.user?.image || "",
@@ -94,7 +92,6 @@ export const ProfileForm = () => {
if (data) {
form.reset(
{
name: data?.user?.name || "",
email: data?.user?.email || "",
password: form.getValues("password") || "",
image: data?.user?.image || "",
@@ -117,7 +114,6 @@ export const ProfileForm = () => {
const onSubmit = async (values: Profile) => {
await mutateAsync({
name: values.name,
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
@@ -128,7 +124,6 @@ export const ProfileForm = () => {
await refetch();
toast.success("Profile Updated");
form.reset({
name: values.name,
email: values.email,
password: "",
image: values.image,
@@ -172,19 +167,6 @@ export const ProfileForm = () => {
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"

View File

@@ -7,7 +7,7 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
const { data, refetch } = api.webServer.get.useQuery(undefined, {
const { data, refetch } = api.user.get.useQuery(undefined, {
enabled: !serverId,
});
@@ -20,9 +20,11 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
},
);
const enabled = data?.enableDockerCleanup || server?.enableDockerCleanup;
const enabled = serverId
? server?.enableDockerCleanup
: data?.user.enableDockerCleanup;
const { mutateAsync } = api.webServer.updateDockerCleanup.useMutation();
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
const handleToggle = async (checked: boolean) => {
try {

View File

@@ -89,7 +89,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
: api.webServer.get.useQuery();
: api.user.getServerMetrics.useQuery();
const url = useUrl();

View File

@@ -147,7 +147,7 @@ export const SetupServer = ({ serverId }: Props) => {
<li>2. Add The SSH Key to Server Manually</li>
</ul>
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2">
<div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key ({server?.sshKey?.name})
<button

View File

@@ -117,7 +117,7 @@ export const CreateSSHKey = () => {
Option 2
</span>
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2">
<div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key
<button

View File

@@ -49,15 +49,12 @@ type AddInvitation = z.infer<typeof addInvitation>;
export const AddInvitation = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: roles } = api.role.all.useQuery();
const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const {
mutateAsync: createInvitation,
isLoading,
error,
} = api.user.createInvitation.useMutation();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization();
const form = useForm<AddInvitation>({
@@ -73,20 +70,36 @@ export const AddInvitation = () => {
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddInvitation) => {
await createInvitation({
setIsLoading(true);
const result = await authClient.organization.inviteMember({
email: data.email.toLowerCase(),
role: data.role,
organizationId: activeOrganization?.id || "",
notificationId: data.notificationId || "",
})
.then(() => {
organizationId: activeOrganization?.id,
});
if (result.error) {
setError(result.error.message || "");
} else {
if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result.data.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
})
.catch((error: any) => {
toast.error(error.message);
});
}
setError(null);
setOpen(false);
}
utils.organization.allInvitations.invalidate();
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -100,7 +113,7 @@ export const AddInvitation = () => {
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
{error && <AlertBlock type="error">{error}</AlertBlock>}
<Form {...form}>
<form
@@ -145,12 +158,6 @@ export const AddInvitation = () => {
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{roles?.map((role) => (
<SelectItem key={role.name} value={role.name}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>

View File

@@ -1,758 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { PenBoxIcon, Trash2 } from "lucide-react";
import { format } from "date-fns";
import { DialogAction } from "@/components/shared/dialog-action";
const assignRoleSchema = z.object({
roleId: z.string(),
accessedProjects: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
});
const createRoleSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
permissions: z.array(z.string()).min(1, "Select at least one permission"),
});
type AssignRoleForm = z.infer<typeof assignRoleSchema>;
type CreateRoleForm = z.infer<typeof createRoleSchema>;
interface Props {
userId: string;
}
export const AddUserPermissionsV2 = ({ userId }: Props) => {
const utils = api.useUtils();
const { data: projects } = api.project.all.useQuery();
const [activeTab, setActiveTab] = useState<"assign" | "create">("assign");
const [editingRole, setEditingRole] = useState<{
roleId: string;
name: string;
description?: string;
permissions: string[];
} | null>(null);
const { data: roles, refetch: refetchRoles } = api.role.all.useQuery();
const { data: defaultRoles } = api.role.getDefaultRoles.useQuery();
const { data: userData, refetch: refetchUser } = api.user.one.useQuery(
{
userId,
},
{
enabled: !!userId,
},
);
const { mutateAsync: createRole, isLoading: isCreatingRole } =
api.role.create.useMutation();
const { mutateAsync: updateRole, isLoading: isUpdatingRole } =
api.role.update.useMutation();
const { mutateAsync: deleteRole, isLoading: isDeletingRole } =
api.role.delete.useMutation();
const { mutateAsync: updateMemberRole, isLoading: isAssigningRole } =
api.user.assignRole.useMutation();
const assignForm = useForm<AssignRoleForm>({
resolver: zodResolver(assignRoleSchema),
defaultValues: {
accessedProjects: [],
accessedServices: [],
},
});
const createForm = useForm<CreateRoleForm>({
resolver: zodResolver(createRoleSchema),
defaultValues: {
permissions: [],
},
});
useEffect(() => {
if (userData) {
assignForm.reset({
roleId: userData.roleId || "",
accessedProjects: userData.accessedProjects || [],
accessedServices: userData.accessedServices || [],
});
}
}, [userData, assignForm]);
// Reset form when switching between create and edit modes
useEffect(() => {
if (editingRole) {
createForm.reset({
name: editingRole.name,
description: editingRole.description || "",
permissions: editingRole.permissions,
});
} else {
createForm.reset({
name: "",
description: "",
permissions: [],
});
}
}, [editingRole, createForm]);
// Check if the selected role is owner or admin (has full access)
const selectedRoleId = assignForm.watch("roleId");
const selectedRole = defaultRoles?.roles?.find(
(role) => role.roleId === selectedRoleId,
);
const isFullAccessRole =
selectedRole &&
(selectedRole.name === "owner" || selectedRole.name === "admin");
const onAssignRole = async (data: AssignRoleForm) => {
try {
await updateMemberRole({
userId,
roleId: data.roleId,
accessedProjects: isFullAccessRole ? [] : data.accessedProjects || [],
accessedServices: isFullAccessRole ? [] : data.accessedServices || [],
});
toast.success("Role assigned successfully");
await refetchUser();
await utils.user.all.invalidate();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to assign role";
toast.error(message);
}
};
const onCreateRole = async (data: CreateRoleForm) => {
try {
if (editingRole) {
// Update existing role
await updateRole({
roleId: editingRole.roleId,
...data,
permissions: data.permissions,
});
toast.success("Role updated successfully");
} else {
// Create new role
await createRole({
...data,
permissions: data.permissions,
});
toast.success("Role created successfully");
}
refetchRoles();
setActiveTab("assign");
setEditingRole(null);
createForm.reset();
} catch (error) {
const message =
error instanceof Error
? error.message
: editingRole
? "Failed to update role"
: "Failed to create role";
toast.error(message);
}
};
const onEditRole = (role: {
roleId: string;
name: string;
description?: string | null;
permissions: string[] | null;
}) => {
setEditingRole({
roleId: role.roleId,
name: role.name,
description: role.description || "",
permissions: role.permissions || [],
});
setActiveTab("create");
};
const cancelEdit = () => {
setEditingRole(null);
setActiveTab("assign");
createForm.reset();
};
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Manage Roles
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Role Management</DialogTitle>
<DialogDescription>
Assign existing roles or create new ones. The Owner role has full
access to all features.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "assign" | "create")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="assign">Assign Role</TabsTrigger>
<TabsTrigger value="create">
{editingRole ? "Edit Role" : "Create Role"}
</TabsTrigger>
</TabsList>
<TabsContent value="assign">
<Form {...assignForm}>
<form onSubmit={assignForm.handleSubmit(onAssignRole)}>
<div className="space-y-4 py-4">
<FormField
control={assignForm.control}
name="roleId"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Select Role</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="space-y-4"
>
<div className="space-y-4">
<h4 className="text-sm font-medium">
Default Roles
</h4>
{defaultRoles?.roles?.map((role) => {
const isOwner = role.name === "owner";
const isAdmin = role.name === "admin";
if (isOwner) {
return null;
}
return (
<FormItem
key={role.roleId}
className="flex items-center space-x-3 space-y-0"
>
<FormControl>
<RadioGroupItem
value={role.roleId || ""}
disabled={isOwner}
/>
</FormControl>
<FormLabel className="font-normal">
<div className="flex items-center gap-2">
<span className="font-medium capitalize">
{role.name}
</span>
{isAdmin && (
<Badge
variant="default"
className="text-xs"
>
Full Access
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
{role.description}
</div>
{!isOwner && (
<div className="flex flex-wrap gap-1 mt-1">
{role.permissions?.map(
(permission) => (
<Badge
key={permission.name}
variant={
isOwner
? "default"
: "secondary"
}
className="text-xs"
>
{permission.description}
</Badge>
),
)}
</div>
)}
</FormLabel>
</FormItem>
);
})}
</div>
<Separator />
{/* Custom Roles Section */}
{roles &&
roles.filter((r) => !r.isSystem).length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-medium">
Custom Roles
</h4>
{roles
?.filter((r) => !r.isSystem)
.map((role) => (
<FormItem
key={role.roleId}
className="flex items-center justify-between space-x-3 space-y-0"
>
<div className="flex items-center space-x-3">
<FormControl>
<RadioGroupItem
value={role.roleId}
/>
</FormControl>
<FormLabel className="font-normal">
<span className="font-medium">
{role.name}
</span>
<div className="text-xs text-muted-foreground">
{role.description}
</div>
<p className="text-xs text-muted-foreground">
{format(
role.createdAt,
"MMM d, yyyy",
)}
</p>
<div className="flex flex-wrap gap-1 mt-1">
{role.permissions?.map(
(permission) => {
const permissionInfo =
defaultRoles?.permissions?.find(
(p) =>
p.name === permission,
);
return (
<Badge
key={permission}
variant="secondary"
className="text-xs"
>
{
permissionInfo?.description
}
</Badge>
);
},
)}
</div>
</FormLabel>
</div>
<div className="flex space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => onEditRole(role)}
title="Edit role"
>
<PenBoxIcon className="h-4 w-4" />
</Button>
<DialogAction
title="Delete Role"
description="Are you sure you want to delete this role?"
type="destructive"
onClick={async () => {
await deleteRole({
roleId: role.roleId,
})
.then(() => {
refetchRoles();
toast.success(
"Role deleted successfully",
);
})
.catch((error) => {
const message =
error instanceof Error
? error.message
: "Error deleting role";
toast.error(message);
});
}}
>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
isLoading={isDeletingRole}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</FormItem>
))}
</div>
)}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
{/* Project Access Section - Only show if not full access role */}
{!isFullAccessRole && selectedRoleId && (
<>
<Separator />
<FormField
control={assignForm.control}
name="accessedProjects"
render={() => (
<FormItem className="space-y-4">
<div>
<FormLabel className="text-base">
Projects Access
</FormLabel>
<FormDescription>
Select the projects that the user can access
</FormDescription>
</div>
{projects?.length === 0 && (
<p className="text-sm text-muted-foreground">
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((project, index) => {
const services = extractServices(project);
return (
<FormField
key={`project-${index}`}
control={assignForm.control}
name="accessedProjects"
render={({ field }) => {
return (
<FormItem
key={project.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
<FormControl>
<Checkbox
checked={field.value?.includes(
project.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
project.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !==
project.projectId,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{project.name}
</FormLabel>
</div>
{services.length === 0 && (
<p className="text-sm text-muted-foreground ml-6">
No services found
</p>
)}
{services?.map(
(service, serviceIndex) => (
<FormField
key={`service-${serviceIndex}`}
control={assignForm.control}
name="accessedServices"
render={({ field }) => {
return (
<FormItem
key={service.id}
className="flex flex-row items-start space-x-3 space-y-0 ml-6"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
service.id,
)}
onCheckedChange={(
checked,
) => {
const currentProjects =
assignForm.getValues(
"accessedProjects",
) || [];
const currentServices =
field.value || [];
if (checked) {
// Add service
const newServices =
[
...currentServices,
service.id,
];
field.onChange(
newServices,
);
// Auto-select project if not already selected
if (
!currentProjects.includes(
project.projectId,
)
) {
assignForm.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove service
const newServices =
currentServices.filter(
(value) =>
value !==
service.id,
);
field.onChange(
newServices,
);
// Check if any other services from this project are still selected
const otherServicesFromProject =
services.filter(
(s) =>
s.id !==
service.id &&
newServices.includes(
s.id,
),
);
// If no other services from this project, unselect the project
if (
otherServicesFromProject.length ===
0
) {
assignForm.setValue(
"accessedProjects",
currentProjects.filter(
(p) =>
p !==
project.projectId,
),
);
}
}
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{service.name}
</FormLabel>
</FormItem>
);
}}
/>
),
)}
</FormItem>
);
}}
/>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
<DialogFooter>
<Button type="submit" disabled={isAssigningRole}>
{isAssigningRole ? "Assigning..." : "Save Role"}
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
{/* Create Role Tab Content */}
<TabsContent value="create">
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit(onCreateRole)}>
<div className="space-y-4 py-4">
<FormField
control={createForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Developer" {...field} />
</FormControl>
<FormDescription>
Role name must be unique
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="e.g. Role for development team members"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="permissions"
render={() => (
<FormItem>
<FormLabel>Permissions</FormLabel>
<Card className=" bg-transparent">
<CardHeader>
<CardTitle className="text-sm">
Available Permissions
</CardTitle>
<CardDescription>
Select the permissions for this role
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
{defaultRoles?.permissions?.map((permission) => (
<FormField
key={permission.name}
control={createForm.control}
name="permissions"
render={({ field }) => (
<FormItem
key={permission.name}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
permission.name,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
permission.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== permission.name,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{permission.description}
</FormLabel>
</FormItem>
)}
/>
))}
</CardContent>
</Card>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type="submit"
disabled={isCreatingRole || isUpdatingRole}
>
{isCreatingRole || isUpdatingRole
? "Saving..."
: "Save Role"}
</Button>
{editingRole && (
<Button
variant="outline"
onClick={cancelEdit}
disabled={isUpdatingRole}
>
Cancel
</Button>
)}
</DialogFooter>
</form>
</Form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,444 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
canDeleteServices: z.boolean().optional().default(false),
canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false),
canAccessToAPI: z.boolean().optional().default(false),
canAccessToSSHKeys: z.boolean().optional().default(false),
canAccessToGitProviders: z.boolean().optional().default(false),
});
type AddPermissions = z.infer<typeof addPermissions>;
interface Props {
userId: string;
}
export const AddUserPermissions = ({ userId }: Props) => {
const { data: projects } = api.project.all.useQuery();
const { data, refetch } = api.user.one.useQuery(
{
userId,
},
{
enabled: !!userId,
},
);
const { mutateAsync, isError, error, isLoading } =
api.user.assignPermissions.useMutation();
const form = useForm<AddPermissions>({
defaultValues: {
accessedProjects: [],
accessedServices: [],
},
resolver: zodResolver(addPermissions),
});
useEffect(() => {
if (data) {
form.reset({
accessedProjects: data.accessedProjects || [],
accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteServices: data.canDeleteServices,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
});
}
}, [form, form.formState.isSubmitSuccessful, form.reset, data]);
const onSubmit = async (data: AddPermissions) => {
await mutateAsync({
id: userId,
canCreateServices: data.canCreateServices,
canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [],
accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
})
.then(async () => {
toast.success("Permissions updated");
refetch();
})
.catch(() => {
toast.error("Error updating the permissions");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Add Permissions
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-[85vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Permissions</DialogTitle>
<DialogDescription>Add or remove permissions</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
<FormField
control={form.control}
name="canCreateProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Projects</FormLabel>
<FormDescription>
Allow the user to create projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Projects</FormLabel>
<FormDescription>
Allow the user to delete projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Services</FormLabel>
<FormDescription>
Allow the user to create services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Services</FormLabel>
<FormDescription>
Allow the user to delete services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Traefik Files</FormLabel>
<FormDescription>
Allow the user to access to the Traefik Tab Files
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToDocker"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Docker</FormLabel>
<FormDescription>
Allow the user to access to the Docker Tab
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToAPI"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to API/CLI</FormLabel>
<FormDescription>
Allow the user to access to the API/CLI
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToSSHKeys"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to SSH Keys</FormLabel>
<FormDescription>
Allow to users to access to the SSH Keys section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToGitProviders"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Git Providers</FormLabel>
<FormDescription>
Allow to users to access to the Git Providers section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessedProjects"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">Projects</FormLabel>
<FormDescription>
Select the Projects that the user can access
</FormDescription>
</div>
{projects?.length === 0 && (
<p className="text-sm text-muted-foreground">
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((item, index) => {
const applications = extractServices(item);
return (
<FormField
key={`project-${index}`}
control={form.control}
name="accessedProjects"
render={({ field }) => {
return (
<FormItem
key={item.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
<FormControl>
<Checkbox
checked={field.value?.includes(
item.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.projectId,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{item.name}
</FormLabel>
</div>
{applications.length === 0 && (
<p className="text-sm text-muted-foreground">
No services found
</p>
)}
{applications?.map((item, index) => (
<FormField
key={`project-${index}`}
control={form.control}
name="accessedServices"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
item.id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.id,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{item.name}
</FormLabel>
</FormItem>
);
}}
/>
))}
</FormItem>
);
}}
/>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
<Button
isLoading={isLoading}
form="hook-form-add-permissions"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -30,10 +30,9 @@ import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { AddUserPermissionsV2 } from "./add-permissions-v2";
import { AddUserPermissions } from "./add-permissions";
export const ShowUsers = () => {
const { data: user } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
@@ -85,22 +84,20 @@ export const ShowUsers = () => {
</TableHeader>
<TableBody>
{data?.map((member) => {
const isSameUser = member.user.id === user?.user.id;
return (
<TableRow key={member.id}>
<TableCell className="w-[250px]">
{member.user.email} {isSameUser && "(You)"}
<TableCell className="w-[100px]">
{member.user.email}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
member?.role?.name === "owner"
member.role === "owner"
? "default"
: "secondary"
}
>
{member?.role?.name}
{member.role}
</Badge>
</TableCell>
<TableCell className="text-center">
@@ -115,77 +112,35 @@ export const ShowUsers = () => {
</TableCell>
<TableCell className="text-right flex justify-end">
{member.role !== "owner" && !isSameUser && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{member.role !== "owner" && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{member.role !== "owner" && (
<>
<AddUserPermissionsV2
userId={member.user.id}
/>
</>
{!isCloud && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete User
</DropdownMenuItem>
</DialogAction>
)}
<DialogAction
title="Unlink User"
description="Are you sure you want to unlink this user?"
type="destructive"
onClick={async () => {
if (!isCloud) {
const orgCount =
await utils.user.checkUserOrganizations.fetch(
{
userId: member.user.id,
},
);
console.log(orgCount);
if (orgCount === 1) {
{!isCloud && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
userId: member.user.id,
})
@@ -197,40 +152,86 @@ export const ShowUsers = () => {
})
.catch(() => {
toast.error(
"Error deleting user",
"Error deleting destination",
);
});
return;
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) =>
e.preventDefault()
}
>
Delete User
</DropdownMenuItem>
</DialogAction>
)}
<DialogAction
title="Unlink User"
description="Are you sure you want to unlink this user?"
type="destructive"
onClick={async () => {
if (!isCloud) {
const orgCount =
await utils.user.checkUserOrganizations.fetch(
{
userId: member.user.id,
},
);
console.log(orgCount);
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting user",
);
});
return;
}
}
}
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
if (!error) {
toast.success(
"User unlinked successfully",
);
refetch();
} else {
toast.error("Error unlinking user");
}
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
if (!error) {
toast.success(
"User unlinked successfully",
);
refetch();
} else {
toast.error(
"Error unlinking user",
);
}
}}
>
Unlink User
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Unlink User
</DropdownMenuItem>
</DialogAction>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);

View File

@@ -62,9 +62,9 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.webServer.get.useQuery();
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync, isLoading } =
api.webServer.assignDomainServer.useMutation();
api.settings.assignDomainServer.useMutation();
const form = useForm<AddServerDomain>({
defaultValues: {
@@ -79,10 +79,10 @@ export const WebDomain = () => {
useEffect(() => {
if (data) {
form.reset({
domain: data?.host || "",
certificateType: data?.certificateType,
letsEncryptEmail: data?.letsEncryptEmail || "",
https: data?.https || false,
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
});
}
}, [form, form.reset, data]);

View File

@@ -16,12 +16,13 @@ import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data } = api.webServer.get.useQuery();
const { data } = api.user.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
@@ -33,6 +34,14 @@ export const WebServer = () => {
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">
<div className="grid md:grid-cols-2 gap-4">
<ShowDokployActions />
@@ -44,7 +53,7 @@ export const WebServer = () => {
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.serverIp}
Server IP: {data?.user.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}

View File

@@ -46,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data } = api.webServer.get.useQuery();
const { data } = api.user.get.useQuery();
const { data: ip } = api.server.publicIp.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.webServer.update.useMutation();
api.user.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
serverIp: data?.serverIp || "",
serverIp: data?.user.serverIp || "",
},
resolver: zodResolver(schema),
});
@@ -62,7 +62,7 @@ export const UpdateServerIp = ({ children }: Props) => {
useEffect(() => {
if (data) {
form.reset({
serverIp: data.serverIp || "",
serverIp: data.user.serverIp || "",
});
}
}, [form, form.reset, data]);
@@ -80,7 +80,7 @@ export const UpdateServerIp = ({ children }: Props) => {
})
.then(async () => {
toast.success("Server IP Updated");
await utils.webServer.get.invalidate();
await utils.user.get.invalidate();
setIsOpen(false);
})
.catch(() => {

View File

@@ -126,7 +126,7 @@ export const UpdateServer = ({
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="max-w-lg p-6">
<DialogContent className="max-w-lg">
<div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold">
Web Server Update
@@ -253,7 +253,7 @@ export const UpdateServer = ({
<ToggleAutoCheckUpdates disabled={isLoading} />
</div>
<div className="space-y-4 flex items-center justify-end">
<div className="space-y-4 flex items-center justify-end mt-4 ">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel

View File

@@ -1,28 +0,0 @@
import { api } from "@/utils/api";
import type { PermissionName } from "@dokploy/server/lib/permissions";
import { useMemo } from "react";
interface Props {
permissions: PermissionName[];
children: React.ReactNode;
}
export const Permissions = ({ permissions, children }: Props) => {
const { data: auth } = api.user.get.useQuery();
const hasPermission = useMemo(() => {
if (auth?.role?.name === "owner" || auth?.role?.name === "admin") {
return true;
}
return permissions.some((permission) =>
auth?.role?.permissions?.includes(permission),
);
}, [permissions, auth]);
if (!hasPermission) {
return null;
}
return <>{children}</>;
};

View File

@@ -128,7 +128,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48">
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{node.Hostname}
@@ -162,7 +162,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48">
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{node.Hostname}

View File

@@ -87,8 +87,8 @@ import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
// The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["user"]["get"];
type SingleNavItem = {
@@ -96,12 +96,13 @@ type SingleNavItem = {
title: string;
url: string;
icon?: LucideIcon;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
};
// NavItem type
// Consists of a single item or a group of items
// If `isSingle` is true or undefined, the item is a single item
// If `isSingle` is false, the item is a group of items
type NavItem =
| SingleNavItem
| {
@@ -115,22 +116,27 @@ type NavItem =
}) => boolean;
};
// ExternalLink type
// Represents an external link item (used for the help section)
type ExternalLink = {
name: string;
url: string;
icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
};
// Menu type
// Consists of home, settings, and help items
type Menu = {
home: NavItem[];
settings: NavItem[];
help: ExternalLink[];
};
// Menu items
// Consists of unfiltered home, settings, and help items
// The items are filtered based on the user's role and permissions
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
@@ -153,8 +159,7 @@ const MENU: Menu = {
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) =>
!isCloud && auth?.role?.name === "owner",
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
},
{
isSingle: true,
@@ -164,10 +169,7 @@ const MENU: Menu = {
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(
PERMISSIONS.TRAEFIK.ACCESS.name,
)) &&
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
!isCloud
),
},
@@ -178,11 +180,7 @@ const MENU: Menu = {
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
},
{
isSingle: true,
@@ -191,11 +189,7 @@ const MENU: Menu = {
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
},
{
isSingle: true,
@@ -204,12 +198,64 @@ const MENU: Menu = {
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
},
// Legacy unused menu, adjusted to the new structure
// {
// isSingle: true,
// title: "Projects",
// url: "/dashboard/projects",
// icon: Folder,
// },
// {
// isSingle: true,
// title: "Monitoring",
// icon: BarChartHorizontalBigIcon,
// url: "/dashboard/settings/monitoring",
// },
// {
// isSingle: false,
// title: "Settings",
// icon: Settings2,
// items: [
// {
// title: "Profile",
// url: "/dashboard/settings/profile",
// },
// {
// title: "Users",
// url: "/dashboard/settings/users",
// },
// {
// title: "SSH Key",
// url: "/dashboard/settings/ssh-keys",
// },
// {
// title: "Git",
// url: "/dashboard/settings/git-providers",
// },
// ],
// },
// {
// isSingle: false,
// title: "Integrations",
// icon: BlocksIcon,
// items: [
// {
// title: "S3 Destinations",
// url: "/dashboard/settings/destinations",
// },
// {
// title: "Registry",
// url: "/dashboard/settings/registry",
// },
// {
// title: "Notifications",
// url: "/dashboard/settings/notifications",
// },
// ],
// },
],
settings: [
@@ -219,8 +265,7 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
{
isSingle: true,
@@ -234,7 +279,7 @@ const MENU: Menu = {
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -242,7 +287,7 @@ const MENU: Menu = {
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -251,17 +296,14 @@ const MENU: Menu = {
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(
auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.SSH_KEYS.ACCESS.name)
),
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -270,12 +312,7 @@ const MENU: Menu = {
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(
auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(
PERMISSIONS.GIT_PROVIDERS.ACCESS.name,
)
),
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
},
{
isSingle: true,
@@ -283,7 +320,7 @@ const MENU: Menu = {
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -291,7 +328,7 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
@@ -300,7 +337,7 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -308,8 +345,7 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
{
isSingle: true,
@@ -317,7 +353,7 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
@@ -325,8 +361,7 @@ const MENU: Menu = {
url: "/dashboard/settings/billing",
icon: CreditCard,
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && isCloud),
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
],
@@ -464,7 +499,6 @@ function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
console.log(user);
const { data: session } = authClient.useSession();
const {
@@ -623,7 +657,7 @@ function SidebarLogo() {
)}
</div>
))}
{(user?.role?.name === "owner" || isCloud) && (
{(user?.role === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
@@ -989,7 +1023,7 @@ export default function Page({ children }: Props) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role?.name === "owner" && (
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>

View File

@@ -23,8 +23,6 @@ import { ChevronsUpDown } from "lucide-react";
import { useRouter } from "next/router";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
import { Permissions } from "../dashboard/shared/Permissions";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
const _AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
@@ -100,7 +98,7 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
<Permissions permissions={[PERMISSIONS.TRAEFIK.ACCESS.name]}>
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -109,9 +107,8 @@ export const UserNav = () => {
>
Traefik
</DropdownMenuItem>
</Permissions>
<Permissions permissions={[PERMISSIONS.DOCKER.VIEW.name]}>
)}
{(data?.role === "owner" || data?.canAccessToDocker) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -122,11 +119,11 @@ export const UserNav = () => {
>
Docker
</DropdownMenuItem>
</Permissions>
)}
</>
) : (
<>
{data?.role?.name === "owner" && (
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -139,7 +136,7 @@ export const UserNav = () => {
</>
)}
</DropdownMenuGroup>
{isCloud && data?.role?.name === "owner" && (
{isCloud && data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -157,6 +154,9 @@ export const UserNav = () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out

View File

@@ -1,187 +0,0 @@
CREATE TABLE "member_role" (
"roleId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"canDelete" boolean DEFAULT true NOT NULL,
"is_system" boolean DEFAULT false,
"permissions" text[],
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"organizationId" text NOT NULL,
CONSTRAINT "member_role_name_unique" UNIQUE("name"),
CONSTRAINT "role_name_unique" UNIQUE("name","organizationId")
);
-- Create default roles for each organization
DO $$
DECLARE
org RECORD;
BEGIN
FOR org IN SELECT id FROM "organization"
LOOP
-- Insert owner role
INSERT INTO "member_role" ("roleId", "name", "description", "canDelete", "is_system", "permissions", "created_at", "updated_at", "organizationId")
VALUES (
org.id || '_owner',
'owner',
'Owner role with full access',
false,
true,
'{"project:create", "project:delete", "service:create", "service:delete", "traefik_files:access", "docker:view", "api:access", "ssh_keys:access", "git_providers:access", "schedules:access"}',
NOW(),
NOW(),
org.id
);
-- Insert admin role
INSERT INTO "member_role" ("roleId", "name", "description", "canDelete", "is_system", "permissions", "created_at", "updated_at", "organizationId")
VALUES (
org.id || '_admin',
'admin',
'Administrator role with elevated access',
false,
true,
'{"project:create", "project:delete", "service:create", "service:delete", "traefik_files:access", "docker:view", "api:access", "ssh_keys:access", "schedules:access"}',
NOW(),
NOW(),
org.id
);
-- Insert member role
INSERT INTO "member_role" ("roleId", "name", "description", "canDelete", "is_system", "permissions", "created_at", "updated_at", "organizationId")
VALUES (
org.id || '_member',
'member',
'Standard member role',
false,
true,
'{"project:create", "service:create", "docker:view"}',
NOW(),
NOW(),
org.id
);
END LOOP;
END $$;
--> statement-breakpoint
ALTER TABLE "user_temp" RENAME TO "users";--> statement-breakpoint
ALTER TABLE "users" DROP CONSTRAINT "user_temp_email_unique";--> statement-breakpoint
ALTER TABLE "backup" DROP CONSTRAINT "backup_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "session_temp" DROP CONSTRAINT "session_temp_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "git_provider" DROP CONSTRAINT "git_provider_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "apikey" DROP CONSTRAINT "apikey_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_inviter_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "member" DROP CONSTRAINT "member_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "organization" DROP CONSTRAINT "organization_owner_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "two_factor" DROP CONSTRAINT "two_factor_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "created_at" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "member" ALTER COLUMN "role" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "member" ADD COLUMN "roleId" text;--> statement-breakpoint
ALTER TABLE "member_role" ADD CONSTRAINT "member_role_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_users_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_roleId_member_role_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."member_role"("roleId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- Update existing members with corresponding roles based on their current role type
DO $$
DECLARE
mem RECORD;
BEGIN
FOR mem IN SELECT m.id, m.organization_id, m.role as role_type FROM "member" m
LOOP
UPDATE "member"
SET "roleId" = mem.organization_id || '_' || mem.role_type
WHERE id = mem.id;
END LOOP;
END $$;
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
--> statement-breakpoint
CREATE TABLE "web_server" (
"webServerId" text PRIMARY KEY NOT NULL,
"serverIp" text,
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
"https" boolean DEFAULT false NOT NULL,
"host" text,
"letsEncryptEmail" text,
"sshPrivateKey" text,
"enableDockerCleanup" boolean DEFAULT false NOT NULL,
"logCleanupCron" text DEFAULT '0 0 * * *',
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL
);
INSERT INTO "web_server" (
"webServerId",
"serverIp",
"certificateType",
"https",
"host",
"letsEncryptEmail",
"sshPrivateKey",
"enableDockerCleanup",
"logCleanupCron",
"metricsConfig"
)
SELECT
gen_random_uuid() as "webServerId",
u."serverIp",
COALESCE(u."certificateType", 'none') as "certificateType",
COALESCE(u."https", false) as "https",
u."host",
u."letsEncryptEmail",
u."sshPrivateKey",
COALESCE(u."enableDockerCleanup", false) as "enableDockerCleanup",
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
COALESCE(u."metricsConfig", '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}') as "metricsConfig"
FROM "users" u
INNER JOIN "organization" o ON u.id = o.owner_id
LIMIT 1;
ALTER TABLE "users" DROP COLUMN "createdAt";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "serverIp";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "certificateType";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "https";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "host";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "logCleanupCron";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "metricsConfig";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN "cleanupCacheOnCompose";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canCreateProjects";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canAccessToSSHKeys";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canCreateServices";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canDeleteProjects";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canDeleteServices";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canAccessToDocker";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canAccessToAPI";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canAccessToGitProviders";--> statement-breakpoint
ALTER TABLE "member" DROP COLUMN "canAccessToTraefikFiles";--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE("email");

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "previewRequireCollaboratorPermissions" boolean DEFAULT true;

View File

@@ -1,5 +1,5 @@
{
"id": "6b7b9d76-9e2d-4251-9a3e-8a337076714e",
"id": "8bf085dd-e054-4ae6-811b-1d1a68dab752",
"prevId": "218e3c9b-ef86-4665-98af-56d65282b73b",
"version": "7",
"dialect": "postgresql",
@@ -111,6 +111,13 @@
"notNull": false,
"default": false
},
"previewRequireCollaboratorPermissions": {
"name": "previewRequireCollaboratorPermissions",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"rollbackActive": {
"name": "rollbackActive",
"type": "boolean",
@@ -822,8 +829,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"public.user_temp": {
"name": "user_temp",
"schema": "",
"columns": {
"id": {
@@ -852,11 +859,17 @@
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"notNull": false,
"default": "now()"
},
"two_factor_enabled": {
@@ -905,8 +918,60 @@
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"serverIp": {
"name": "serverIp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"certificateType": {
"name": "certificateType",
"type": "certificateType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "now()"
"default": "'none'"
},
"https": {
"name": "https",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": false
},
"letsEncryptEmail": {
"name": "letsEncryptEmail",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sshPrivateKey": {
"name": "sshPrivateKey",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enableDockerCleanup": {
"name": "enableDockerCleanup",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"logCleanupCron": {
"name": "logCleanupCron",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "'0 0 * * *'"
},
"role": {
"name": "role",
@@ -929,6 +994,34 @@
"notNull": true,
"default": false
},
"metricsConfig": {
"name": "metricsConfig",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{\"server\":{\"type\":\"Dokploy\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"retentionDays\":2,\"cronJob\":\"\",\"urlCallback\":\"\",\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb"
},
"cleanupCacheApplications": {
"name": "cleanupCacheApplications",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"cleanupCacheOnPreviews": {
"name": "cleanupCacheOnPreviews",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"cleanupCacheOnCompose": {
"name": "cleanupCacheOnCompose",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
@@ -953,8 +1046,8 @@
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"user_temp_email_unique": {
"name": "user_temp_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
@@ -1888,10 +1981,10 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_userId_users_id_fk": {
"name": "backup_userId_users_id_fk",
"backup_userId_user_temp_id_fk": {
"name": "backup_userId_user_temp_id_fk",
"tableFrom": "backup",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"userId"
],
@@ -2600,10 +2693,10 @@
},
"indexes": {},
"foreignKeys": {
"session_temp_user_id_users_id_fk": {
"name": "session_temp_user_id_users_id_fk",
"session_temp_user_id_user_temp_id_fk": {
"name": "session_temp_user_id_user_temp_id_fk",
"tableFrom": "session_temp",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"user_id"
],
@@ -3978,10 +4071,10 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"git_provider_userId_users_id_fk": {
"name": "git_provider_userId_users_id_fk",
"git_provider_userId_user_temp_id_fk": {
"name": "git_provider_userId_user_temp_id_fk",
"tableFrom": "git_provider",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"userId"
],
@@ -4653,107 +4746,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.member_role": {
"name": "member_role",
"schema": "",
"columns": {
"roleId": {
"name": "roleId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"canDelete": {
"name": "canDelete",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"is_system": {
"name": "is_system",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"permissions": {
"name": "permissions",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"organizationId": {
"name": "organizationId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"member_role_organizationId_organization_id_fk": {
"name": "member_role_organizationId_organization_id_fk",
"tableFrom": "member_role",
"tableTo": "organization",
"columnsFrom": [
"organizationId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"member_role_name_unique": {
"name": "member_role_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"role_name_unique": {
"name": "role_name_unique",
"nullsNotDistinct": false,
"columns": [
"name",
"organizationId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
@@ -4870,10 +4862,10 @@
},
"indexes": {},
"foreignKeys": {
"account_user_id_users_id_fk": {
"name": "account_user_id_users_id_fk",
"account_user_id_user_temp_id_fk": {
"name": "account_user_id_user_temp_id_fk",
"tableFrom": "account",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"user_id"
],
@@ -5023,10 +5015,10 @@
},
"indexes": {},
"foreignKeys": {
"apikey_user_id_users_id_fk": {
"name": "apikey_user_id_users_id_fk",
"apikey_user_id_user_temp_id_fk": {
"name": "apikey_user_id_user_temp_id_fk",
"tableFrom": "apikey",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"user_id"
],
@@ -5111,10 +5103,10 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"invitation_inviter_id_users_id_fk": {
"name": "invitation_inviter_id_users_id_fk",
"invitation_inviter_id_user_temp_id_fk": {
"name": "invitation_inviter_id_user_temp_id_fk",
"tableFrom": "invitation",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"inviter_id"
],
@@ -5157,13 +5149,7 @@
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"roleId": {
"name": "roleId",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"created_at": {
"name": "created_at",
@@ -5177,6 +5163,69 @@
"primaryKey": false,
"notNull": false
},
"canCreateProjects": {
"name": "canCreateProjects",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canAccessToSSHKeys": {
"name": "canAccessToSSHKeys",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canCreateServices": {
"name": "canCreateServices",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canDeleteProjects": {
"name": "canDeleteProjects",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canDeleteServices": {
"name": "canDeleteServices",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canAccessToDocker": {
"name": "canAccessToDocker",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canAccessToAPI": {
"name": "canAccessToAPI",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canAccessToGitProviders": {
"name": "canAccessToGitProviders",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canAccessToTraefikFiles": {
"name": "canAccessToTraefikFiles",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"accesedProjects": {
"name": "accesedProjects",
"type": "text[]",
@@ -5207,10 +5256,10 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"member_user_id_users_id_fk": {
"name": "member_user_id_users_id_fk",
"member_user_id_user_temp_id_fk": {
"name": "member_user_id_user_temp_id_fk",
"tableFrom": "member",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"user_id"
],
@@ -5219,19 +5268,6 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"member_roleId_member_role_roleId_fk": {
"name": "member_roleId_member_role_roleId_fk",
"tableFrom": "member",
"tableTo": "member_role",
"columnsFrom": [
"roleId"
],
"columnsTo": [
"roleId"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -5289,10 +5325,10 @@
},
"indexes": {},
"foreignKeys": {
"organization_owner_id_users_id_fk": {
"name": "organization_owner_id_users_id_fk",
"organization_owner_id_user_temp_id_fk": {
"name": "organization_owner_id_user_temp_id_fk",
"tableFrom": "organization",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"owner_id"
],
@@ -5348,10 +5384,10 @@
},
"indexes": {},
"foreignKeys": {
"two_factor_user_id_users_id_fk": {
"name": "two_factor_user_id_users_id_fk",
"two_factor_user_id_user_temp_id_fk": {
"name": "two_factor_user_id_user_temp_id_fk",
"tableFrom": "two_factor",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"user_id"
],
@@ -5558,10 +5594,10 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_userId_users_id_fk": {
"name": "schedule_userId_users_id_fk",
"schedule_userId_user_temp_id_fk": {
"name": "schedule_userId_user_temp_id_fk",
"tableFrom": "schedule",
"tableTo": "users",
"tableTo": "user_temp",
"columnsFrom": [
"userId"
],
@@ -5881,85 +5917,6 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.web_server": {
"name": "web_server",
"schema": "",
"columns": {
"webServerId": {
"name": "webServerId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"serverIp": {
"name": "serverIp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"certificateType": {
"name": "certificateType",
"type": "certificateType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'none'"
},
"https": {
"name": "https",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": false
},
"letsEncryptEmail": {
"name": "letsEncryptEmail",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sshPrivateKey": {
"name": "sshPrivateKey",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enableDockerCleanup": {
"name": "enableDockerCleanup",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"logCleanupCron": {
"name": "logCleanupCron",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "'0 0 * * *'"
},
"metricsConfig": {
"name": "metricsConfig",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{\"server\":{\"type\":\"Dokploy\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"retentionDays\":2,\"cronJob\":\"\",\"urlCallback\":\"\",\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {

View File

@@ -726,8 +726,8 @@
{
"idx": 103,
"version": "7",
"when": 1752428260850,
"tag": "0103_brainy_nehzno",
"when": 1752465764072,
"tag": "0103_cultured_pestilence",
"breakpoints": true
}
]

View File

@@ -24,7 +24,7 @@
// });
// for (const admin of admins) {
// const user = await db
// .insert(schema.users)
// .insert(schema.users_temp)
// .values({
// id: admin.adminId,
// email: admin.auth.email,
@@ -74,7 +74,7 @@
// for (const member of admin.users) {
// const userTemp = await db
// .insert(schema.users)
// .insert(schema.users_temp)
// .values({
// id: member.userId,
// email: member.auth.email,

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.24.1",
"version": "v0.24.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -187,10 +187,10 @@
"ct3aMetadata": {
"initVersion": "7.25.2"
},
"packageManager": "pnpm@9.5.0",
"packageManager": "pnpm@9.12.0",
"engines": {
"node": "^20.16.0",
"pnpm": ">=9.5.0"
"pnpm": ">=9.12.0"
},
"lint-staged": {
"*": [
@@ -198,6 +198,8 @@
]
},
"commitlint": {
"extends": ["@commitlint/config-conventional"]
"extends": [
"@commitlint/config-conventional"
]
}
}

View File

@@ -5,7 +5,10 @@ import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import {
IS_CLOUD,
checkUserRepositoryPermissions,
createPreviewDeployment,
createSecurityBlockedComment,
findGithubById,
findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId,
removePreviewDeployment,
@@ -346,6 +349,18 @@ export default async function handler(
const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref;
const owner = githubBody?.repository?.owner?.login;
const prAuthor = githubBody?.pull_request?.user?.login;
// Validate PR author information is present
if (!prAuthor) {
console.warn(
"⚠️ SECURITY: PR author information missing in webhook payload",
);
res.status(400).json({
message: "PR author information missing",
});
return;
}
const apps = await db.query.applications.findMany({
where: and(
@@ -361,13 +376,72 @@ export default async function handler(
},
});
// SECURITY: Check collaborator permissions per application setting
const secureApps: typeof apps = [];
const blockedApps: string[] = [];
let userPermission: string | null = null;
for (const app of apps) {
// If the app requires collaborator permissions, verify them
if (app.previewRequireCollaboratorPermissions !== false) {
try {
const githubProvider = await findGithubById(githubResult.githubId);
const { hasWriteAccess, permission } =
await checkUserRepositoryPermissions(
githubProvider,
owner,
repository,
prAuthor,
);
userPermission = permission; // Store permission for comment
if (!hasWriteAccess) {
console.warn(
`🚨 SECURITY: Blocked preview deployment for ${app.name} from unauthorized user ${prAuthor} on ${owner}/${repository}. Permission: ${permission || "none"}`,
);
blockedApps.push(app.name);
continue;
}
console.log(
`✅ SECURITY: Preview deployment authorized for ${app.name} from user ${prAuthor} on ${owner}/${repository}. Permission: ${permission}`,
);
} catch (error) {
console.error(
`Error validating PR author permissions for ${app.name}:`,
error,
);
blockedApps.push(app.name);
continue; // Skip this app on error
}
} else {
console.warn(
`⚠️ SECURITY: Preview deployment for ${app.name} allows deployment from any PR author (security check disabled)`,
);
}
secureApps.push(app);
}
const prBranch = githubBody?.pull_request?.head?.ref;
const prNumber = githubBody?.pull_request?.number;
const prTitle = githubBody?.pull_request?.title;
const prURL = githubBody?.pull_request?.html_url;
for (const app of apps) {
// Create security notification comment if any apps were blocked
if (blockedApps.length > 0) {
await createSecurityBlockedComment({
owner,
repository,
prNumber: Number.parseInt(prNumber),
prAuthor,
permission: userPermission,
githubId: githubResult.githubId,
});
}
for (const app of secureApps) {
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;

View File

@@ -1,6 +1,6 @@
import { buffer } from "node:stream/consumers";
import { db } from "@/server/db";
import { organization, server, users } from "@/server/db/schema";
import { organization, server, users_temp } from "@/server/db/schema";
import { type Server, findUserById } from "@dokploy/server";
import { asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -64,13 +64,13 @@ export default async function handler(
session.subscription as string,
);
await db
.update(users)
.update(users_temp)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(users.id, adminId))
.where(eq(users_temp.id, adminId))
.returning();
const admin = await findUserById(adminId);
@@ -85,12 +85,14 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(users)
.update(users_temp)
.set({
stripeSubscriptionId: newSubscription.id,
stripeCustomerId: newSubscription.customer as string,
})
.where(eq(users.stripeCustomerId, newSubscription.customer as string))
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
)
.returning();
break;
@@ -100,12 +102,14 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(users)
.update(users_temp)
.set({
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(eq(users.stripeCustomerId, newSubscription.customer as string));
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
const admin = await findUserByStripeCustomerId(
newSubscription.customer as string,
@@ -131,12 +135,12 @@ export default async function handler(
if (newSubscription.status === "active") {
await db
.update(users)
.update(users_temp)
.set({
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(
eq(users.stripeCustomerId, newSubscription.customer as string),
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
const newServersQuantity = admin.serversQuantity;
@@ -144,10 +148,10 @@ export default async function handler(
} else {
await disableServers(admin.id);
await db
.update(users)
.update(users_temp)
.set({ serversQuantity: 0 })
.where(
eq(users.stripeCustomerId, newSubscription.customer as string),
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
}
@@ -168,11 +172,11 @@ export default async function handler(
}
await db
.update(users)
.update(users_temp)
.set({
serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(users.stripeCustomerId, suscription.customer as string));
.where(eq(users_temp.stripeCustomerId, suscription.customer as string));
const admin = await findUserByStripeCustomerId(
suscription.customer as string,
@@ -201,11 +205,13 @@ export default async function handler(
return res.status(400).send("Webhook Error: Admin not found");
}
await db
.update(users)
.update(users_temp)
.set({
serversQuantity: 0,
})
.where(eq(users.stripeCustomerId, newInvoice.customer as string));
.where(
eq(users_temp.stripeCustomerId, newInvoice.customer as string),
);
await disableServers(admin.id);
}
@@ -223,13 +229,13 @@ export default async function handler(
await disableServers(admin.id);
await db
.update(users)
.update(users_temp)
.set({
stripeCustomerId: null,
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(eq(users.stripeCustomerId, customer.id));
.where(eq(users_temp.stripeCustomerId, customer.id));
break;
}
@@ -256,8 +262,8 @@ const disableServers = async (userId: string) => {
};
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
const user = db.query.users.findFirst({
where: eq(users.stripeCustomerId, stripeCustomerId),
const user = db.query.users_temp.findFirst({
where: eq(users_temp.stripeCustomerId, stripeCustomerId),
});
return user;
};

View File

@@ -3,7 +3,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -54,8 +53,12 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) {
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,

View File

@@ -20,7 +20,7 @@ const Dashboard = () => {
false,
);
const { data: webServer, isLoading } = api.webServer.get.useQuery();
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
return (
<div className="space-y-4 pb-10">
{/* <AlertBlock>
@@ -59,12 +59,12 @@ const Dashboard = () => {
<ShowPaidMonitoring
BASE_URL={
process.env.NODE_ENV === "production"
? `http://${webServer?.serverIp}:${webServer?.metricsConfig?.server?.port}/metrics`
? `http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}/metrics`
: BASE_URL
}
token={
process.env.NODE_ENV === "production"
? webServer?.metricsConfig?.server?.token
? monitoring?.metricsConfig?.server?.token
: DEFAULT_TOKEN
}
/>

View File

@@ -94,7 +94,6 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
export type Services = {
appName: string;
@@ -222,6 +221,7 @@ const Project = (
) => {
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId } = props;
const { data: auth } = api.user.get.useQuery();
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "createdAt-desc";
@@ -736,27 +736,30 @@ const Project = (
Stop
</Button>
</DialogAction>
<Permissions permissions={["project:delete"]}>
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
type="destructive"
onClick={handleBulkDelete}
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
{(auth?.role === "owner" ||
auth?.canDeleteServices) && (
<>
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
type="destructive"
onClick={handleBulkDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</Permissions>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</>
)}
<Dialog
open={isMoveDialogOpen}

View File

@@ -53,7 +53,6 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState =
| "projects"
@@ -88,6 +87,7 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
return (
<div className="pb-10">
@@ -178,9 +178,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={applicationId} type="application" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -50,7 +50,6 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState =
| "projects"
@@ -78,6 +77,7 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -170,9 +170,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={composeId} type="compose" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -44,7 +44,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -58,6 +57,7 @@ const Mariadb = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -142,9 +142,9 @@ const Mariadb = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={mariadbId} type="mariadb" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -44,7 +44,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -58,6 +57,8 @@ const Mongo = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -142,9 +143,9 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={mongoId} type="mongo" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -29,6 +29,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server/lib/auth";
@@ -43,8 +44,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
import { cn } from "@/lib/utils";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -57,6 +56,7 @@ const MySql = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -143,9 +143,9 @@ const MySql = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={mysqlId} type="mysql" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -29,6 +29,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server/lib/auth";
@@ -43,8 +44,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
import { cn } from "@/lib/utils";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -57,6 +56,7 @@ const Postgresql = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -142,9 +142,9 @@ const Postgresql = (
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={postgresId} type="postgres" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -43,7 +43,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState = "projects" | "monitoring" | "settings" | "advanced";
@@ -57,6 +56,8 @@ const Redis = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -141,9 +142,9 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
<Permissions permissions={["service:delete"]}>
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={redisId} type="redis" />
</Permissions>
)}
</div>
</div>
</CardHeader>

View File

@@ -39,7 +39,7 @@ export async function getServerSideProps(
};
}
const { user } = await validateRequest(ctx.req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: true,

View File

@@ -44,7 +44,7 @@ export async function getServerSideProps(
await helpers.user.get.prefetch();
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -31,7 +31,7 @@ export async function getServerSideProps(
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -25,7 +25,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -34,7 +34,7 @@ export async function getServerSideProps(
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -26,7 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -3,7 +3,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { validateRequest } from "@dokploy/server";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -50,12 +49,12 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (
!user?.role?.permissions?.includes(
PERMISSIONS.GIT_PROVIDERS.ACCESS.name,
)
) {
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToGitProviders) {
return {
redirect: {
permanent: true,

View File

@@ -26,7 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -1,23 +1,27 @@
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
const Page = () => {
const { data } = api.user.get.useQuery();
// const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
<Permissions permissions={["api:access"]}>
<ShowApiKeys />
</Permissions>
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
{/* {isCloud && <RemoveSelfAccount />} */}
</div>
</div>
);

View File

@@ -26,7 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -59,7 +59,7 @@ export async function getServerSideProps(
},
};
}
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -36,7 +36,7 @@ export async function getServerSideProps(
},
};
}
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -3,7 +3,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { validateRequest } from "@dokploy/server";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -51,10 +50,12 @@ export async function getServerSideProps(
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (
!user?.role?.permissions?.includes(PERMISSIONS.SSH_KEYS.ACCESS.name)
) {
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToSSHKeys) {
return {
redirect: {
permanent: true,

View File

@@ -29,7 +29,7 @@ export async function getServerSideProps(
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,

View File

@@ -3,7 +3,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -54,8 +53,12 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) {
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,

View File

@@ -3,7 +3,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -54,8 +53,12 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.TRAEFIK.ACCESS.name)) {
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToTraefikFiles) {
return {
redirect: {
permanent: true,

View File

@@ -1,10 +1,12 @@
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext, NextPage } from "next";
import dynamic from "next/dynamic";
import "swagger-ui-react/swagger-ui.css";
import { useEffect, useState } from "react";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import superjson from "superjson";
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
@@ -69,7 +71,8 @@ const Home: NextPage = () => {
export default Home;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { user } = await validateRequest(context.req);
const { req, res } = context;
const { user, session } = await validateRequest(context.req);
if (!user) {
return {
redirect: {
@@ -78,9 +81,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.API.ACCESS.name)) {
if (!userR?.canAccessToAPI) {
return {
redirect: {
permanent: true,

View File

@@ -1,18 +1,18 @@
import { findOwner } from "@dokploy/server";
import { findAdmin } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { users } from "@dokploy/server/db/schema";
import { users_temp } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
(async () => {
try {
const result = await findOwner();
const result = await findAdmin();
const update = await db
.update(users)
.update(users_temp)
.set({
twoFactorEnabled: false,
})
.where(eq(users.id, result.userId));
.where(eq(users_temp.id, result.userId));
if (update) {
console.log("2FA reset successful");

View File

@@ -1,4 +1,4 @@
import { findOwner } from "@dokploy/server";
import { findAdmin } from "@dokploy/server";
import { generateRandomPassword } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { account } from "@dokploy/server/db/schema";
@@ -8,7 +8,7 @@ import { eq } from "drizzle-orm";
try {
const randomPassword = await generateRandomPassword();
const result = await findOwner();
const result = await findAdmin();
const update = await db
.update(account)

View File

@@ -28,6 +28,8 @@ import { projectRouter } from "./routers/project";
import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
import { rollbackRouter } from "./routers/rollbacks";
import { scheduleRouter } from "./routers/schedule";
import { securityRouter } from "./routers/security";
import { serverRouter } from "./routers/server";
import { settingsRouter } from "./routers/settings";
@@ -35,11 +37,7 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user";
import { scheduleRouter } from "./routers/schedule";
import { rollbackRouter } from "./routers/rollbacks";
import { volumeBackupsRouter } from "./routers/volume-backups";
import { roleRouter } from "./routers/role";
import { webServerRouter } from "./routers/web-server";
/**
* This is the primary router for your server.
*
@@ -86,8 +84,6 @@ export const appRouter = createTRPCRouter({
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
role: roleRouter,
webServer: webServerRouter,
});
// export type definition of API

View File

@@ -3,7 +3,7 @@ import {
IS_CLOUD,
findUserById,
setupWebMonitoring,
updateWebServer,
updateUser,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { adminProcedure, createTRPCRouter } from "../trpc";
@@ -27,8 +27,7 @@ export const adminRouter = createTRPCRouter({
});
}
await updateWebServer({
// @ts-expect-error - TODO: fix this
await updateUser(user.id, {
metricsConfig: {
server: {
type: "Dokploy",
@@ -53,7 +52,7 @@ export const adminRouter = createTRPCRouter({
},
});
const currentServer = await setupWebMonitoring();
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
} catch (error) {
throw error;

View File

@@ -147,10 +147,11 @@ export const aiRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ ctx, input }) => {
try {
return await suggestVariants({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw new TRPCError({
@@ -162,7 +163,7 @@ export const aiRouter = createTRPCRouter({
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.session.activeOrganizationId,
input.projectId,
@@ -215,7 +216,7 @@ export const aiRouter = createTRPCRouter({
}
}
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,

View File

@@ -63,7 +63,7 @@ export const applicationRouter = createTRPCRouter({
.input(apiCreateApplication)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -88,7 +88,7 @@ export const applicationRouter = createTRPCRouter({
}
const newApplication = await createApplication(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newApplication.applicationId,
@@ -110,7 +110,7 @@ export const applicationRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
@@ -201,7 +201,7 @@ export const applicationRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,

View File

@@ -31,7 +31,7 @@ import {
findGitProviderById,
findProjectById,
findServerById,
findWebServer,
findUserById,
getComposeContainer,
loadServices,
randomizeComposeFile,
@@ -64,7 +64,7 @@ export const composeRouter = createTRPCRouter({
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -88,7 +88,7 @@ export const composeRouter = createTRPCRouter({
}
const newService = await createCompose(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newService.composeId,
@@ -105,7 +105,7 @@ export const composeRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -177,7 +177,7 @@ export const composeRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -469,7 +469,7 @@ export const composeRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -487,8 +487,8 @@ export const composeRouter = createTRPCRouter({
const template = await fetchTemplateFiles(input.id, input.baseUrl);
const webServer = await findWebServer();
let serverIp = webServer.serverIp || "127.0.0.1";
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
const project = await findProjectById(input.projectId);
@@ -524,7 +524,7 @@ export const composeRouter = createTRPCRouter({
isolatedDeployment: true,
});
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
compose.composeId,
@@ -709,8 +709,8 @@ export const composeRouter = createTRPCRouter({
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
const webServer = await findWebServer();
let serverIp = webServer.serverIp || "127.0.0.1";
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
@@ -785,8 +785,8 @@ export const composeRouter = createTRPCRouter({
await removeDomainById(domain.domainId);
}
const webServer = await findWebServer();
let serverIp = webServer.serverIp || "127.0.0.1";
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);

View File

@@ -20,8 +20,8 @@ import {
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure

View File

@@ -8,9 +8,9 @@ import {
getServiceContainersByAppName,
getStackContainersByAppName,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;

View File

@@ -13,9 +13,9 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
findWebServer,
generateTraefikMeDomain,
manageDomain,
removeDomain,
@@ -93,19 +93,25 @@ export const domainRouter = createTRPCRouter({
}),
generateDomain: protectedProcedure
.input(z.object({ appName: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input }) => {
return generateTraefikMeDomain(input.appName, input.serverId);
.mutation(async ({ input, ctx }) => {
return generateTraefikMeDomain(
input.appName,
ctx.user.ownerId,
input.serverId,
);
}),
canGenerateTraefikMeDomains: protectedProcedure
.input(z.object({ serverId: z.string() }))
.query(async ({ input }) => {
const webServer = await findWebServer();
.query(async ({ input, ctx }) => {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
return webServer?.serverIp;
return organization?.owner.serverIp;
}),
update: protectedProcedure

View File

@@ -41,7 +41,7 @@ export const mariadbRouter = createTRPCRouter({
.input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mariadbRouter = createTRPCRouter({
});
}
const newMariadb = await createMariadb(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newMariadb.mariadbId,
@@ -92,7 +92,7 @@ export const mariadbRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMariaDB)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
@@ -219,7 +219,7 @@ export const mariadbRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,

View File

@@ -41,7 +41,7 @@ export const mongoRouter = createTRPCRouter({
.input(apiCreateMongo)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mongoRouter = createTRPCRouter({
});
}
const newMongo = await createMongo(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newMongo.mongoId,
@@ -96,7 +96,7 @@ export const mongoRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMongo)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mongoId,
@@ -261,7 +261,7 @@ export const mongoRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mongoId,

View File

@@ -12,8 +12,8 @@ import {
getServiceContainer,
updateMount,
} from "@dokploy/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const mountRouter = createTRPCRouter({
create: protectedProcedure

View File

@@ -44,7 +44,7 @@ export const mysqlRouter = createTRPCRouter({
.input(apiCreateMySql)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -69,7 +69,7 @@ export const mysqlRouter = createTRPCRouter({
}
const newMysql = await createMysql(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newMysql.mysqlId,
@@ -100,7 +100,7 @@ export const mysqlRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMySql)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
@@ -260,7 +260,7 @@ export const mysqlRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,

View File

@@ -24,7 +24,7 @@ import {
apiUpdateTelegram,
notifications,
server,
webServer,
users_temp,
} from "@/server/db/schema";
import {
IS_CLOUD,
@@ -345,19 +345,19 @@ export const notificationRouter = createTRPCRouter({
if (input.ServerType === "Dokploy") {
const result = await db
.select()
.from(webServer)
.from(users_temp)
.where(
sql`${webServer.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
sql`${users_temp.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
);
if (!result?.[0]?.webServerId) {
if (!result?.[0]?.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token not found",
});
}
organizationId = result?.[0]?.webServerId;
organizationId = result?.[0]?.id;
ServerName = "Dokploy";
} else {
const result = await db

View File

@@ -32,6 +32,8 @@ export const organizationRouter = createTRPCRouter({
.returning()
.then((res) => res[0]);
console.log("result", result);
if (!result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",

View File

@@ -41,7 +41,7 @@ export const postgresRouter = createTRPCRouter({
.input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const postgresRouter = createTRPCRouter({
});
}
const newPostgres = await createPostgres(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newPostgres.postgresId,
@@ -96,7 +96,7 @@ export const postgresRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOnePostgres)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.postgresId,
@@ -244,7 +244,7 @@ export const postgresRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.postgresId,

View File

@@ -57,7 +57,7 @@ export const projectRouter = createTRPCRouter({
.input(apiCreateProject)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -78,7 +78,7 @@ export const projectRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewProject(
ctx.user.id,
project.projectId,
@@ -99,7 +99,7 @@ export const projectRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneProject)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -164,7 +164,7 @@ export const projectRouter = createTRPCRouter({
return project;
}),
all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
const { accessedProjects, accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -241,7 +241,7 @@ export const projectRouter = createTRPCRouter({
.input(apiRemoveProject)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkProjectAccess(
ctx.user.id,
"delete",
@@ -314,7 +314,7 @@ export const projectRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -361,6 +361,7 @@ export const projectRouter = createTRPCRouter({
previewDeployments,
mounts,
appName,
refreshToken,
...application
} = await findApplicationById(id);
const newAppName = appName.substring(
@@ -603,8 +604,14 @@ export const projectRouter = createTRPCRouter({
break;
}
case "compose": {
const { composeId, mounts, domains, appName, ...compose } =
await findComposeById(id);
const {
composeId,
mounts,
domains,
appName,
refreshToken,
...compose
} = await findComposeById(id);
const newAppName = appName.substring(
0,
@@ -649,10 +656,7 @@ export const projectRouter = createTRPCRouter({
}
}
if (
!input.duplicateInSameProject &&
(ctx.user.role.name === "member" || !ctx.user.role.isSystem)
) {
if (!input.duplicateInSameProject && ctx.user.role === "member") {
await addNewProject(
ctx.user.id,
targetProject.projectId,

View File

@@ -41,7 +41,7 @@ export const redisRouter = createTRPCRouter({
.input(apiCreateRedis)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const redisRouter = createTRPCRouter({
});
}
const newRedis = await createRedis(input);
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newRedis.redisId,
@@ -89,7 +89,7 @@ export const redisRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneRedis)
.query(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.redisId,
@@ -251,7 +251,7 @@ export const redisRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.redisId,

View File

@@ -1,75 +0,0 @@
import { createTRPCRouter } from "@/server/api/trpc";
// import { createRole, removeRoleById, updateRoleById } from "@dokploy/server";
// import { defaultPermissions } from "@dokploy/server/lib/permissions";
export const roleRouter = createTRPCRouter({
// all: protectedProcedure.query(async ({ ctx }) => {
// const roles = await db.query.role.findMany({
// where: and(
// eq(role.organizationId, ctx.session.activeOrganizationId),
// eq(role.isSystem, false),
// ),
// orderBy: [asc(role.createdAt)],
// });
// return roles;
// }),
// delete: protectedProcedure
// .input(apiFindOneRole)
// .mutation(async ({ input }) => {
// try {
// return removeRoleById(input.roleId);
// } catch (error) {
// const message =
// error instanceof Error ? error.message : "Error input: Deleting role";
// throw new TRPCError({
// code: "BAD_REQUEST",
// message,
// });
// }
// }),
// create: protectedProcedure
// .input(createRoleSchema)
// .mutation(async ({ input, ctx }) => {
// try {
// return await createRole(
// {
// ...input,
// },
// ctx.session.activeOrganizationId,
// );
// } catch (error) {
// console.error(error);
// throw new TRPCError({
// code: "BAD_REQUEST",
// message: "Error input: Creating role",
// cause: error,
// });
// }
// }),
// update: protectedProcedure
// .input(updateRoleSchema)
// .mutation(async ({ input }) => {
// return await updateRoleById(input.roleId, input);
// }),
// getDefaultRoles: protectedProcedure.query(async ({ ctx }) => {
// const roles = await db.query.role.findMany({
// where: and(
// eq(role.organizationId, ctx.session.activeOrganizationId),
// eq(role.isSystem, true),
// ),
// });
// // add the description from the constants roles to the roles
// const rolesWithDescription = defaultPermissions.map((role) => {
// const roleInfo = roles.find((r) => r.name === role.name);
// return {
// ...roleInfo,
// ...role,
// };
// });
// const set = new Set(rolesWithDescription.flatMap((r) => r.permissions));
// return {
// roles: rolesWithDescription,
// permissions: Array.from(set),
// };
// }),
});

View File

@@ -1,12 +1,16 @@
import { db } from "@/server/db";
import {
apiAssignDomain,
apiEnableDashboard,
apiModifyTraefikConfig,
apiReadStatsLogs,
apiReadTraefikConfig,
apiSaveSSHKey,
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
} from "@/server/db/schema";
import { removeJob, schedule } from "@/server/utils/backup";
import {
DEFAULT_UPDATE_DATA,
IS_CLOUD,
@@ -19,6 +23,7 @@ import {
execAsync,
execAsyncRemote,
findServerById,
findUserById,
getDokployImage,
getDokployImageTag,
getLogCleanupStatus,
@@ -35,9 +40,14 @@ import {
readMainConfig,
readMonitoringConfig,
recreateDirectory,
sendDockerCleanupNotifications,
spawnAsync,
startLogCleanup,
stopLogCleanup,
updateLetsEncryptEmail,
updateServerById,
updateServerTraefik,
updateUser,
writeConfig,
writeMainConfig,
writeTraefikConfigInPath,
@@ -47,6 +57,7 @@ import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { TRPCError } from "@trpc/server";
import { sql } from "drizzle-orm";
import { dump, load } from "js-yaml";
import { scheduleJob, scheduledJobs } from "node-schedule";
import { z } from "zod";
import packageInfo from "../../../package.json";
import { appRouter } from "../root";
@@ -176,6 +187,135 @@ export const settingsRouter = createTRPCRouter({
await recreateDirectory(MONITORING_PATH);
return true;
}),
saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
sshPrivateKey: input.sshPrivateKey,
});
return true;
}),
assignDomainServer: adminProcedure
.input(apiAssignDomain)
.mutation(async ({ ctx, input }) => {
if (IS_CLOUD) {
return true;
}
const user = await updateUser(ctx.user.id, {
host: input.host,
...(input.letsEncryptEmail && {
letsEncryptEmail: input.letsEncryptEmail,
}),
certificateType: input.certificateType,
https: input.https,
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
updateServerTraefik(user, input.host);
if (input.letsEncryptEmail) {
updateLetsEncryptEmail(input.letsEncryptEmail);
}
return user;
}),
cleanSSHPrivateKey: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
sshPrivateKey: null,
});
return true;
}),
updateDockerCleanup: adminProcedure
.input(apiUpdateDockerCleanup)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
await updateServerById(input.serverId, {
enableDockerCleanup: input.enableDockerCleanup,
});
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
if (server.enableDockerCleanup) {
const server = await findServerById(input.serverId);
if (server.serverStatus === "inactive") {
throw new TRPCError({
code: "NOT_FOUND",
message: "Server is inactive",
});
}
if (IS_CLOUD) {
await schedule({
cronSchedule: "0 0 * * *",
serverId: input.serverId,
type: "server",
});
} else {
scheduleJob(server.serverId, "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages(server.serverId);
await cleanUpDockerBuilder(server.serverId);
await cleanUpSystemPrune(server.serverId);
await sendDockerCleanupNotifications(server.organizationId);
});
}
} else {
if (IS_CLOUD) {
await removeJob({
cronSchedule: "0 0 * * *",
serverId: input.serverId,
type: "server",
});
} else {
const currentJob = scheduledJobs[server.serverId];
currentJob?.cancel();
}
}
} else if (!IS_CLOUD) {
const userUpdated = await updateUser(ctx.user.id, {
enableDockerCleanup: input.enableDockerCleanup,
});
if (userUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
await sendDockerCleanupNotifications(
ctx.session.activeOrganizationId,
);
});
} else {
const currentJob = scheduledJobs["docker-cleanup"];
currentJob?.cancel();
}
}
return true;
}),
readTraefikConfig: adminProcedure.query(() => {
if (IS_CLOUD) {
@@ -330,6 +470,13 @@ export const settingsRouter = createTRPCRouter({
return readConfigInPath(input.path, input.serverId);
}),
getIp: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
const user = await findUserById(ctx.user.ownerId);
return user.serverIp;
}),
getOpenApiDocument: protectedProcedure.query(
async ({ ctx }): Promise<unknown> => {

Some files were not shown because too many files have changed in this diff Show More