diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts new file mode 100644 index 000000000..7f14e2d0e --- /dev/null +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: overrides.canCreateProjects ?? false, + canDeleteProjects: overrides.canDeleteProjects ?? false, + canCreateServices: overrides.canCreateServices ?? false, + canDeleteServices: overrides.canDeleteServices ?? false, + canCreateEnvironments: overrides.canCreateEnvironments ?? false, + canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, + canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, + canAccessToDocker: overrides.canAccessToDocker ?? false, + canAccessToAPI: overrides.canAccessToAPI ?? false, + canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, + canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { checkPermission } = await import("@dokploy/server/services/permission"); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("static roles bypass enterprise resources", () => { + it("owner bypasses deployment.read", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { deployment: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses backup.create", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkPermission(ctx, { backup: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member bypasses schedule.delete", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { schedule: ["delete"] }), + ).resolves.toBeUndefined(); + }); + + it("member bypasses multiple enterprise permissions at once", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { + deployment: ["read"], + backup: ["create"], + domain: ["delete"], + }), + ).resolves.toBeUndefined(); + }); +}); + +describe("static roles validate free-tier resources", () => { + it("owner passes project.create", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails project.create (no legacy override)", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).rejects.toThrow(); + }); + + it("member passes service.read", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { service: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails service.create", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { service: ["create"] }), + ).rejects.toThrow(); + }); +}); + +describe("legacy boolean overrides for member", () => { + it("member passes project.create with canCreateProjects=true", async () => { + memberToReturn = mockMemberData("member", { canCreateProjects: true }); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member passes docker.read with canAccessToDocker=true", async () => { + memberToReturn = mockMemberData("member", { canAccessToDocker: true }); + await expect( + checkPermission(ctx, { docker: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails docker.read with canAccessToDocker=false", async () => { + memberToReturn = mockMemberData("member"); + await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts new file mode 100644 index 000000000..9568b12af --- /dev/null +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { + enterpriseOnlyResources, + statements, +} from "@dokploy/server/lib/access-control"; + +const FREE_TIER_RESOURCES = [ + "organization", + "member", + "invitation", + "team", + "ac", + "project", + "service", + "environment", + "docker", + "sshKeys", + "gitProviders", + "traefikFiles", + "api", +]; + +const ENTERPRISE_RESOURCES = [ + "volume", + "deployment", + "envVars", + "projectEnvVars", + "environmentEnvVars", + "server", + "registry", + "certificate", + "backup", + "volumeBackup", + "schedule", + "domain", + "destination", + "notification", + "logs", + "monitoring", + "auditLog", +]; + +describe("enterpriseOnlyResources set", () => { + it("contains all enterprise resources", () => { + for (const resource of ENTERPRISE_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(true); + } + }); + + it("does NOT contain free-tier resources", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("every resource in statements is either free or enterprise", () => { + const allResources = Object.keys(statements); + for (const resource of allResources) { + const isFree = FREE_TIER_RESOURCES.includes(resource); + const isEnterprise = enterpriseOnlyResources.has(resource); + expect(isFree || isEnterprise).toBe(true); + } + }); + + it("free and enterprise sets don't overlap", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("all statement resources are accounted for", () => { + const allResources = Object.keys(statements); + const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES]; + for (const resource of allResources) { + expect(categorized).toContain(resource); + } + }); +}); diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts new file mode 100644 index 000000000..759c8dad8 --- /dev/null +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: overrides.canCreateProjects ?? false, + canDeleteProjects: overrides.canDeleteProjects ?? false, + canCreateServices: overrides.canCreateServices ?? false, + canDeleteServices: overrides.canDeleteServices ?? false, + canCreateEnvironments: overrides.canCreateEnvironments ?? false, + canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, + canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, + canAccessToDocker: overrides.canAccessToDocker ?? false, + canAccessToAPI: overrides.canAccessToAPI ?? false, + canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, + canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { resolvePermissions } = await import( + "@dokploy/server/services/permission" +); +const { enterpriseOnlyResources, statements } = await import( + "@dokploy/server/lib/access-control" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("enterprise resources for static roles", () => { + it("owner gets true for all enterprise resources", async () => { + memberToReturn = mockMemberData("owner"); + const perms = await resolvePermissions(ctx); + + for (const resource of enterpriseOnlyResources) { + const actions = statements[resource as keyof typeof statements]; + for (const action of actions) { + expect((perms as any)[resource][action]).toBe(true); + } + } + }); + + it("admin gets true for all enterprise resources", async () => { + memberToReturn = mockMemberData("admin"); + const perms = await resolvePermissions(ctx); + + for (const resource of enterpriseOnlyResources) { + const actions = statements[resource as keyof typeof statements]; + for (const action of actions) { + expect((perms as any)[resource][action]).toBe(true); + } + } + }); + + it("member gets true for service-level enterprise resources", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + + expect(perms.deployment.read).toBe(true); + expect(perms.deployment.create).toBe(true); + expect(perms.domain.read).toBe(true); + expect(perms.backup.read).toBe(true); + expect(perms.logs.read).toBe(true); + expect(perms.monitoring.read).toBe(true); + }); + + it("member gets false for org-level enterprise resources", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + + expect(perms.server.read).toBe(false); + expect(perms.registry.read).toBe(false); + expect(perms.certificate.read).toBe(false); + expect(perms.destination.read).toBe(false); + expect(perms.notification.read).toBe(false); + expect(perms.auditLog.read).toBe(false); + }); +}); + +describe("free-tier resources for member", () => { + it("member gets service.read=true", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.service.read).toBe(true); + }); + + it("member gets project.create=false without legacy override", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(false); + }); + + it("member gets project.create=true with canCreateProjects", async () => { + memberToReturn = mockMemberData("member", { canCreateProjects: true }); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(true); + }); + + it("member gets docker.read=false without legacy override", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.docker.read).toBe(false); + }); + + it("member gets docker.read=true with canAccessToDocker", async () => { + memberToReturn = mockMemberData("member", { canAccessToDocker: true }); + const perms = await resolvePermissions(ctx); + expect(perms.docker.read).toBe(true); + }); +}); + +describe("free-tier resources for owner", () => { + it("owner gets all free-tier permissions as true", async () => { + memberToReturn = mockMemberData("owner"); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(true); + expect(perms.project.delete).toBe(true); + expect(perms.service.create).toBe(true); + expect(perms.service.read).toBe(true); + expect(perms.service.delete).toBe(true); + expect(perms.docker.read).toBe(true); + expect(perms.traefikFiles.read).toBe(true); + expect(perms.traefikFiles.write).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/permissions/service-access.test.ts b/apps/dokploy/__test__/permissions/service-access.test.ts new file mode 100644 index 000000000..b3786807d --- /dev/null +++ b/apps/dokploy/__test__/permissions/service-access.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + accessedServices: string[] = [], + accessedProjects: string[] = [], +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects, + accessedServices, + accessedEnvironments: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { checkServicePermissionAndAccess, checkServiceAccess } = await import( + "@dokploy/server/services/permission" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("checkServicePermissionAndAccess", () => { + it("owner bypasses accessedServices check", async () => { + memberToReturn = mockMemberData("owner", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses accessedServices check", async () => { + memberToReturn = mockMemberData("admin", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + backup: ["create"], + }), + ).resolves.toBeUndefined(); + }); + + it("member with access to service passes", async () => { + memberToReturn = mockMemberData("member", ["service-123"]); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).resolves.toBeUndefined(); + }); + + it("member WITHOUT access to service fails", async () => { + memberToReturn = mockMemberData("member", ["other-service"]); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).rejects.toThrow("You don't have access to this service"); + }); + + it("member with empty accessedServices fails", async () => { + memberToReturn = mockMemberData("member", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + domain: ["delete"], + }), + ).rejects.toThrow("You don't have access to this service"); + }); +}); + +describe("checkServiceAccess", () => { + it("member with service access passes read check", async () => { + memberToReturn = mockMemberData("member", ["app-1"]); + await expect( + checkServiceAccess(ctx, "app-1", "read"), + ).resolves.toBeUndefined(); + }); + + it("member without service access fails read check", async () => { + memberToReturn = mockMemberData("member", []); + await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow( + "You don't have access to this service", + ); + }); + + it("owner bypasses all access checks", async () => { + memberToReturn = mockMemberData("owner", [], []); + await expect( + checkServiceAccess(ctx, "project-1", "create"), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index b422279ca..e07f34ade 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = { urlCallback: "", }, }, + whitelabelingConfig: { + appName: null, + appDescription: null, + logoUrl: null, + faviconUrl: null, + customCss: null, + loginLogoUrl: null, + supportUrl: null, + docsUrl: null, + errorPageTitle: null, + errorPageDescription: null, + metaTitle: null, + footerText: null, + }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx index 5d8943197..94efbc285 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx @@ -15,13 +15,17 @@ interface Props { } export const ShowTraefikConfig = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.traefikFiles.read ?? false; const { data, isPending } = api.application.readTraefikConfig.useQuery( { applicationId, }, - { enabled: !!applicationId }, + { enabled: !!applicationId && canRead }, ); + if (!canRead) return null; + return ( diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx index a8ec9053f..b3646803c 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx @@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => { }; export const UpdateTraefikConfig = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.traefikFiles.write ?? false; const [open, setOpen] = useState(false); const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { data, refetch } = api.application.readTraefikConfig.useQuery( @@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { } }} > - - - + {canWrite && ( + + + + )} Update traefik config diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx index 92b259140..bc2329f06 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -21,6 +21,13 @@ interface Props { } export const ShowVolumes = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.volume.read ?? false; + const canCreate = permissions?.volume.create ?? false; + const canDelete = permissions?.volume.delete ?? false; + + if (!canRead) return null; + const queryMap = { postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), @@ -50,7 +57,7 @@ export const ShowVolumes = ({ id, type }: Props) => { - {data && data?.mounts.length > 0 && ( + {canCreate && data && data?.mounts.length > 0 && ( Add Volume @@ -63,9 +70,11 @@ export const ShowVolumes = ({ id, type }: Props) => { No volumes/mounts configured - - Add Volume - + {canCreate && ( + + Add Volume + + )} ) : (
@@ -130,38 +139,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
- - { - await deleteVolume({ - mountId: mount.mountId, - }) - .then(() => { - refetch(); - toast.success("Volume deleted successfully"); + {canCreate && ( + + )} + {canDelete && ( + { + await deleteVolume({ + mountId: mount.mountId, }) - .catch(() => { - toast.error("Error deleting volume"); - }); - }} - > - - + + + )}
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 61841e294..3cecef1ec 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronUp, Clock, + Copy, Loader2, RefreshCcw, RocketIcon, @@ -10,6 +11,7 @@ import { } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import copy from "copy-to-clipboard"; import { AlertBlock } from "@/components/shared/alert-block"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -97,6 +99,12 @@ export const ShowDeployments = ({ new Set(), ); + const webhookUrl = useMemo( + () => + `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, + [url, refreshToken, type], + ); + const MAX_DESCRIPTION_LENGTH = 200; const truncateDescription = (description: string): string => { @@ -224,11 +232,27 @@ export const ShowDeployments = ({
Webhook URL:
- - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(webhookUrl); + toast.success("Copied to clipboard."); + } + }} + onClick={() => { + copy(webhookUrl); + toast.success("Copied to clipboard."); + }} + > + {webhookUrl} + + {(type === "application" || type === "compose") && ( )} diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index c207ba59c..06428ae21 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -50,6 +50,9 @@ interface Props { } export const ShowDomains = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canCreateDomain = permissions?.domain.create ?? false; + const canDeleteDomain = permissions?.domain.delete ?? false; const { data: application } = type === "application" ? api.application.one.useQuery( @@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
- {data && data?.length > 0 && ( + {canCreateDomain && data && data?.length > 0 && ( - -
+ {canCreateDomain && ( +
+ + + +
+ )}
) : (
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => { } /> )} - - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success( - "Domain deleted successfully", - ); + + + )} + {canDeleteDomain && ( + { + await deleteDomain({ + domainId: item.domainId, }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - + + + )}
diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx index 8ff0f6a63..4f695ac88 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx @@ -36,6 +36,8 @@ interface Props { } export const ShowEnvironment = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? false; const queryMap = { postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), @@ -185,25 +187,27 @@ PORT=3000 )} /> -
- {hasChanges && ( + {canWrite && ( +
+ {hasChanges && ( + + )} - )} - -
+
+ )} diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 04b6bc4c9..fcfd81778 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -31,6 +31,8 @@ interface Props { } export const ShowEnvironment = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? false; const { mutateAsync, isPending } = api.application.saveEnvironment.useMutation(); @@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => { )} /> )} -
- {hasChanges && ( - + )} + - )} - -
+
+ )}
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index d10925eff..a4fab46d9 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; +import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { Watch Paths - -
- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 624adeb55..37a387bb5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => { Watch Paths - -

- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx index ee42caa5e..01fc9e84a 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -30,6 +30,9 @@ interface Props { export const ShowGeneralApplication = ({ applicationId }: Props) => { const router = useRouter(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; + const canUpdateService = permissions?.service.create ?? false; const { data, refetch } = api.application.one.useQuery( { applicationId, @@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { - { - await deploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application deployed successfully"); - refetch(); - router.push( - `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`, - ); + {canDeploy && ( + { + await deploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error deploying application"); - }); - }} - > - - - { - await reload({ - applicationId: applicationId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Application reloaded successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await reload({ + applicationId: applicationId, + appName: data?.appName || "", }) - .catch(() => { - toast.error("Error reloading application"); - }); - }} - > - - - { - await redeploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application rebuilt successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await redeploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error rebuilding application"); - }); - }} - > - - + + + )} - {data?.applicationStatus === "idle" ? ( + {canDeploy && data?.applicationStatus === "idle" ? ( { - ) : ( + ) : canDeploy ? ( { - )} + ) : null} { Open Terminal -

- Autodeploy - { - await update({ - applicationId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + applicationId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )} -
- Clean Cache - { - await update({ - applicationId, - cleanCache: enabled, - }) - .then(async () => { - toast.success("Clean Cache Updated"); - await refetch(); + {canUpdateService && ( +
+ Clean Cache + { + await update({ + applicationId, + cleanCache: enabled, }) - .catch(() => { - toast.error("Error updating Clean Cache"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Clean Cache Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Clean Cache"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )} diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index 9d417ee91..f4db6ad4a 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -46,6 +46,8 @@ interface Props { } export const DeleteService = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDelete = permissions?.service.delete ?? false; const [isOpen, setIsOpen] = useState(false); const queryMap = { @@ -123,6 +125,8 @@ export const DeleteService = ({ id, type }: Props) => { data?.applicationStatus === "running") || (data && "composeStatus" in data && data?.composeStatus === "running"); + if (!canDelete) return null; + return ( diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index 8067a7db6..d04725e26 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -19,6 +19,9 @@ interface Props { } export const ComposeActions = ({ composeId }: Props) => { const router = useRouter(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; + const canUpdateService = permissions?.service.create ?? false; const { data, refetch } = api.compose.one.useQuery( { composeId, @@ -35,162 +38,169 @@ export const ComposeActions = ({ composeId }: Props) => { return (
- { - await deploy({ - composeId: composeId, - }) - .then(() => { - toast.success("Compose deployed successfully"); - refetch(); - router.push( - `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, - ); - }) - .catch(() => { - toast.error("Error deploying compose"); - }); - }} - > - - - { - await redeploy({ - composeId: composeId, - }) - .then(() => { - toast.success("Compose reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading compose"); - }); - }} - > - - - {data?.composeType === "docker-compose" && - data?.composeStatus === "idle" ? ( + {canDeploy && ( { - await start({ + await deploy({ composeId: composeId, }) .then(() => { - toast.success("Compose started successfully"); + toast.success("Compose deployed successfully"); refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); }) .catch(() => { - toast.error("Error starting compose"); + toast.error("Error deploying compose"); }); }} > - ) : ( + )} + {canDeploy && ( { - await stop({ + await redeploy({ composeId: composeId, }) .then(() => { - toast.success("Compose stopped successfully"); + toast.success("Compose reloaded successfully"); refetch(); }) .catch(() => { - toast.error("Error stopping compose"); + toast.error("Error reloading compose"); }); }} > )} + {canDeploy && + (data?.composeType === "docker-compose" && + data?.composeStatus === "idle" ? ( + { + await start({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting compose"); + }); + }} + > + + + ) : ( + { + await stop({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping compose"); + }); + }} + > + + + ))} { Open Terminal -
- Autodeploy - { - await update({ - composeId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + composeId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 8193ec8b6..28f958e3e 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -26,6 +26,8 @@ const AddComposeFile = z.object({ type AddComposeFile = z.infer; export const ComposeFileEditor = ({ composeId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canUpdate = permissions?.service.create ?? false; const utils = api.useUtils(); const { data, refetch } = api.compose.one.useQuery( { @@ -164,14 +166,16 @@ services:
- + {canUpdate && ( + + )}
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index 4ad4f741c..c84a55bb3 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { Watch Paths - -
- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index f77983996..1f0c6924c 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -45,10 +45,12 @@ import { import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; type User = typeof authClient.$Infer.Session.user; export const ImpersonationBar = () => { + const { config: whitelabeling } = useWhitelabeling(); const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [isImpersonating, setIsImpersonating] = useState(false); @@ -180,7 +182,10 @@ export const ImpersonationBar = () => { )} >

- + {!isImpersonating ? (
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx index 9d953279c..7c89d7b52 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx @@ -21,6 +21,8 @@ interface Props { } export const ShowGeneralMariadb = ({ mariadbId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.mariadb.one.useQuery( { mariadbId, @@ -72,154 +74,33 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => { Deploy Settings - - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - - - { - await reload({ - mariadbId: mariadbId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Mariadb reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading Mariadb"); - }); - }} - > - - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - mariadbId: mariadbId, - }) - .then(() => { - toast.success("Mariadb started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting Mariadb"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - - ) : ( - - { - await stop({ - mariadbId: mariadbId, - }) - .then(() => { - toast.success("Mariadb stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping Mariadb"); - }); - }} - > - + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + + { + await start({ + mariadbId: mariadbId, + }) + .then(() => { + toast.success("Mariadb started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Mariadb"); + }); + }} + > + + + + ) : ( + + { + await stop({ + mariadbId: mariadbId, + }) + .then(() => { + toast.success("Mariadb stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Mariadb"); + }); + }} + > + + + + ))} { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.mongo.one.useQuery( { mongoId, @@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - mongoId: mongoId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Mongo reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading Mongo"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - mongoId: mongoId, - }) - .then(() => { - toast.success("Mongo started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting Mongo"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - mongoId: mongoId, - }) - .then(() => { - toast.success("Mongo stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping Mongo"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + mongoId: mongoId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Mongo reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Mongo"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + mongoId: mongoId, + }) + .then(() => { + toast.success("Mongo started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Mongo"); + }); + }} + > + + + ) : ( + { + await stop({ + mongoId: mongoId, + }) + .then(() => { + toast.success("Mongo stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Mongo"); + }); + }} + > + + + ))} { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.mysql.one.useQuery( { mysqlId, @@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - mysqlId: mysqlId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("MySQL reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading MySQL"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - mysqlId: mysqlId, - }) - .then(() => { - toast.success("MySQL started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting MySQL"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - mysqlId: mysqlId, - }) - .then(() => { - toast.success("MySQL stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping MySQL"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + mysqlId: mysqlId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("MySQL reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading MySQL"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + mysqlId: mysqlId, + }) + .then(() => { + toast.success("MySQL started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting MySQL"); + }); + }} + > + + + ) : ( + { + await stop({ + mysqlId: mysqlId, + }) + .then(() => { + toast.success("MySQL stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping MySQL"); + }); + }} + > + + + ))} { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.postgres.one.useQuery( { postgresId: postgresId, @@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - postgresId: postgresId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("PostgreSQL reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading PostgreSQL"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - postgresId: postgresId, - }) - .then(() => { - toast.success("PostgreSQL started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting PostgreSQL"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - postgresId: postgresId, - }) - .then(() => { - toast.success("PostgreSQL stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping PostgreSQL"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + postgresId: postgresId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("PostgreSQL reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading PostgreSQL"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + postgresId: postgresId, + }) + .then(() => { + toast.success("PostgreSQL started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting PostgreSQL"); + }); + }} + > + + + ) : ( + { + await stop({ + postgresId: postgresId, + }) + .then(() => { + toast.success("PostgreSQL stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping PostgreSQL"); + }); + }} + > + + + ))} { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.environmentEnvVars.read ?? false; + const canWrite = permissions?.environmentEnvVars.write ?? false; const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); const { mutateAsync, error, isError, isPending } = @@ -97,6 +100,10 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { }; }, [form, onSubmit, isPending, isOpen]); + if (!canRead) { + return null; + } + return ( @@ -141,6 +148,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { )} /> - - - + {canWrite && ( + + + + )}
diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx index b02f9024a..46e5d1f54 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx @@ -39,6 +39,9 @@ interface Props { } export const ProjectEnvironment = ({ projectId, children }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.projectEnvVars.read ?? false; + const canWrite = permissions?.projectEnvVars.write ?? false; const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); const { mutateAsync, error, isError, isPending } = @@ -96,6 +99,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { }; }, [form, onSubmit, isPending, isOpen]); + if (!canRead) { + return null; + } + return ( @@ -139,6 +146,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { )} /> - - - + {canWrite && ( + + + + )}
diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index ce74eb8b3..c352503f7 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -13,6 +13,7 @@ import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; +import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { @@ -61,6 +62,7 @@ export const ShowProjects = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isPending } = api.project.all.useQuery(); const { data: auth } = api.user.get.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); const [searchQuery, setSearchQuery] = useState( @@ -165,12 +167,14 @@ export const ShowProjects = () => { return ( <> - {!isCloud && (
)} +
@@ -184,9 +188,7 @@ export const ShowProjects = () => { Create and manage your projects - {(auth?.role === "owner" || - auth?.role === "admin" || - auth?.canCreateProjects) && ( + {permissions?.project.create && (
@@ -359,8 +361,7 @@ export const ShowProjects = () => {
e.stopPropagation()} > - {(auth?.role === "owner" || - auth?.canDeleteProjects) && ( + {permissions?.project.delete && ( { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.redis.one.useQuery( { redisId, @@ -72,153 +74,158 @@ export const ShowGeneralRedis = ({ redisId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - redisId: redisId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Redis reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading Redis"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - redisId: redisId, - }) - .then(() => { - toast.success("Redis started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting Redis"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - redisId: redisId, - }) - .then(() => { - toast.success("Redis stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping Redis"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + redisId: redisId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Redis reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Redis"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + redisId: redisId, + }) + .then(() => { + toast.success("Redis started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Redis"); + }); + }} + > + + + ) : ( + { + await stop({ + redisId: redisId, + }) + .then(() => { + toast.success("Redis stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Redis"); + }); + }} + > + + + ))} { api.stripe.upgradeSubscription.useMutation(); const utils = api.useUtils(); - const [serverQuantity, setServerQuantity] = useState(3); + const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1); + const [startupServerQuantity, setStartupServerQuantity] = useState( + STARTUP_SERVERS_INCLUDED, + ); const [isAnnual, setIsAnnual] = useState(false); const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>( null, @@ -111,6 +114,12 @@ export const ShowBilling = () => { productId: string, ) => { const stripe = await stripePromise; + const serverQuantity = + tier === "startup" + ? startupServerQuantity + : tier === "hobby" + ? hobbyServerQuantity + : hobbyServerQuantity; if (data && data.subscriptions.length === 0) { createCheckoutSession({ tier, @@ -679,7 +688,7 @@ export const ShowBilling = () => {

$ {calculatePriceHobby( - serverQuantity, + hobbyServerQuantity, isAnnual, ).toFixed(2)} /{isAnnual ? "yr" : "mo"} @@ -692,7 +701,8 @@ export const ShowBilling = () => {

$ {( - calculatePriceHobby(serverQuantity, true) / 12 + calculatePriceHobby(hobbyServerQuantity, true) / + 12 ).toFixed(2)} /mo

@@ -724,19 +734,19 @@ export const ShowBilling = () => { Servers: - setServerQuantity( + setHobbyServerQuantity( Math.max( 1, Number( @@ -750,7 +760,7 @@ export const ShowBilling = () => { @@ -775,7 +785,7 @@ export const ShowBilling = () => { onClick={() => handleCheckout("hobby", data!.hobbyProductId!) } - disabled={serverQuantity < 1} + disabled={hobbyServerQuantity < 1} > Get Started @@ -806,7 +816,7 @@ export const ShowBilling = () => {

$ {calculatePriceStartup( - serverQuantity, + startupServerQuantity, isAnnual, ).toFixed(2)} /{isAnnual ? "yr" : "mo"} @@ -819,7 +829,10 @@ export const ShowBilling = () => {

$ {( - calculatePriceStartup(serverQuantity, true) / 12 + calculatePriceStartup( + startupServerQuantity, + true, + ) / 12 ).toFixed(2)} /mo

@@ -856,13 +869,14 @@ export const ShowBilling = () => {
- setServerQuantity( + setStartupServerQuantity( Math.max( STARTUP_SERVERS_INCLUDED, Number( @@ -887,7 +901,9 @@ export const ShowBilling = () => { variant="outline" size="icon" className="h-8 w-8" - onClick={() => setServerQuantity((q) => q + 1)} + onClick={() => + setStartupServerQuantity((q) => q + 1) + } > @@ -917,7 +933,7 @@ export const ShowBilling = () => { ) } disabled={ - serverQuantity < STARTUP_SERVERS_INCLUDED + startupServerQuantity < STARTUP_SERVERS_INCLUDED } > Get Started @@ -1009,7 +1025,7 @@ export const ShowBilling = () => {

${" "} {calculatePrice( - serverQuantity, + hobbyServerQuantity, isAnnual, ).toFixed(2)}{" "} USD @@ -1018,7 +1034,10 @@ export const ShowBilling = () => {

${" "} {( - calculatePrice(serverQuantity, isAnnual) / 12 + calculatePrice( + hobbyServerQuantity, + isAnnual, + ) / 12 ).toFixed(2)}{" "} / Month USD

@@ -1026,9 +1045,10 @@ export const ShowBilling = () => { ) : (

${" "} - {calculatePrice(serverQuantity, isAnnual).toFixed( - 2, - )}{" "} + {calculatePrice( + hobbyServerQuantity, + isAnnual, + ).toFixed(2)}{" "} USD

)} @@ -1071,26 +1091,28 @@ export const ShowBilling = () => {
- {serverQuantity} Servers + {hobbyServerQuantity} Servers
{ - setServerQuantity( + setHobbyServerQuantity( e.target.value as unknown as number, ); }} @@ -1099,7 +1121,9 @@ export const ShowBilling = () => { diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index e861c9027..76c54cdfa 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -18,6 +18,7 @@ export const ShowCertificates = () => { const { mutateAsync, isPending: isRemoving } = api.certificates.remove.useMutation(); const { data, isPending, refetch } = api.certificates.all.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -53,7 +54,7 @@ export const ShowCertificates = () => { You don't have any certificates created - + {permissions?.certificate.create && }
) : (
@@ -101,47 +102,52 @@ export const ShowCertificates = () => {
-
- { - await mutateAsync({ - certificateId: certificate.certificateId, - }) - .then(() => { - toast.success( - "Certificate deleted successfully", - ); - refetch(); + {permissions?.certificate.delete && ( +
+ { + await mutateAsync({ + certificateId: + certificate.certificateId, }) - .catch(() => { - toast.error( - "Error deleting certificate", - ); - }); - }} - > - - -
+ +
+
+ )}
); })}
-
- -
+ {permissions?.certificate.create && ( +
+ +
+ )}
)} diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx index cbaf5de2f..86deb38a7 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx @@ -16,6 +16,7 @@ export const ShowRegistry = () => { const { mutateAsync, isPending: isRemoving } = api.registry.remove.useMutation(); const { data, isPending, refetch } = api.registry.all.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -44,7 +45,7 @@ export const ShowRegistry = () => { You don't have any registry configurations - + {permissions?.registry.create && }
) : (
@@ -73,45 +74,49 @@ export const ShowRegistry = () => { registryId={registry.registryId} /> - { - await mutateAsync({ - registryId: registry.registryId, - }) - .then(() => { - toast.success( - "Registry configuration deleted successfully", - ); - refetch(); + {permissions?.registry.delete && ( + { + await mutateAsync({ + registryId: registry.registryId, }) - .catch(() => { - toast.error( - "Error deleting registry configuration", - ); - }); - }} - > - - + + + )}
))} -
- -
+ {permissions?.registry.create && ( +
+ +
+ )} )} diff --git a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx index 3cb29d54a..39741b932 100644 --- a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx @@ -16,6 +16,7 @@ export const ShowDestinations = () => { const { data, isPending, refetch } = api.destination.all.useQuery(); const { mutateAsync, isPending: isRemoving } = api.destination.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -45,7 +46,7 @@ export const ShowDestinations = () => { To create a backup it is required to set at least 1 provider. - + {permissions?.destination.create && }
) : (
@@ -71,43 +72,49 @@ export const ShowDestinations = () => { - { - await mutateAsync({ - destinationId: destination.destinationId, - }) - .then(() => { - toast.success( - "Destination deleted successfully", - ); - refetch(); + {permissions?.destination.delete && ( + { + await mutateAsync({ + destinationId: destination.destinationId, }) - .catch(() => { - toast.error("Error deleting destination"); - }); - }} - > - - + + + )}
))} -
- -
+ {permissions?.destination.create && ( +
+ +
+ )} )} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index 6ef1e15db..c4e549573 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -737,6 +737,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); setVisible(false); await utils.notification.all.invalidate(); + if (notificationId) { + await utils.notification.one.invalidate({ notificationId }); + } }) .catch(() => { toast.error( diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index d8ac31d97..3d62658ae 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -26,6 +26,7 @@ export const ShowNotifications = () => { const { data, isPending, refetch } = api.notification.all.useQuery(); const { mutateAsync, isPending: isRemoving } = api.notification.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -56,7 +57,9 @@ export const ShowNotifications = () => { To send notifications it is required to set at least 1 provider. - + {permissions?.notification.create && ( + + )}
) : (
@@ -126,45 +129,50 @@ export const ShowNotifications = () => { notificationId={notification.notificationId} /> - { - await mutateAsync({ - notificationId: notification.notificationId, - }) - .then(() => { - toast.success( - "Notification deleted successfully", - ); - refetch(); + {permissions?.notification.delete && ( + { + await mutateAsync({ + notificationId: + notification.notificationId, }) - .catch(() => { - toast.error( - "Error deleting notification", - ); - }); - }} - > - - + + + )}
))} -
- -
+ {permissions?.notification.create && ( +
+ +
+ )} )} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 45ee314d4..859098394 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -59,6 +59,7 @@ export const ShowServers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: canCreateMoreServers } = api.stripe.canCreateMoreServers.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -115,7 +116,7 @@ export const ShowServers = () => { Start adding servers to deploy your applications remotely. - + {permissions?.server.create && }
) : (
@@ -362,66 +363,71 @@ export const ShowServers = () => {
- - -
- - You can not delete this - server because it has - active services. - - You have active services - associated with this - server, please delete - them first. - -
- ) - } - onClick={async () => { - await mutateAsync({ - serverId: server.serverId, - }) - .then(() => { - refetch(); - toast.success( - `Server ${server.name} deleted successfully`, - ); + {permissions?.server.delete && ( + + +
+ + You can not delete this + server because it has + active services. + + You have active + services associated + with this server, + please delete them + first. + +
+ ) + } + onClick={async () => { + await mutateAsync({ + serverId: server.serverId, }) - .catch((err) => { - toast.error(err.message); - }); - }} - > - - -
- - -

- {canDelete - ? "Delete Server" - : "Cannot delete - has active services"} -

-
- + + +
+
+ +

+ {canDelete + ? "Delete Server" + : "Cannot delete - has active services"} +

+
+
+ )}
)} @@ -431,13 +437,15 @@ export const ShowServers = () => { })} -
- {data && data?.length > 0 && ( -
- -
- )} -
+ {permissions?.server.create && ( +
+ {data && data?.length > 0 && ( +
+ +
+ )} +
+ )} )} diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx index 6fd62e462..86ea0a2ea 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx @@ -17,6 +17,7 @@ export const ShowDestinations = () => { const { data, isPending, refetch } = api.sshKey.all.useQuery(); const { mutateAsync, isPending: isRemoving } = api.sshKey.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -46,7 +47,7 @@ export const ShowDestinations = () => { You don't have any SSH keys - + {permissions?.sshKeys.create && }
) : (
@@ -84,43 +85,47 @@ export const ShowDestinations = () => {
- { - await mutateAsync({ - sshKeyId: sshKey.sshKeyId, - }) - .then(() => { - toast.success( - "SSH Key deleted successfully", - ); - refetch(); + {permissions?.sshKeys.delete && ( + { + await mutateAsync({ + sshKeyId: sshKey.sshKeyId, }) - .catch(() => { - toast.error("Error deleting SSH Key"); - }); - }} - > - - + + + )}
))} -
- -
+ {permissions?.sshKeys.create && ( +
+ +
+ )} )} diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 10b46ef53..f9dce77c9 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -32,7 +32,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; const addInvitation = z.object({ @@ -40,7 +39,7 @@ const addInvitation = z.object({ .string() .min(1, "Email is required") .email({ message: "Invalid email" }), - role: z.enum(["member", "admin"]), + role: z.string().min(1, "Role is required"), notificationId: z.string().optional(), }); @@ -49,13 +48,14 @@ type AddInvitation = z.infer; export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); - const [isLoading, setIsLoading] = useState(false); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: emailProviders } = api.notification.getEmailProviders.useQuery(); + const { mutateAsync: inviteMember, isPending: isInviting } = + api.organization.inviteMember.useMutation(); const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); + const { data: customRoles } = api.customRole.all.useQuery(); const [error, setError] = useState(null); - const { data: activeOrganization } = api.organization.active.useQuery(); const form = useForm({ defaultValues: { @@ -70,19 +70,15 @@ export const AddInvitation = () => { }, [form, form.formState.isSubmitSuccessful, form.reset]); const onSubmit = async (data: AddInvitation) => { - setIsLoading(true); - const result = await authClient.organization.inviteMember({ - email: data.email.toLowerCase(), - role: data.role, - organizationId: activeOrganization?.id, - }); + try { + const result = await inviteMember({ + email: data.email.toLowerCase(), + role: data.role, + }); - if (result.error) { - setError(result.error.message || ""); - } else { if (!isCloud && data.notificationId) { await sendInvitation({ - invitationId: result.data.id, + invitationId: result!.id, notificationId: data.notificationId || "", }) .then(() => { @@ -96,10 +92,11 @@ export const AddInvitation = () => { } setError(null); setOpen(false); + } catch (error: any) { + setError(error.message || "Failed to create invitation"); } utils.organization.allInvitations.invalidate(); - setIsLoading(false); }; return ( @@ -159,6 +156,11 @@ export const AddInvitation = () => { Member Admin + {customRoles?.map((role) => ( + + {role.role} + + ))} @@ -212,7 +214,7 @@ export const AddInvitation = () => { )} + + + + + + ); +} diff --git a/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx b/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx new file mode 100644 index 000000000..7f1851493 --- /dev/null +++ b/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx @@ -0,0 +1,112 @@ +import { ClipboardList } from "lucide-react"; +import React from "react"; +import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { columns } from "./columns"; +import { type AuditLogFilters, DataTable } from "./data-table"; + +function AuditLogsContent() { + const [pageIndex, setPageIndex] = React.useState(0); + const [pageSize, setPageSize] = React.useState(50); + const [filters, setFilters] = React.useState({ + userEmail: "", + resourceName: "", + action: "", + resourceType: "", + dateRange: undefined, + }); + + const [debouncedText, setDebouncedText] = React.useState({ + userEmail: "", + resourceName: "", + }); + + React.useEffect(() => { + const t = setTimeout(() => { + setDebouncedText({ + userEmail: filters.userEmail, + resourceName: filters.resourceName, + }); + setPageIndex(0); + }, 400); + return () => clearTimeout(t); + }, [filters.userEmail, filters.resourceName]); + + const handleFilterChange = ( + key: K, + value: AuditLogFilters[K], + ) => { + setFilters((prev) => ({ ...prev, [key]: value })); + if (key !== "userEmail" && key !== "resourceName") { + setPageIndex(0); + } + }; + + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setPageIndex(0); + }; + + const { data, isLoading } = api.auditLog.all.useQuery({ + userEmail: debouncedText.userEmail || undefined, + resourceName: debouncedText.resourceName || undefined, + action: filters.action || undefined, + resourceType: filters.resourceType || undefined, + from: filters.dateRange?.from, + to: filters.dateRange?.to, + limit: pageSize, + offset: pageIndex * pageSize, + }); + + return ( + + ); +} + +export function ShowAuditLogs() { + return ( + +
+ + + + + Audit Logs + + + Track all actions performed by members in your organization. + + + + + + +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx new file mode 100644 index 000000000..a93cb87c6 --- /dev/null +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -0,0 +1,1032 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { + Loader2, + PlusIcon, + ShieldCheck, + Sparkles, + TrashIcon, + Users, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +/** Labels and descriptions for each resource */ +const RESOURCE_META: Record = { + project: { + label: "Projects", + description: "Manage project creation and deletion", + }, + service: { + label: "Services", + description: + "Manage services (applications, databases, compose) within projects", + }, + environment: { + label: "Environments", + description: "Manage environment creation, viewing, and deletion", + }, + docker: { + label: "Docker", + description: "Access to Docker containers, images, and volumes management", + }, + sshKeys: { + label: "SSH Keys", + description: "Manage SSH key configurations for servers and repositories", + }, + gitProviders: { + label: "Git Providers", + description: "Access to Git providers (GitHub, GitLab, Bitbucket, Gitea)", + }, + traefikFiles: { + label: "Traefik Files", + description: "Access to the Traefik file system configuration", + }, + api: { + label: "API / CLI", + description: "Access to API keys and CLI usage", + }, + // Enterprise-only resources + volume: { + label: "Volumes", + description: "Manage persistent volumes and mounts attached to services", + }, + deployment: { + label: "Deployments", + description: "Trigger, view, and cancel service deployments", + }, + envVars: { + label: "Service Env Vars", + description: "View and edit environment variables of services", + }, + projectEnvVars: { + label: "Project Shared Env Vars", + description: "View and edit shared environment variables at project level", + }, + environmentEnvVars: { + label: "Environment Shared Env Vars", + description: + "View and edit shared environment variables at environment level", + }, + server: { + label: "Servers", + description: "Manage remote servers and nodes", + }, + registry: { + label: "Registries", + description: "Manage Docker image registries", + }, + certificate: { + label: "Certificates", + description: "Manage SSL/TLS certificates", + }, + backup: { + label: "Backups", + description: "Manage database backups and restores", + }, + volumeBackup: { + label: "Volume Backups", + description: "Manage Docker volume backups and restores", + }, + schedule: { + label: "Schedules", + description: "Manage scheduled jobs (commands, deployments, scripts)", + }, + domain: { + label: "Domains", + description: "Manage custom domains assigned to services", + }, + destination: { + label: "S3 Destinations", + description: + "Manage S3-compatible backup destinations (AWS, Cloudflare R2, etc.)", + }, + notification: { + label: "Notifications", + description: + "Manage notification providers (Slack, Discord, Telegram, etc.)", + }, + member: { + label: "Users", + description: "Manage organization members, invitations, and roles", + }, + logs: { + label: "Logs", + description: "View service and deployment logs", + }, + monitoring: { + label: "Monitoring", + description: "View server and service metrics (CPU, RAM, disk)", + }, + auditLog: { + label: "Audit Logs", + description: "View the audit log of actions performed in the organization", + }, +}; + +/** Descriptions for each action within a resource */ +const ACTION_META: Record< + string, + Record +> = { + project: { + create: { label: "Create", description: "Create new projects" }, + delete: { + label: "Delete", + description: "Delete projects and all their content", + }, + }, + service: { + create: { + label: "Create", + description: "Create new services inside projects", + }, + read: { + label: "Read", + description: "View services, logs, and deployments", + }, + delete: { + label: "Delete", + description: "Delete services from projects", + }, + }, + environment: { + create: { + label: "Create", + description: "Create new environments in projects", + }, + read: { + label: "Read", + description: "View environments and their services", + }, + delete: { + label: "Delete", + description: "Delete environments and their content", + }, + }, + docker: { + read: { + label: "Read", + description: "View Docker containers, images, networks, and volumes", + }, + }, + sshKeys: { + read: { + label: "Read", + description: "View SSH key configurations", + }, + create: { + label: "Create", + description: "Create and edit SSH keys", + }, + delete: { + label: "Delete", + description: "Remove SSH keys", + }, + }, + gitProviders: { + read: { + label: "Read", + description: "View Git provider connections", + }, + create: { + label: "Create", + description: "Create and update Git provider connections", + }, + delete: { + label: "Delete", + description: "Remove Git provider connections", + }, + }, + traefikFiles: { + read: { + label: "Read", + description: "View Traefik configuration files", + }, + write: { + label: "Write", + description: "Edit and save Traefik configuration files", + }, + }, + api: { + read: { + label: "Read", + description: "Create and manage API keys for CLI access", + }, + }, + volume: { + read: { + label: "Read", + description: "View volumes and mounts attached to services", + }, + create: { label: "Create", description: "Add and edit volumes and mounts" }, + delete: { + label: "Delete", + description: "Remove volumes and mounts from services", + }, + }, + deployment: { + read: { label: "Read", description: "View deployment history and status" }, + create: { + label: "Deploy", + description: "Trigger new deployments manually", + }, + cancel: { label: "Cancel", description: "Cancel running deployments" }, + }, + envVars: { + read: { label: "Read", description: "View environment variable values" }, + write: { + label: "Write", + description: "Create, update, and delete environment variables", + }, + }, + projectEnvVars: { + read: { + label: "Read", + description: "View project-level shared environment variables", + }, + write: { + label: "Write", + description: "Edit project-level shared environment variables", + }, + }, + environmentEnvVars: { + read: { + label: "Read", + description: "View environment-level shared environment variables", + }, + write: { + label: "Write", + description: "Edit environment-level shared environment variables", + }, + }, + server: { + read: { + label: "Read", + description: "View server list and connection details", + }, + create: { label: "Create", description: "Add new remote servers" }, + delete: { + label: "Delete", + description: "Remove servers from the organization", + }, + }, + registry: { + read: { label: "Read", description: "View configured Docker registries" }, + create: { label: "Create", description: "Add new Docker registries" }, + delete: { label: "Delete", description: "Remove Docker registries" }, + }, + certificate: { + read: { label: "Read", description: "View SSL/TLS certificates" }, + create: { + label: "Create", + description: "Issue and configure new certificates", + }, + delete: { label: "Delete", description: "Remove certificates" }, + }, + backup: { + read: { label: "Read", description: "View backup history and status" }, + create: { label: "Create", description: "Trigger manual backups" }, + delete: { label: "Delete", description: "Delete backup files" }, + restore: { + label: "Restore", + description: "Restore a database from a backup", + }, + }, + volumeBackup: { + read: { + label: "Read", + description: "View volume backup history and status", + }, + create: { + label: "Create", + description: "Create and trigger volume backups", + }, + update: { + label: "Update", + description: "Update volume backup configuration", + }, + delete: { label: "Delete", description: "Delete volume backup files" }, + restore: { + label: "Restore", + description: "Restore a Docker volume from a backup", + }, + }, + schedule: { + read: { + label: "Read", + description: "View scheduled jobs and their history", + }, + create: { label: "Create", description: "Create and run scheduled jobs" }, + update: { + label: "Update", + description: "Update scheduled job configuration", + }, + delete: { label: "Delete", description: "Delete scheduled jobs" }, + }, + domain: { + read: { label: "Read", description: "View domains assigned to services" }, + create: { label: "Create", description: "Assign new domains to services" }, + delete: { label: "Delete", description: "Remove domains from services" }, + }, + destination: { + read: { label: "Read", description: "View S3 backup destinations" }, + create: { label: "Create", description: "Add and edit S3 destinations" }, + delete: { label: "Delete", description: "Remove S3 destinations" }, + }, + notification: { + read: { label: "Read", description: "View notification providers" }, + create: { + label: "Create", + description: "Add and edit notification providers", + }, + delete: { label: "Delete", description: "Remove notification providers" }, + }, + member: { + read: { + label: "Read", + description: "View the list of organization members", + }, + create: { + label: "Create", + description: "Invite new members to the organization", + }, + update: { + label: "Update", + description: "Change member roles and permissions", + }, + delete: { + label: "Delete", + description: "Remove members from the organization", + }, + }, + logs: { + read: { label: "Read", description: "View real-time and historical logs" }, + }, + monitoring: { + read: { + label: "Read", + description: "View CPU, RAM, disk, and network metrics", + }, + }, + auditLog: { + read: { label: "Read", description: "View the audit log history" }, + }, +}; + +/** Resources that should be hidden from the custom role editor (better-auth internals) */ +const HIDDEN_RESOURCES = ["organization", "invitation", "team", "ac"]; + +/** Predefined role presets with sensible permission defaults */ +const ROLE_PRESETS: { + name: string; + label: string; + description: string; + permissions: Record; +}[] = [ + { + name: "viewer", + label: "Viewer", + description: "Read-only access across all resources", + permissions: { + service: ["read"], + environment: ["read"], + docker: ["read"], + sshKeys: ["read"], + gitProviders: ["read"], + traefikFiles: ["read"], + api: ["read"], + volume: ["read"], + deployment: ["read"], + envVars: ["read"], + projectEnvVars: ["read"], + environmentEnvVars: ["read"], + server: ["read"], + registry: ["read"], + certificate: ["read"], + backup: ["read"], + volumeBackup: ["read"], + schedule: ["read"], + domain: ["read"], + destination: ["read"], + notification: ["read"], + member: ["read"], + logs: ["read"], + monitoring: ["read"], + auditLog: ["read"], + }, + }, + { + name: "developer", + label: "Developer", + description: "Deploy services, manage env vars, domains, and view logs", + permissions: { + project: ["create"], + service: ["create", "read"], + environment: ["create", "read"], + docker: ["read"], + gitProviders: ["read"], + api: ["read"], + volume: ["read", "create", "delete"], + deployment: ["read", "create", "cancel"], + envVars: ["read", "write"], + projectEnvVars: ["read"], + environmentEnvVars: ["read"], + domain: ["read", "create", "delete"], + schedule: ["read", "create", "update", "delete"], + logs: ["read"], + monitoring: ["read"], + }, + }, + { + name: "deployer", + label: "Deployer", + description: "Trigger and manage deployments only", + permissions: { + service: ["read"], + environment: ["read"], + deployment: ["read", "create", "cancel"], + logs: ["read"], + monitoring: ["read"], + }, + }, + { + name: "devops", + label: "DevOps", + description: + "Full infrastructure access: servers, registries, certs, backups, and deployments", + permissions: { + project: ["create", "delete"], + service: ["create", "read", "delete"], + environment: ["create", "read", "delete"], + docker: ["read"], + sshKeys: ["read", "create", "delete"], + gitProviders: ["read", "create", "delete"], + traefikFiles: ["read", "write"], + api: ["read"], + volume: ["read", "create", "delete"], + deployment: ["read", "create", "cancel"], + envVars: ["read", "write"], + projectEnvVars: ["read", "write"], + environmentEnvVars: ["read", "write"], + server: ["read", "create", "delete"], + registry: ["read", "create", "delete"], + certificate: ["read", "create", "delete"], + backup: ["read", "create", "delete", "restore"], + volumeBackup: ["read", "create", "update", "delete", "restore"], + schedule: ["read", "create", "update", "delete"], + domain: ["read", "create", "delete"], + destination: ["read", "create", "delete"], + notification: ["read", "create", "delete"], + logs: ["read"], + monitoring: ["read"], + auditLog: ["read"], + }, + }, +]; + +const createRoleSchema = z.object({ + roleName: z + .string() + .min(1, "Role name is required") + .max(50, "Role name must be 50 characters or less") + .regex( + /^[a-zA-Z0-9_-]+$/, + "Only letters, numbers, hyphens, and underscores allowed", + ), +}); + +type CreateRoleSchema = z.infer; + +export const ManageCustomRoles = () => { + return ( + +
+ + + + Custom Roles + + + Create and manage custom roles with fine-grained permissions + + + + + + + +
+
+ ); +}; + +interface HandleCustomRoleProps { + roleName?: string; + initialPermissions?: Record; + onSuccess: () => void; +} + +function HandleCustomRole({ + roleName, + initialPermissions, + onSuccess, +}: HandleCustomRoleProps) { + const [open, setOpen] = useState(false); + const [permissions, setPermissions] = useState>({}); + const { data: statements } = api.customRole.getStatements.useQuery(); + const isEdit = !!roleName; + + const form = useForm({ + defaultValues: { roleName: "" }, + resolver: zodResolver(createRoleSchema), + }); + + useEffect(() => { + if (open) { + setPermissions(initialPermissions ? { ...initialPermissions } : {}); + form.reset({ roleName: isEdit ? (roleName ?? "") : "" }); + } + }, [open]); + + const { mutateAsync: createRole, isPending: isCreating } = + api.customRole.create.useMutation(); + const { mutateAsync: updateRole, isPending: isUpdating } = + api.customRole.update.useMutation(); + + const visibleResources = statements + ? Object.entries(statements).filter( + ([key]) => !HIDDEN_RESOURCES.includes(key), + ) + : []; + + const togglePermission = (resource: string, action: string) => { + setPermissions((prev) => { + const current = prev[resource] || []; + const has = current.includes(action); + return { + ...prev, + [resource]: has + ? current.filter((a) => a !== action) + : [...current, action], + }; + }); + }; + + const handleSubmit = async (data: CreateRoleSchema) => { + try { + if (isEdit) { + const newName = data.roleName !== roleName ? data.roleName : undefined; + await updateRole({ + roleName: roleName!, + newRoleName: newName, + permissions, + }); + toast.success(`Role "${newName ?? roleName}" updated`); + } else { + await createRole({ roleName: data.roleName, permissions }); + toast.success(`Role "${data.roleName}" created`); + } + if (!isEdit) { + setOpen(false); + } + onSuccess(); + } catch (error) { + let message = `Error ${isEdit ? "updating" : "creating"} role`; + if (error instanceof Error) { + try { + const parsed = JSON.parse(error.message); + if (Array.isArray(parsed) && parsed[0]?.message) { + message = parsed[0].message; + } else { + message = error.message; + } + } catch { + message = error.message; + } + } + toast.error(message); + } + }; + + return ( + + + {isEdit ? ( + + ) : ( + + )} + + + + + {isEdit ? "Edit Role" : "Create Custom Role"} + + + {isEdit + ? "Update permissions for this role" + : "Define a new role with specific permissions"} + + +
+ + ( + + Role Name + + + + + + )} + /> + + + {!isEdit && ( +
+

+ + Start from a preset +

+
+ {ROLE_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + + + +
+
+ ); +} + +const CustomRolesContent = () => { + const { + data: customRoles, + isPending, + refetch, + } = api.customRole.all.useQuery(); + const { mutateAsync: deleteRole } = api.customRole.remove.useMutation(); + + const handleDelete = async (roleName: string) => { + try { + await deleteRole({ roleName }); + toast.success(`Role "${roleName}" deleted`); + refetch(); + } catch (error) { + let message = "Error deleting role"; + if (error instanceof Error) { + try { + const parsed = JSON.parse(error.message); + message = + Array.isArray(parsed) && parsed[0]?.message + ? parsed[0].message + : error.message; + } catch { + message = error.message; + } + } + toast.error(message); + } + }; + + if (isPending) { + return ( +
+ Loading... + +
+ ); + } + + return ( +
+
+ +
+ + {customRoles?.length === 0 ? ( +
+
+ +
+
+

No custom roles yet

+

+ Create a role to define fine-grained access for your team members. +

+
+
+ ) : ( +
+ {customRoles?.map((role) => { + const totalPermissions = Object.values(role.permissions).flat() + .length; + const enabledResources = Object.entries(role.permissions).filter( + ([, actions]) => (actions as string[]).length > 0, + ); + return ( +
+
+
+
+ +
+
+
+

+ {role.role} +

+ {role.memberCount > 0 && ( + + )} +
+

+ {enabledResources.length} resource + {enabledResources.length !== 1 ? "s" : ""} ·{" "} + {totalPermissions} permission + {totalPermissions !== 1 ? "s" : ""} +

+
+
+
+ + + {role.memberCount > 0 && ( + + + {role.memberCount} member + {role.memberCount !== 1 ? "s are" : " is"}{" "} + currently assigned + {" "} + to this role. Reassign them before deleting. + + )} + + Are you sure you want to delete the{" "} + "{role.role}" role? This action + cannot be undone. + +
+ } + disabled={role.memberCount > 0} + type="destructive" + onClick={() => handleDelete(role.role)} + > + + +
+
+ + {enabledResources.length > 0 && ( +
+ {enabledResources.map(([resource, actions]) => ( +
+ + {RESOURCE_META[resource]?.label || resource} + + · + + {(actions as string[]) + .map((a) => ACTION_META[resource]?.[a]?.label || a) + .join(", ")} + +
+ ))} +
+ )} +
+ ); + })} +
+ )} + + ); +}; + +function MembersBadge({ + roleName, + count, +}: { + roleName: string; + count: number; +}) { + const [open, setOpen] = useState(false); + const { data: members, isLoading } = api.customRole.membersByRole.useQuery( + { roleName }, + { enabled: open }, + ); + return ( + + + + + +

+ Assigned members +

+ {isLoading ? ( +
+ +
+ ) : members && members.length > 0 ? ( +
    + {members.map((m) => ( +
  • +
    + {(m.firstName?.[0] || m.email?.[0] || "?").toUpperCase()} +
    +
    + {(m.firstName || m.lastName) && ( +

    + {[m.firstName, m.lastName].filter(Boolean).join(" ")} +

    + )} +

    + {m.email} +

    +
    +
  • + ))} +
+ ) : ( +

+ No members found. +

+ )} +
+
+ ); +} + +/** Reusable permission toggle grid with descriptions */ +function PermissionEditor({ + resources, + permissions, + onToggle, +}: { + resources: [string, readonly string[]][]; + permissions: Record; + onToggle: (resource: string, action: string) => void; +}) { + return ( +
+

Permissions

+
+ {resources.map(([resource, actions]) => { + const meta = RESOURCE_META[resource]; + return ( +
+
+

{meta?.label || resource}

+ {meta?.description && ( +

+ {meta.description} +

+ )} +
+
+ {actions.map((action) => { + const actionMeta = ACTION_META[resource]?.[action]; + return ( +
onToggle(resource, action)} + > + onToggle(resource, action)} + /> +
+ + {actionMeta?.label || action} + +
+
+ ); + })} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx new file mode 100644 index 000000000..d53af90a2 --- /dev/null +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface WhitelabelingPreviewProps { + config: { + appName?: string; + logoUrl?: string; + footerText?: string; + }; +} + +export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) { + const appName = config.appName || "Dokploy"; + + return ( + + + Live Preview + + A quick preview of how your branding changes will look. + + + +
+ {/* Simulated sidebar header */} +
+ {config.logoUrl ? ( + Preview Logo + ) : ( +
+ {appName.charAt(0).toUpperCase()} +
+ )} + {appName} +
+ + {/* Simulated content area */} +
+
+
+
+
+
+
+ Button +
+
+ Secondary +
+
+
+ + {/* Simulated footer */} + {config.footerText && ( +
+ {config.footerText} +
+ )} +
+ + + ); +} diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx new file mode 100644 index 000000000..a1a327561 --- /dev/null +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx @@ -0,0 +1,31 @@ +"use client"; + +import Head from "next/head"; +import { api } from "@/utils/api"; + +export function WhitelabelingProvider() { + const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + if (!config) return null; + + return ( + <> + + {config.metaTitle && {config.metaTitle}} + {config.faviconUrl && } + + + {config.customCss && ( +