Compare commits

..

6 Commits

Author SHA1 Message Date
Mauricio Siu
80d5313dd8 Merge branch 'canary' into 1365-create-preview-deployment-using-api 2025-07-13 20:49:12 -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
Mauricio Siu
da0e726326 feat(preview-deployment): enhance external deployment support
- Add support for external preview deployments with optional GitHub comment handling
- Modify deployment services to conditionally update GitHub issue comments
- Update queue types and deployment worker to handle external deployment flag
- Refactor preview deployment creation to support external deployments
- Improve preview deployment router with more flexible deployment creation logic
2025-03-08 17:07:07 -06:00
114 changed files with 1697 additions and 8780 deletions

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

@@ -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

@@ -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

@@ -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

@@ -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,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
},
);
const enabled = data?.enableDockerCleanup || server?.enableDockerCleanup;
const enabled = data?.user.enableDockerCleanup || server?.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

@@ -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 = {
@@ -102,6 +102,10 @@ type SingleNavItem = {
}) => 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,6 +119,8 @@ type NavItem =
}) => boolean;
};
// ExternalLink type
// Represents an external link item (used for the help section)
type ExternalLink = {
name: string;
url: string;
@@ -125,12 +131,18 @@ type ExternalLink = {
}) => 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 +165,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 +175,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 +186,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 +195,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 +204,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 +271,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 +285,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 +293,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 +302,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 +318,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 +326,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 +334,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 +343,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 +351,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 +359,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 +367,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 +505,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 +663,7 @@ function SidebarLogo() {
)}
</div>
))}
{(user?.role?.name === "owner" || isCloud) && (
{(user?.role === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
@@ -989,7 +1029,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");

File diff suppressed because it is too large Load Diff

View File

@@ -722,13 +722,6 @@
"when": 1751848685503,
"tag": "0102_opposite_grandmaster",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1752428260850,
"tag": "0103_brainy_nehzno",
"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.2",
"private": true,
"license": "Apache-2.0",
"type": "module",

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

@@ -37,6 +37,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";
@@ -53,7 +54,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 +88,7 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
return (
<div className="pb-10">
@@ -178,9 +179,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

@@ -33,6 +33,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";
@@ -50,7 +51,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 +78,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 +171,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

@@ -38,8 +38,6 @@ 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

@@ -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

@@ -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

@@ -1,13 +1,23 @@
import { apiFindAllByApplication } from "@/server/db/schema";
import { db } from "@/server/db";
import { apiFindAllByApplication, applications } from "@/server/db/schema";
import {
createPreviewDeployment,
findApplicationById,
findPreviewDeploymentByApplicationId,
findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId,
findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { eq } from "drizzle-orm";
import { and } from "drizzle-orm";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import type { DeploymentJob } from "@/server/queues/queue-types";
export const previewDeploymentRouter = createTRPCRouter({
all: protectedProcedure
@@ -59,4 +69,142 @@ export const previewDeploymentRouter = createTRPCRouter({
}
return previewDeployment;
}),
create: protectedProcedure
.input(
z.object({
action: z.enum(["opened", "synchronize", "reopened", "closed"]),
pullRequestId: z.string(),
repository: z.string(),
owner: z.string(),
branch: z.string(),
deploymentHash: z.string(),
prBranch: z.string(),
prNumber: z.any(),
prTitle: z.string(),
prURL: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const organizationId = ctx.session.activeOrganizationId;
const action = input.action;
const prId = input.pullRequestId;
if (action === "closed") {
const previewDeploymentResult =
await findPreviewDeploymentsByPullRequestId(prId);
const filteredPreviewDeploymentResult = previewDeploymentResult.filter(
(previewDeployment) =>
previewDeployment.application.project.organizationId ===
organizationId,
);
if (filteredPreviewDeploymentResult.length > 0) {
for (const previewDeployment of filteredPreviewDeploymentResult) {
try {
await removePreviewDeployment(
previewDeployment.previewDeploymentId,
);
} catch (error) {
console.log(error);
}
}
}
return {
message: "Preview Deployments Closed",
};
}
if (
action === "opened" ||
action === "synchronize" ||
action === "reopened"
) {
const deploymentHash = input.deploymentHash;
const prBranch = input.prBranch;
const prNumber = input.prNumber;
const prTitle = input.prTitle;
const prURL = input.prURL;
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.repository, input.repository),
eq(applications.branch, input.branch),
eq(applications.isPreviewDeploymentsActive, true),
eq(applications.owner, input.owner),
),
with: {
previewDeployments: true,
project: true,
},
});
const filteredApps = apps.filter(
(app) => app.project.organizationId === organizationId,
);
console.log(filteredApps);
for (const app of filteredApps) {
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult) {
try {
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
pullRequestId: prId,
pullRequestNumber: prNumber,
pullRequestTitle: prTitle,
pullRequestURL: prURL,
});
console.log(previewDeployment);
previewDeploymentId = previewDeployment.previewDeploymentId;
} catch (error) {
console.log(error);
}
}
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: "Preview Deployment",
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application-preview",
server: !!app.serverId,
previewDeploymentId,
isExternal: true,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
}
return {
message: "Preview Deployments Created",
};
}),
});

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",
@@ -649,10 +649,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> => {
@@ -494,16 +641,10 @@ export const settingsRouter = createTRPCRouter({
},
})
.input(
z
.object({
dateRange: z
.object({
start: z.string().optional(),
end: z.string().optional(),
})
.optional(),
})
.optional(),
z.object({
start: z.string().optional(),
end: z.string().optional(),
}),
)
.query(async ({ input }) => {
if (IS_CLOUD) {

View File

@@ -1,13 +1,14 @@
import {
IS_CLOUD,
createApiKey,
findAdmin,
findNotificationById,
findOrganizationById,
findUserById,
getUserByToken,
removeUserById,
sendEmailNotification,
updateUser,
findWebServer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -29,7 +30,6 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
const apiCreateApiKey = z.object({
name: z.string().min(1),
@@ -54,7 +54,6 @@ export const userRouter = createTRPCRouter({
where: eq(member.organizationId, ctx.session.activeOrganizationId),
with: {
user: true,
role: true,
},
orderBy: [asc(member.createdAt)],
});
@@ -87,10 +86,7 @@ export const userRouter = createTRPCRouter({
// Allow access if:
// 1. User is requesting their own information
// 2. User has owner role (admin permissions) AND user is in the same organization
if (
memberResult.userId !== ctx.user.id &&
ctx.user.role?.name !== "owner"
) {
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
@@ -106,7 +102,6 @@ export const userRouter = createTRPCRouter({
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
role: true,
user: {
with: {
apiKeys: true,
@@ -152,6 +147,19 @@ export const userRouter = createTRPCRouter({
return memberResult?.user;
}),
getServerMetrics: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: true,
},
});
return memberResult?.user;
}),
update: protectedProcedure
.input(apiUpdateUser)
.mutation(async ({ input, ctx }) => {
@@ -191,6 +199,14 @@ export const userRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getUserByToken(input.token);
}),
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
return {
serverIp: user.serverIp,
enabledFeatures: user.enablePaidFeatures,
metricsConfig: user?.metricsConfig,
};
}),
remove: protectedProcedure
.input(
z.object({
@@ -367,83 +383,6 @@ export const userRouter = createTRPCRouter({
return organizations.length;
}),
createInvitation: adminProcedure
.input(
z.object({
email: z.string().email(),
role: z.string(),
organizationId: z.string(),
notificationId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const organization = await findOrganizationById(input.organizationId);
if (organization?.ownerId !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to create invitations",
});
}
const invitationResult = await db
.insert(invitation)
.values({
email: input.email,
role: input.role,
organizationId: input.organizationId,
status: "pending",
// 24 hours
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
inviterId: ctx.user.id,
})
.returning()
.then(([invitation]) => invitation);
const webServer = await findWebServer();
let host = "";
if (process.env.NODE_ENV === "development") {
host = "http://localhost:3000";
} else {
host = webServer.host || "";
}
if (IS_CLOUD) {
host = "https://app.dokploy.com";
}
const inviteLink = `${host}/invitation?token=${invitationResult?.id}`;
if (IS_CLOUD) {
await sendEmail({
email: invitationResult?.email || "",
subject: "Invitation to join organization",
text: `
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
});
} else if (input.notificationId) {
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
if (!email) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
});
}
await sendEmailNotification(
{
...email,
toAddresses: [invitationResult?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
}
}),
sendInvitation: adminProcedure
.input(
z.object({
@@ -471,11 +410,11 @@ export const userRouter = createTRPCRouter({
});
}
const webServer = await findWebServer();
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: webServer.host;
: admin.user.host;
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
@@ -499,52 +438,4 @@ export const userRouter = createTRPCRouter({
}
return inviteLink;
}),
assignRole: adminProcedure
.input(
z.object({
userId: z.string(),
roleId: z.string(),
accessedProjects: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
if (organization?.ownerId !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to assign roles",
});
}
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session.activeOrganizationId),
),
});
if (!memberResult) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Member not found",
});
}
await db
.update(member)
.set({
roleId: input.roleId,
accessedProjects: input.accessedProjects || [],
accessedServices: input.accessedServices || [],
})
.where(eq(member.id, memberResult.id));
} catch (error) {
throw error;
}
}),
});

View File

@@ -1,169 +0,0 @@
import {
apiAssignDomain,
apiSaveSSHKey,
apiUpdateDockerCleanup,
updateWebServerSchema,
} from "@/server/db/schema";
import { removeJob, schedule } from "@/server/utils/backup";
import {
IS_CLOUD,
cleanUpDockerBuilder,
cleanUpSystemPrune,
cleanUpUnusedImages,
findServerById,
findWebServer,
sendDockerCleanupNotifications,
updateLetsEncryptEmail,
updateServerById,
updateServerTraefik,
updateWebServer,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { scheduleJob, scheduledJobs } from "node-schedule";
import { adminProcedure, createTRPCRouter } from "../trpc";
export const webServerRouter = createTRPCRouter({
get: adminProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
return await findWebServer();
}),
update: adminProcedure
.input(updateWebServerSchema)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}
return await updateWebServer(input);
}),
saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}
await updateWebServer({
sshPrivateKey: input.sshPrivateKey,
});
return true;
}),
assignDomainServer: adminProcedure
.input(apiAssignDomain)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return true;
}
const webServer = await updateWebServer({
host: input.host,
...(input.letsEncryptEmail && {
letsEncryptEmail: input.letsEncryptEmail,
}),
certificateType: input.certificateType,
https: input.https,
});
if (!webServer) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
updateServerTraefik(webServer, input.host);
if (input.letsEncryptEmail) {
updateLetsEncryptEmail(input.letsEncryptEmail);
}
return webServer;
}),
cleanSSHPrivateKey: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
}
await updateWebServer({
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 updateWebServer({
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;
}),
});

View File

@@ -98,6 +98,7 @@ export const deploymentWorker = new Worker(
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
isExternal: job.data.isExternal,
});
}
} else {
@@ -107,6 +108,7 @@ export const deploymentWorker = new Worker(
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
isExternal: job.data.isExternal,
});
}
}

View File

@@ -26,6 +26,7 @@ type DeployJob =
applicationType: "application-preview";
previewDeploymentId: string;
serverId?: string;
isExternal?: boolean;
};
export type DeploymentJob = DeployJob;

View File

@@ -6,7 +6,7 @@
// boolean,
// } from "drizzle-orm/pg-core";
// export const users = pgTable("users", {
// export const users_temp = pgTable("users_temp", {
// id: text("id").primaryKey(),
// name: text("name").notNull(),
// email: text("email").notNull().unique(),
@@ -29,7 +29,7 @@
// userAgent: text("user_agent"),
// userId: text("user_id")
// .notNull()
// .references(() => users.id, { onDelete: "cascade" }),
// .references(() => users_temp.id, { onDelete: "cascade" }),
// activeOrganizationId: text("active_organization_id"),
// });
@@ -39,7 +39,7 @@
// providerId: text("provider_id").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => users.id, { onDelete: "cascade" }),
// .references(() => users_temp.id, { onDelete: "cascade" }),
// accessToken: text("access_token"),
// refreshToken: text("refresh_token"),
// idToken: text("id_token"),

View File

@@ -1,26 +0,0 @@
import { sql } from "drizzle-orm";
import { db } from "..";
import { organization } from "../schema/account";
import { getDefaultRolesSQL } from "../schema/rbac";
export async function createDefaultRoles() {
try {
// Get all organizations
const organizations = await db.select().from(organization);
// Create default roles for each organization
for (const org of organizations) {
const rolesSQL = getDefaultRolesSQL(org.id);
await db.execute(sql.raw(rolesSQL));
console.log(
`Created default roles for organization: ${org.name} (${org.id})`,
);
}
console.log("Successfully created default roles for all organizations");
} catch (error) {
console.error("Error creating default roles:", error);
throw error;
}
}

View File

@@ -9,8 +9,7 @@ import {
import { nanoid } from "nanoid";
import { projects } from "./project";
import { server } from "./server";
import { users } from "./user";
// import { role } from "./rbac";
import { users_temp } from "./user";
export const account = pgTable("account", {
id: text("id")
@@ -22,7 +21,7 @@ export const account = pgTable("account", {
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
@@ -40,9 +39,9 @@ export const account = pgTable("account", {
});
export const accountRelations = relations(account, ({ one }) => ({
user: one(users, {
user: one(users_temp, {
fields: [account.userId],
references: [users.id],
references: [users_temp.id],
}),
}));
@@ -66,15 +65,15 @@ export const organization = pgTable("organization", {
metadata: text("metadata"),
ownerId: text("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
});
export const organizationRelations = relations(
organization,
({ one, many }) => ({
owner: one(users, {
owner: one(users_temp, {
fields: [organization.ownerId],
references: [users.id],
references: [users_temp.id],
}),
servers: many(server),
projects: many(projects),
@@ -91,12 +90,24 @@ export const member = pgTable("member", {
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: text("role").$type<"owner" | "member" | "admin">(),
// roleId: text("roleId").references(() => role.roleId, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"),
// Permissions
canCreateProjects: boolean("canCreateProjects").notNull().default(false),
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),
canCreateServices: boolean("canCreateServices").notNull().default(false),
canDeleteProjects: boolean("canDeleteProjects").notNull().default(false),
canDeleteServices: boolean("canDeleteServices").notNull().default(false),
canAccessToDocker: boolean("canAccessToDocker").notNull().default(false),
canAccessToAPI: boolean("canAccessToAPI").notNull().default(false),
canAccessToGitProviders: boolean("canAccessToGitProviders")
.notNull()
.default(false),
canAccessToTraefikFiles: boolean("canAccessToTraefikFiles")
.notNull()
.default(false),
accessedProjects: text("accesedProjects")
.array()
.notNull()
@@ -112,30 +123,24 @@ export const memberRelations = relations(member, ({ one }) => ({
fields: [member.organizationId],
references: [organization.id],
}),
user: one(users, {
user: one(users_temp, {
fields: [member.userId],
references: [users.id],
references: [users_temp.id],
}),
// role: one(role, {
// fields: [member.roleId],
// references: [role.roleId],
// }),
}));
export const invitation = pgTable("invitation", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
role: text("role").$type<"owner" | "member" | "admin">(),
status: text("status").notNull(),
expiresAt: timestamp("expires_at").notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
teamId: text("team_id"),
});
@@ -152,7 +157,7 @@ export const twoFactor = pgTable("two_factor", {
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
});
export const apikey = pgTable("apikey", {
@@ -163,7 +168,7 @@ export const apikey = pgTable("apikey", {
key: text("key").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
@@ -182,8 +187,8 @@ export const apikey = pgTable("apikey", {
});
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(users, {
user: one(users_temp, {
fields: [apikey.userId],
references: [users.id],
references: [users_temp.id],
}),
}));

View File

@@ -19,7 +19,7 @@ import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users } from "./user";
import { users_temp } from "./user";
export const databaseType = pgEnum("databaseType", [
"postgres",
"mariadb",
@@ -74,7 +74,7 @@ export const backups = pgTable("backup", {
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
onDelete: "cascade",
}),
userId: text("userId").references(() => users.id),
userId: text("userId").references(() => users_temp.id),
// Only for compose backups
metadata: jsonb("metadata").$type<
| {
@@ -118,9 +118,9 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
fields: [backups.mongoId],
references: [mongo.mongoId],
}),
user: one(users, {
user: one(users_temp, {
fields: [backups.userId],
references: [users.id],
references: [users_temp.id],
}),
compose: one(compose, {
fields: [backups.composeId],

View File

@@ -8,7 +8,7 @@ import { bitbucket } from "./bitbucket";
import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { users } from "./user";
import { users_temp } from "./user";
export const gitProviderType = pgEnum("gitProviderType", [
"github",
@@ -32,7 +32,7 @@ export const gitProvider = pgTable("git_provider", {
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
});
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
@@ -56,9 +56,9 @@ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
fields: [gitProvider.organizationId],
references: [organization.id],
}),
user: one(users, {
user: one(users_temp, {
fields: [gitProvider.userId],
references: [users.id],
references: [users_temp.id],
}),
}));

View File

@@ -30,9 +30,7 @@ export * from "./server";
export * from "./utils";
export * from "./preview-deployments";
export * from "./ai";
// export * from "./rbac";
export * from "./account";
export * from "./schedule";
export * from "./rollbacks";
export * from "./volume-backups";
export * from "./web-server";

View File

@@ -1,58 +0,0 @@
// import { relations } from "drizzle-orm";
// import { pgTable, text, timestamp, boolean, unique } from "drizzle-orm/pg-core";
// import { nanoid } from "nanoid";
// import { organization, member } from "./account";
// import { createInsertSchema } from "drizzle-zod";
// import { z } from "zod";
// export const role = pgTable(
// "member_role",
// {
// roleId: text("roleId")
// .primaryKey()
// .$defaultFn(() => nanoid()),
// name: text("name").notNull().unique(),
// description: text("description"),
// canDelete: boolean("canDelete").notNull().default(true),
// isSystem: boolean("is_system").default(false),
// permissions: text("permissions").array(),
// createdAt: timestamp("created_at").notNull().defaultNow(),
// updatedAt: timestamp("updated_at").notNull().defaultNow(),
// organizationId: text("organizationId")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// },
// (table) => ({
// roleName: unique("role_name_unique").on(table.name, table.organizationId),
// }),
// );
// export const roleRelations = relations(role, ({ one, many }) => ({
// organization: one(organization, {
// fields: [role.organizationId],
// references: [organization.id],
// }),
// members: many(member),
// }));
// export type Role = typeof role.$inferSelect;
// export const createRoleSchema = createInsertSchema(role)
// .omit({
// roleId: true,
// createdAt: true,
// updatedAt: true,
// isSystem: true,
// organizationId: true,
// })
// .extend({
// permissions: z.array(z.string()),
// });
// export const updateRoleSchema = createRoleSchema.extend({
// roleId: z.string().min(1),
// });
// export const apiFindOneRole = z.object({
// roleId: z.string().min(1),
// });

View File

@@ -7,7 +7,7 @@ import { applications } from "./application";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { server } from "./server";
import { users } from "./user";
import { users_temp } from "./user";
import { generateAppName } from "./utils";
export const shellTypes = pgEnum("shellType", ["bash", "sh"]);
@@ -45,7 +45,7 @@ export const schedules = pgTable("schedule", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
userId: text("userId").references(() => users.id, {
userId: text("userId").references(() => users_temp.id, {
onDelete: "cascade",
}),
enabled: boolean("enabled").notNull().default(true),
@@ -69,9 +69,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
fields: [schedules.serverId],
references: [server.serverId],
}),
user: one(users, {
user: one(users_temp, {
fields: [schedules.userId],
references: [users.id],
references: [users_temp.id],
}),
deployments: many(deployments),
}));

View File

@@ -1,5 +1,5 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { users } from "./user";
import { users_temp } from "./user";
// OLD TABLE
export const session = pgTable("session_temp", {
@@ -12,7 +12,7 @@ export const session = pgTable("session_temp", {
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users_temp.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
});

View File

@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
@@ -13,6 +14,7 @@ import { account, apikey, organization } from "./account";
import { backups } from "./backups";
import { projects } from "./project";
import { schedules } from "./schedule";
import { certificateType } from "./shared";
import { paths } from "@dokploy/server/constants";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -21,8 +23,10 @@ import { paths } from "@dokploy/server/constants";
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
// OLD TABLE
// TEMP
export const users = pgTable("users", {
export const users_temp = pgTable("user_temp", {
id: text("id")
.notNull()
.primaryKey()
@@ -32,7 +36,10 @@ export const users = pgTable("users", {
expirationDate: text("expirationDate")
.notNull()
.$defaultFn(() => new Date().toISOString()),
createdAt: timestamp("created_at").notNull().defaultNow(),
createdAt2: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
createdAt: timestamp("created_at").defaultNow(),
// Auth
twoFactorEnabled: boolean("two_factor_enabled"),
email: text("email").notNull().unique(),
@@ -41,19 +48,83 @@ export const users = pgTable("users", {
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull(),
// Admin
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
role: text("role").notNull().default("user"),
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {
type: "Dokploy" | "Remote";
refreshRate: number;
port: number;
token: string;
urlCallback: string;
retentionDays: number;
cronJob: string;
thresholds: {
cpu: number;
memory: number;
};
};
containers: {
refreshRate: number;
services: {
include: string[];
exclude: string[];
};
};
}>()
.notNull()
.default({
server: {
type: "Dokploy",
refreshRate: 60,
port: 4500,
token: "",
retentionDays: 2,
cronJob: "",
urlCallback: "",
thresholds: {
cpu: 0,
memory: 0,
},
},
containers: {
refreshRate: 60,
services: {
include: [],
exclude: [],
},
},
}),
cleanupCacheApplications: boolean("cleanupCacheApplications")
.notNull()
.default(false),
cleanupCacheOnPreviews: boolean("cleanupCacheOnPreviews")
.notNull()
.default(false),
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
.notNull()
.default(false),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
});
export const usersRelations = relations(users, ({ one, many }) => ({
export const usersRelations = relations(users_temp, ({ one, many }) => ({
account: one(account, {
fields: [users.id],
fields: [users_temp.id],
references: [account.userId],
}),
organizations: many(organization),
@@ -63,7 +134,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
schedules: many(schedules),
}));
const createSchema = createInsertSchema(users, {
const createSchema = createInsertSchema(users_temp, {
id: z.string().min(1),
isRegistered: z.boolean().optional(),
}).omit({
@@ -128,6 +199,33 @@ export const apiFindOneUserByAuth = createSchema
// authId: true,
})
.required();
export const apiSaveSSHKey = createSchema
.pick({
sshPrivateKey: true,
})
.required();
export const apiAssignDomain = createSchema
.pick({
host: true,
certificateType: true,
letsEncryptEmail: true,
https: true,
})
.required()
.partial({
letsEncryptEmail: true,
https: true,
});
export const apiUpdateDockerCleanup = createSchema
.pick({
enableDockerCleanup: true,
})
.required()
.extend({
serverId: z.string().optional(),
});
export const apiTraefikConfig = z.object({
traefikConfig: z.string().min(1),

View File

@@ -1,104 +0,0 @@
import { boolean, jsonb, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { certificateType } from "./shared";
import { z } from "zod";
import { createInsertSchema } from "drizzle-zod";
export const webServer = pgTable("web_server", {
webServerId: text("webServerId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
// Admin
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {
type: "Dokploy" | "Remote";
refreshRate: number;
port: number;
token: string;
urlCallback: string;
retentionDays: number;
cronJob: string;
thresholds: {
cpu: number;
memory: number;
};
};
containers: {
refreshRate: number;
services: {
include: string[];
exclude: string[];
};
};
}>()
.notNull()
.default({
server: {
type: "Dokploy",
refreshRate: 60,
port: 4500,
token: "",
retentionDays: 2,
cronJob: "",
urlCallback: "",
thresholds: {
cpu: 0,
memory: 0,
},
},
containers: {
refreshRate: 60,
services: {
include: [],
exclude: [],
},
},
}),
});
export type WebServer = typeof webServer.$inferSelect;
const createSchema = createInsertSchema(webServer);
export const updateWebServerSchema = createSchema.omit({
webServerId: true,
metricsConfig: true,
});
export const apiSaveSSHKey = createSchema
.pick({
sshPrivateKey: true,
})
.required();
export const apiAssignDomain = createSchema
.pick({
host: true,
certificateType: true,
letsEncryptEmail: true,
https: true,
})
.required()
.partial({
letsEncryptEmail: true,
https: true,
});
export const apiUpdateDockerCleanup = createSchema
.pick({
enableDockerCleanup: true,
})
.required()
.extend({
serverId: z.string().optional(),
});

View File

@@ -1,13 +0,0 @@
import { createDefaultRoles } from "../migrations/create-default-roles";
async function main() {
try {
await createDefaultRoles();
process.exit(0);
} catch (error) {
console.error("Failed to create default roles:", error);
process.exit(1);
}
}
main();

View File

@@ -13,7 +13,6 @@ export * from "./services/settings";
export * from "./services/volume-backups";
export * from "./services/docker";
export * from "./services/destination";
export * from "./services/role";
export * from "./services/deployment";
export * from "./services/mount";
export * from "./services/certificate";
@@ -35,7 +34,6 @@ export * from "./services/server";
export * from "./services/schedule";
export * from "./services/application";
export * from "./services/rollbacks";
export * from "./services/web-server";
export * from "./utils/databases/rebuild";
export * from "./setup/config-paths";
export * from "./setup/postgres-setup";

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