Compare commits

...

22 Commits

Author SHA1 Message Date
Mauricio Siu d84099108a refactor: simplify role management by removing unused role schema and related logic; update user role checks in context and procedures 2025-07-13 14:00:26 -06:00
Mauricio Siu cee426dcf5 Merge branch 'canary' into feat/add-admin-roles 2025-07-13 13:20:04 -06:00
Mauricio Siu 1074e9b08e Merge branch 'canary' into feat/add-admin-roles 2025-07-13 12:56:48 -06:00
Mauricio Siu a5911e2bac feat(invitation): refactor invitation creation process and enhance error handling
- Replaced the existing invitation creation logic with a new mutation that integrates role and organization checks.
- Updated the invitation form to handle errors more effectively, displaying error messages directly from the API response.
- Introduced a new `member_role` table to manage user roles with associated permissions, ensuring better role management.
- Enhanced SQL migration scripts to create default roles for organizations and update existing member roles accordingly.
- Improved the user router to include a new `createInvitation` procedure for streamlined invitation management.
2025-07-13 11:44:46 -06:00
Mauricio Siu a43b8ee2d2 feat(permissions): update role-based access checks across dashboard components
- Refactored user role checks in various dashboard pages to utilize the new permissions structure.
- Replaced direct role comparisons with permission checks for enhanced security and maintainability.
- Updated the `validateRequest` function to streamline user role handling and ensure proper access control.
- Improved consistency in permission checks across components, ensuring only authorized users can access specific features.
2025-07-13 02:05:59 -06:00
Mauricio Siu 982a1d5d31 Merge branch 'canary' into feat/add-admin-roles 2025-07-13 01:56:45 -06:00
Mauricio Siu 30d45bf2e5 feat(permissions): implement role-based access control and refactor user permissions
- Introduced a new Permissions component to manage role-based access across various components.
- Updated user role checks to utilize the new permissions structure, replacing direct role comparisons with permission checks.
- Refactored multiple components to enhance permission handling, ensuring only authorized users can access specific features.
- Removed deprecated add-permissions component and streamlined user permission management.
- Enhanced role management in the backend to support the new permissions schema, improving overall security and maintainability.
2025-07-13 01:52:08 -06:00
Mauricio Siu db221e5cc4 feat(database): update member roles and enforce roleId constraints
- Added a script to update existing members with corresponding roles based on their current role type.
- Set the "roleId" column in the "member" table to NOT NULL to ensure data integrity.
- Enhanced foreign key constraints for better relationship management within the database.
2025-07-13 00:20:41 -06:00
Mauricio Siu e1773a8f8b feat(database): add member_role table and update user role management
- Introduced a new "member_role" table to define user roles with associated permissions.
- Implemented default roles (owner, admin, member) for each organization during migration.
- Updated the "users" table and other related tables to reflect changes in role management.
- Enhanced SQL migration scripts to ensure data integrity and consistency across the database.
2025-07-13 00:15:24 -06:00
Mauricio Siu e8475730fa feat(database): introduce member_role table and update user roles
- Created a new "member_role" table to manage user roles with associated permissions.
- Migrated existing roles from the deprecated "organization_role" table and updated member records accordingly.
- Enhanced the "member" table by adding a foreign key reference to the new "member_role" table.
- Updated SQL migration scripts to reflect changes in user and role management, ensuring data integrity and consistency.
2025-07-13 00:02:52 -06:00
Mauricio Siu d78e634cb0 refactor(sidebar): clean up unused types and improve menu structure
- Removed legacy comments and unused type definitions from the sidebar component for better clarity.
- Added role information to user queries in the user router to enhance permission handling.
- Introduced a new schedule access permission in the RBAC schema and updated role permissions accordingly.
- Enhanced error messages in role management functions for improved user feedback.
2025-07-12 23:45:06 -06:00
Mauricio Siu 509d95fbf2 style(users): enhance permissions card appearance
- Updated the permissions card to have a transparent background for improved visual integration.
- Adjusted the permissions title to use a smaller text size for better alignment with the overall design.
2025-07-12 23:27:40 -06:00
Mauricio Siu b928e94e51 refactor(users): improve role assignment UI and logic
- Enhanced the role assignment component by conditionally rendering roles and disabling the owner role in the selection.
- Updated the display of role permissions and descriptions for better clarity and user experience.
- Changed the button label from "Assign Role" to "Save Role" to better reflect the action being performed.
2025-07-12 23:26:50 -06:00
Mauricio Siu 3052979bdd refactor(web-server): update components to utilize web server data
- Replaced user IP references with web server data across various components, including domain management and database credential displays.
- Adjusted API calls to fetch web server information instead of user data, enhancing data consistency and clarity.
- Refactored related functions to streamline the handling of server configurations and improve overall code maintainability.
2025-07-12 23:16:06 -06:00
Mauricio Siu 2ec4868a09 feat(web-server): migrate user-related functionality to web server model
- Refactored components and API routes to utilize the new web server schema, replacing user references with web server data.
- Updated the dashboard settings to fetch and manage web server domains, IPs, and configurations.
- Introduced a new web server router to handle related API requests, enhancing the overall architecture and data management.
- Added SQL migration for the new web server table and adjusted the database schema accordingly.
2025-07-12 22:57:36 -06:00
Mauricio Siu 733777eeb1 refactor(users): replace users_temp with users across the codebase
- Updated all references from the temporary users table (users_temp) to the main users table (users) in various files, including migration, API handlers, and database schema.
- Ensured consistency in user data handling and improved overall code clarity by removing the temporary user schema.
2025-07-12 16:23:37 -06:00
Mauricio Siu 521330682d feat(users): enhance user display and management options
- Added functionality to display the current user with a "(You)" label in the user list.
- Updated the dropdown menu to conditionally show actions based on user roles, preventing owners from being deleted or unlinked.
- Improved user deletion and unlinking processes with success and error notifications.
2025-07-12 16:18:33 -06:00
Mauricio Siu 7cc048450b Merge branch 'canary' into feat/add-admin-roles 2025-07-12 16:05:28 -06:00
Mauricio Siu 427674dd64 feat(permissions): enhance user role management with project and service access
- Added support for managing accessed projects and services in user role assignments.
- Updated the role management UI to include options for selecting projects and services based on user roles.
- Enhanced API endpoints to handle new fields for accessed projects and services during role assignment.
- Refactored role permissions structure to improve clarity and maintainability.
2025-07-11 22:27:06 -06:00
Mauricio Siu 8b8dc8c94f refactor(roles): streamline role permissions handling
- Refactored role permissions management by importing specific permissions directly instead of querying the database.
- Updated the `getDefaultRoles` method to return predefined permissions for owner, admin, and member roles.
- Simplified the permissions structure in the RBAC schema for better clarity and maintainability.
2025-07-09 01:45:41 -06:00
Mauricio Siu d6e8653839 feat(roles): implement role management functionality
- Added a new component for managing user roles, allowing assignment and creation of roles.
- Introduced a new API router for role management, including endpoints for creating, updating, and deleting roles.
- Updated the database schema to support role management with a new "organization_role" table.
- Enhanced user management to include role assignments and permissions handling.
- Updated UI components to integrate the new role management features.
2025-07-09 01:45:33 -06:00
Mauricio Siu d0b7ce3a50 refactor: rename findAdmin to findOwner across multiple files
- Updated the function name from findAdmin to findOwner in reset-2fa.ts, reset-password.ts, user.ts, admin.ts, and access-log handler.ts to reflect the change in role terminology.
- Adjusted related logic to ensure consistency in user role handling.
2025-07-08 21:52:50 -06:00
100 changed files with 8665 additions and 1404 deletions
@@ -5,7 +5,8 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig, User } from "@dokploy/server";
import type { FileConfig } from "@dokploy/server";
import type { WebServer } from "@dokploy/server/db/schema";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
@@ -13,11 +14,8 @@ import {
} from "@dokploy/server";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
const baseAdmin: WebServer = {
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
metricsConfig: {
containers: {
refreshRate: 20,
@@ -40,10 +38,6 @@ const baseAdmin: User = {
urlCallback: "",
},
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
@@ -51,22 +45,7 @@ const baseAdmin: User = {
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
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,
webServerId: "1",
};
beforeEach(() => {
@@ -85,8 +64,6 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
"example.com",
);
@@ -71,7 +71,7 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const { data: webServer } = api.webServer.get.useQuery();
const {
data,
@@ -110,7 +110,9 @@ export const ShowDomains = ({ id, type }: Props) => {
const result = await validateDomain({
domain: host,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
application?.server?.ipAddress?.toString() ||
webServer?.serverIp?.toString() ||
"",
});
setValidationStates((prev) => ({
@@ -210,7 +212,7 @@ export const ShowDomains = ({ id, type }: Props) => {
}}
serverIp={
application?.server?.ipAddress?.toString() ||
ip?.toString()
webServer?.serverIp?.toString()
}
/>
)}
@@ -46,11 +46,11 @@ interface Props {
mariadbId: string;
}
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data: webServer } = api.webServer.get.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 || ip;
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const form = useForm<DockerProvider>({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
@@ -46,11 +46,11 @@ interface Props {
mongoId: string;
}
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data: webServer } = api.webServer.get.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 || ip;
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const form = useForm<DockerProvider>({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
@@ -46,11 +46,11 @@ interface Props {
mysqlId: string;
}
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data: webServer } = api.webServer.get.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 || ip;
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const form = useForm<DockerProvider>({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
@@ -46,11 +46,11 @@ interface Props {
postgresId: string;
}
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data: webServer } = api.webServer.get.useQuery();
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
const { mutateAsync, isLoading } =
api.postgres.saveExternalPort.useMutation();
const getIp = data?.server?.ipAddress || ip;
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const [connectionUrl, setConnectionUrl] = useState("");
const form = useForm<DockerProvider>({
@@ -47,11 +47,12 @@ 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("");
@@ -83,11 +84,11 @@ export const ShowProjects = () => {
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<Permissions permissions={[PERMISSIONS.PROJECT.CREATE.name]}>
<div className="">
<HandleProject />
</div>
)}
</Permissions>
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
@@ -289,8 +290,11 @@ export const ShowProjects = () => {
<div
onClick={(e) => e.stopPropagation()}
>
{(auth?.role === "owner" ||
auth?.canDeleteProjects) && (
<Permissions
permissions={[
PERMISSIONS.PROJECT.DELETE.name,
]}
>
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
@@ -356,7 +360,7 @@ export const ShowProjects = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</Permissions>
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -46,11 +46,11 @@ interface Props {
redisId: string;
}
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data: webServer } = api.webServer.get.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 || ip;
const getIp = data?.server?.ipAddress || webServer?.serverIp;
const form = useForm<DockerProvider>({
defaultValues: {},
@@ -14,7 +14,7 @@ export const ShowWelcomeDokploy = () => {
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
if (!isCloud || data?.role !== "admin") {
if (!isCloud || data?.role?.name !== "admin") {
return null;
}
@@ -23,14 +23,14 @@ export const ShowWelcomeDokploy = () => {
!isLoading &&
isCloud &&
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
data?.role === "owner"
data?.role?.name === "owner"
) {
setOpen(true);
}
}, [isCloud, isLoading]);
const handleClose = (isOpen: boolean) => {
if (data?.role === "owner") {
if (data?.role?.name === "owner") {
setOpen(isOpen);
if (!isOpen) {
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
@@ -32,6 +32,7 @@ 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(),
@@ -79,6 +80,7 @@ export const ProfileForm = () => {
const form = useForm<Profile>({
defaultValues: {
name: data?.user?.name || "",
email: data?.user?.email || "",
password: "",
image: data?.user?.image || "",
@@ -92,6 +94,7 @@ export const ProfileForm = () => {
if (data) {
form.reset(
{
name: data?.user?.name || "",
email: data?.user?.email || "",
password: form.getValues("password") || "",
image: data?.user?.image || "",
@@ -114,6 +117,7 @@ export const ProfileForm = () => {
const onSubmit = async (values: Profile) => {
await mutateAsync({
name: values.name,
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
@@ -124,6 +128,7 @@ export const ProfileForm = () => {
await refetch();
toast.success("Profile Updated");
form.reset({
name: values.name,
email: values.email,
password: "",
image: values.image,
@@ -167,6 +172,19 @@ 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"
@@ -7,7 +7,7 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
const { data, refetch } = api.user.get.useQuery(undefined, {
const { data, refetch } = api.webServer.get.useQuery(undefined, {
enabled: !serverId,
});
@@ -20,9 +20,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
},
);
const enabled = data?.user.enableDockerCleanup || server?.enableDockerCleanup;
const enabled = data?.enableDockerCleanup || server?.enableDockerCleanup;
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
const { mutateAsync } = api.webServer.updateDockerCleanup.useMutation();
const handleToggle = async (checked: boolean) => {
try {
@@ -89,7 +89,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
: api.user.getServerMetrics.useQuery();
: api.webServer.get.useQuery();
const url = useUrl();
@@ -49,12 +49,15 @@ type AddInvitation = z.infer<typeof addInvitation>;
export const AddInvitation = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false);
const { data: roles } = api.role.all.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null);
const {
mutateAsync: createInvitation,
isLoading,
error,
} = api.user.createInvitation.useMutation();
const { data: activeOrganization } = authClient.useActiveOrganization();
const form = useForm<AddInvitation>({
@@ -70,36 +73,20 @@ export const AddInvitation = () => {
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddInvitation) => {
setIsLoading(true);
const result = await authClient.organization.inviteMember({
await createInvitation({
email: data.email.toLowerCase(),
role: data.role,
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 {
organizationId: activeOrganization?.id || "",
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created");
}
setError(null);
setOpen(false);
}
})
.catch((error: any) => {
toast.error(error.message);
});
utils.organization.allInvitations.invalidate();
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -113,7 +100,7 @@ export const AddInvitation = () => {
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error}</AlertBlock>}
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
<Form {...form}>
<form
@@ -158,6 +145,12 @@ 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>
@@ -0,0 +1,758 @@
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>
);
};
@@ -1,444 +0,0 @@
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>
);
};
@@ -30,9 +30,10 @@ import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { AddUserPermissions } from "./add-permissions";
import { AddUserPermissionsV2 } from "./add-permissions-v2";
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();
@@ -84,20 +85,22 @@ export const ShowUsers = () => {
</TableHeader>
<TableBody>
{data?.map((member) => {
const isSameUser = member.user.id === user?.user.id;
return (
<TableRow key={member.id}>
<TableCell className="w-[100px]">
{member.user.email}
<TableCell className="w-[250px]">
{member.user.email} {isSameUser && "(You)"}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
member.role === "owner"
member?.role?.name === "owner"
? "default"
: "secondary"
}
>
{member.role}
{member?.role?.name}
</Badge>
</TableCell>
<TableCell className="text-center">
@@ -112,35 +115,77 @@ export const ShowUsers = () => {
</TableCell>
<TableCell className="text-right flex justify-end">
<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" && !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>
{member.role !== "owner" && (
<AddUserPermissions
userId={member.user.id}
/>
)}
{member.role !== "owner" && (
<>
{!isCloud && (
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
<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) {
await mutateAsync({
userId: member.user.id,
})
@@ -152,86 +197,40 @@ export const ShowUsers = () => {
})
.catch(() => {
toast.error(
"Error deleting destination",
"Error deleting user",
);
});
}}
>
<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;
}
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",
);
}
}}
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()}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Unlink User
</DropdownMenuItem>
</DialogAction>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
Unlink User
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
@@ -62,9 +62,9 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.user.get.useQuery();
const { data, refetch } = api.webServer.get.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
api.webServer.assignDomainServer.useMutation();
const form = useForm<AddServerDomain>({
defaultValues: {
@@ -79,10 +79,10 @@ export const WebDomain = () => {
useEffect(() => {
if (data) {
form.reset({
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
domain: data?.host || "",
certificateType: data?.certificateType,
letsEncryptEmail: data?.letsEncryptEmail || "",
https: data?.https || false,
});
}
}, [form, form.reset, data]);
@@ -16,13 +16,12 @@ import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data } = api.user.get.useQuery();
const { data } = api.webServer.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="">
@@ -34,14 +33,6 @@ 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 />
@@ -53,7 +44,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?.user.serverIp}
Server IP: {data?.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}
@@ -46,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data } = api.user.get.useQuery();
const { data } = api.webServer.get.useQuery();
const { data: ip } = api.server.publicIp.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.user.update.useMutation();
api.webServer.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
serverIp: data?.user.serverIp || "",
serverIp: data?.serverIp || "",
},
resolver: zodResolver(schema),
});
@@ -62,7 +62,7 @@ export const UpdateServerIp = ({ children }: Props) => {
useEffect(() => {
if (data) {
form.reset({
serverIp: data.user.serverIp || "",
serverIp: data.serverIp || "",
});
}
}, [form, form.reset, data]);
@@ -80,7 +80,7 @@ export const UpdateServerIp = ({ children }: Props) => {
})
.then(async () => {
toast.success("Server IP Updated");
await utils.user.get.invalidate();
await utils.webServer.get.invalidate();
setIsOpen(false);
})
.catch(() => {
@@ -0,0 +1,28 @@
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}</>;
};
+48 -88
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,10 +102,6 @@ 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
| {
@@ -119,8 +115,6 @@ type NavItem =
}) => boolean;
};
// ExternalLink type
// Represents an external link item (used for the help section)
type ExternalLink = {
name: string;
url: string;
@@ -131,18 +125,12 @@ 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: [
{
@@ -165,7 +153,8 @@ const MENU: Menu = {
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
isEnabled: ({ isCloud, auth }) =>
!isCloud && auth?.role?.name === "owner",
},
{
isSingle: true,
@@ -175,7 +164,10 @@ const MENU: Menu = {
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(
PERMISSIONS.TRAEFIK.ACCESS.name,
)) &&
!isCloud
),
},
@@ -186,7 +178,11 @@ const MENU: Menu = {
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
},
{
isSingle: true,
@@ -195,7 +191,11 @@ const MENU: Menu = {
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
},
{
isSingle: true,
@@ -204,64 +204,12 @@ const MENU: Menu = {
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!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: [
@@ -271,7 +219,8 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && !isCloud),
},
{
isSingle: true,
@@ -285,7 +234,7 @@ const MENU: Menu = {
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -293,7 +242,7 @@ const MENU: Menu = {
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -302,14 +251,17 @@ const MENU: Menu = {
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
!!(
auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.SSH_KEYS.ACCESS.name)
),
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -318,7 +270,12 @@ const MENU: Menu = {
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
!!(
auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(
PERMISSIONS.GIT_PROVIDERS.ACCESS.name,
)
),
},
{
isSingle: true,
@@ -326,7 +283,7 @@ const MENU: Menu = {
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -334,7 +291,7 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
@@ -343,7 +300,7 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -351,7 +308,8 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && !isCloud),
},
{
isSingle: true,
@@ -359,7 +317,7 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -367,7 +325,8 @@ const MENU: Menu = {
url: "/dashboard/settings/billing",
icon: CreditCard,
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && isCloud),
},
],
@@ -505,6 +464,7 @@ 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 {
@@ -663,7 +623,7 @@ function SidebarLogo() {
)}
</div>
))}
{(user?.role === "owner" || isCloud) && (
{(user?.role?.name === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
@@ -1029,7 +989,7 @@ export default function Page({ children }: Props) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
{!isCloud && auth?.role?.name === "owner" && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>
+9 -9
View File
@@ -23,6 +23,8 @@ 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;
@@ -98,7 +100,7 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
<Permissions permissions={[PERMISSIONS.TRAEFIK.ACCESS.name]}>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -107,8 +109,9 @@ export const UserNav = () => {
>
Traefik
</DropdownMenuItem>
)}
{(data?.role === "owner" || data?.canAccessToDocker) && (
</Permissions>
<Permissions permissions={[PERMISSIONS.DOCKER.VIEW.name]}>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -119,11 +122,11 @@ export const UserNav = () => {
>
Docker
</DropdownMenuItem>
)}
</Permissions>
</>
) : (
<>
{data?.role === "owner" && (
{data?.role?.name === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -136,7 +139,7 @@ export const UserNav = () => {
</>
)}
</DropdownMenuGroup>
{isCloud && data?.role === "owner" && (
{isCloud && data?.role?.name === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -154,9 +157,6 @@ export const UserNav = () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
+187
View File
@@ -0,0 +1,187 @@
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
+7
View File
@@ -722,6 +722,13 @@
"when": 1751848685503,
"tag": "0102_opposite_grandmaster",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1752428260850,
"tag": "0103_brainy_nehzno",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -24,7 +24,7 @@
// });
// for (const admin of admins) {
// const user = await db
// .insert(schema.users_temp)
// .insert(schema.users)
// .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_temp)
// .insert(schema.users)
// .values({
// id: member.userId,
// email: member.auth.email,
+19 -25
View File
@@ -1,6 +1,6 @@
import { buffer } from "node:stream/consumers";
import { db } from "@/server/db";
import { organization, server, users_temp } from "@/server/db/schema";
import { organization, server, users } 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_temp)
.update(users)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(users_temp.id, adminId))
.where(eq(users.id, adminId))
.returning();
const admin = await findUserById(adminId);
@@ -85,14 +85,12 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(users_temp)
.update(users)
.set({
stripeSubscriptionId: newSubscription.id,
stripeCustomerId: newSubscription.customer as string,
})
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
)
.where(eq(users.stripeCustomerId, newSubscription.customer as string))
.returning();
break;
@@ -102,14 +100,12 @@ export default async function handler(
const newSubscription = event.data.object as Stripe.Subscription;
await db
.update(users_temp)
.update(users)
.set({
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
);
.where(eq(users.stripeCustomerId, newSubscription.customer as string));
const admin = await findUserByStripeCustomerId(
newSubscription.customer as string,
@@ -135,12 +131,12 @@ export default async function handler(
if (newSubscription.status === "active") {
await db
.update(users_temp)
.update(users)
.set({
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
eq(users.stripeCustomerId, newSubscription.customer as string),
);
const newServersQuantity = admin.serversQuantity;
@@ -148,10 +144,10 @@ export default async function handler(
} else {
await disableServers(admin.id);
await db
.update(users_temp)
.update(users)
.set({ serversQuantity: 0 })
.where(
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
eq(users.stripeCustomerId, newSubscription.customer as string),
);
}
@@ -172,11 +168,11 @@ export default async function handler(
}
await db
.update(users_temp)
.update(users)
.set({
serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
})
.where(eq(users_temp.stripeCustomerId, suscription.customer as string));
.where(eq(users.stripeCustomerId, suscription.customer as string));
const admin = await findUserByStripeCustomerId(
suscription.customer as string,
@@ -205,13 +201,11 @@ export default async function handler(
return res.status(400).send("Webhook Error: Admin not found");
}
await db
.update(users_temp)
.update(users)
.set({
serversQuantity: 0,
})
.where(
eq(users_temp.stripeCustomerId, newInvoice.customer as string),
);
.where(eq(users.stripeCustomerId, newInvoice.customer as string));
await disableServers(admin.id);
}
@@ -229,13 +223,13 @@ export default async function handler(
await disableServers(admin.id);
await db
.update(users_temp)
.update(users)
.set({
stripeCustomerId: null,
stripeSubscriptionId: null,
serversQuantity: 0,
})
.where(eq(users_temp.stripeCustomerId, customer.id));
.where(eq(users.stripeCustomerId, customer.id));
break;
}
@@ -262,8 +256,8 @@ const disableServers = async (userId: string) => {
};
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
const user = db.query.users_temp.findFirst({
where: eq(users_temp.stripeCustomerId, stripeCustomerId),
const user = db.query.users.findFirst({
where: eq(users.stripeCustomerId, stripeCustomerId),
});
return user;
};
+3 -6
View File
@@ -3,6 +3,7 @@ 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";
@@ -53,12 +54,8 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) {
return {
redirect: {
permanent: true,
+3 -3
View File
@@ -20,7 +20,7 @@ const Dashboard = () => {
false,
);
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
const { data: webServer, isLoading } = api.webServer.get.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://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}/metrics`
? `http://${webServer?.serverIp}:${webServer?.metricsConfig?.server?.port}/metrics`
: BASE_URL
}
token={
process.env.NODE_ENV === "production"
? monitoring?.metricsConfig?.server?.token
? webServer?.metricsConfig?.server?.token
: DEFAULT_TOKEN
}
/>
@@ -94,6 +94,7 @@ 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;
@@ -221,7 +222,6 @@ 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,30 +736,27 @@ const Project = (
Stop
</Button>
</DialogAction>
{(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}
<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"
>
<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}
/>
</>
)}
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</Permissions>
<Dialog
open={isMoveDialogOpen}
@@ -37,7 +37,6 @@ 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";
@@ -54,6 +53,7 @@ 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,7 +88,6 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
return (
<div className="pb-10">
@@ -179,9 +178,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={applicationId} type="application" />
)}
</Permissions>
</div>
</div>
</CardHeader>
@@ -33,7 +33,6 @@ 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";
@@ -51,6 +50,7 @@ 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,7 +78,6 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -171,9 +170,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={composeId} type="compose" />
)}
</Permissions>
</div>
</div>
</CardHeader>
@@ -44,6 +44,7 @@ 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";
@@ -57,7 +58,6 @@ 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} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={mariadbId} type="mariadb" />
)}
</Permissions>
</div>
</div>
</CardHeader>
@@ -44,6 +44,7 @@ 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";
@@ -57,8 +58,6 @@ 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 (
@@ -143,9 +142,9 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={mongoId} type="mongo" />
)}
</Permissions>
</div>
</div>
</CardHeader>
@@ -29,7 +29,6 @@ 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";
@@ -44,6 +43,8 @@ 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";
@@ -56,7 +57,6 @@ 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} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={mysqlId} type="mysql" />
)}
</Permissions>
</div>
</div>
</CardHeader>
@@ -29,7 +29,6 @@ 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";
@@ -44,6 +43,8 @@ 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";
@@ -56,7 +57,6 @@ 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} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={postgresId} type="postgres" />
)}
</Permissions>
</div>
</div>
</CardHeader>
@@ -43,6 +43,7 @@ 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";
@@ -56,8 +57,6 @@ 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 (
@@ -142,9 +141,9 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={redisId} type="redis" />
)}
</Permissions>
</div>
</div>
</CardHeader>
+1 -1
View File
@@ -39,7 +39,7 @@ export async function getServerSideProps(
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
+1 -1
View File
@@ -44,7 +44,7 @@ export async function getServerSideProps(
await helpers.user.get.prefetch();
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -31,7 +31,7 @@ export async function getServerSideProps(
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -25,7 +25,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -34,7 +34,7 @@ export async function getServerSideProps(
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -26,7 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -3,6 +3,7 @@ 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";
@@ -49,12 +50,12 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToGitProviders) {
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (
!user?.role?.permissions?.includes(
PERMISSIONS.GIT_PROVIDERS.ACCESS.name,
)
) {
return {
redirect: {
permanent: true,
@@ -26,7 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -1,27 +1,23 @@
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 />
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
{/* {isCloud && <RemoveSelfAccount />} */}
<Permissions permissions={["api:access"]}>
<ShowApiKeys />
</Permissions>
</div>
</div>
);
@@ -26,7 +26,7 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
@@ -59,7 +59,7 @@ export async function getServerSideProps(
},
};
}
if (user.role === "member") {
if (user.role?.name === "member" || !user?.role?.isSystem) {
return {
redirect: {
permanent: true,
@@ -36,7 +36,7 @@ export async function getServerSideProps(
},
};
}
if (user.role === "member") {
if (user.role?.name === "member" || !user?.role?.isSystem) {
return {
redirect: {
permanent: true,
@@ -3,6 +3,7 @@ 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 +51,10 @@ export async function getServerSideProps(
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToSSHKeys) {
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (
!user?.role?.permissions?.includes(PERMISSIONS.SSH_KEYS.ACCESS.name)
) {
return {
redirect: {
permanent: true,
@@ -29,7 +29,7 @@ export async function getServerSideProps(
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user || (user.role?.name !== "owner" && user.role?.name !== "admin")) {
return {
redirect: {
permanent: true,
+3 -6
View File
@@ -3,6 +3,7 @@ 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";
@@ -53,12 +54,8 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) {
return {
redirect: {
permanent: true,
+3 -6
View File
@@ -3,6 +3,7 @@ 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";
@@ -53,12 +54,8 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToTraefikFiles) {
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.TRAEFIK.ACCESS.name)) {
return {
redirect: {
permanent: true,
+4 -21
View File
@@ -1,12 +1,10 @@
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 superjson from "superjson";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
@@ -71,8 +69,7 @@ const Home: NextPage = () => {
export default Home;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req, res } = context;
const { user, session } = await validateRequest(context.req);
const { user } = await validateRequest(context.req);
if (!user) {
return {
redirect: {
@@ -81,23 +78,9 @@ 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 (!userR?.canAccessToAPI) {
if (user.role?.name === "member" || !user?.role?.isSystem) {
if (!user?.role?.permissions?.includes(PERMISSIONS.API.ACCESS.name)) {
return {
redirect: {
permanent: true,
+5 -5
View File
@@ -1,18 +1,18 @@
import { findAdmin } from "@dokploy/server";
import { findOwner } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { users_temp } from "@dokploy/server/db/schema";
import { users } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
(async () => {
try {
const result = await findAdmin();
const result = await findOwner();
const update = await db
.update(users_temp)
.update(users)
.set({
twoFactorEnabled: false,
})
.where(eq(users_temp.id, result.userId));
.where(eq(users.id, result.userId));
if (update) {
console.log("2FA reset successful");
+2 -2
View File
@@ -1,4 +1,4 @@
import { findAdmin } from "@dokploy/server";
import { findOwner } 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 findAdmin();
const result = await findOwner();
const update = await db
.update(account)
+4
View File
@@ -38,6 +38,8 @@ 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.
*
@@ -84,6 +86,8 @@ export const appRouter = createTRPCRouter({
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
role: roleRouter,
webServer: webServerRouter,
});
// export type definition of API
+4 -3
View File
@@ -3,7 +3,7 @@ import {
IS_CLOUD,
findUserById,
setupWebMonitoring,
updateUser,
updateWebServer,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { adminProcedure, createTRPCRouter } from "../trpc";
@@ -27,7 +27,8 @@ export const adminRouter = createTRPCRouter({
});
}
await updateUser(user.id, {
await updateWebServer({
// @ts-expect-error - TODO: fix this
metricsConfig: {
server: {
type: "Dokploy",
@@ -52,7 +53,7 @@ export const adminRouter = createTRPCRouter({
},
});
const currentServer = await setupWebMonitoring(user.id);
const currentServer = await setupWebMonitoring();
return currentServer;
} catch (error) {
throw error;
+3 -4
View File
@@ -147,11 +147,10 @@ export const aiRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
.mutation(async ({ input }) => {
try {
return await suggestVariants({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw new TRPCError({
@@ -163,7 +162,7 @@ export const aiRouter = createTRPCRouter({
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.session.activeOrganizationId,
input.projectId,
@@ -216,7 +215,7 @@ export const aiRouter = createTRPCRouter({
}
}
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,
@@ -63,7 +63,7 @@ export const applicationRouter = createTRPCRouter({
.input(apiCreateApplication)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -88,7 +88,7 @@ export const applicationRouter = createTRPCRouter({
}
const newApplication = await createApplication(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
+13 -13
View File
@@ -31,7 +31,7 @@ import {
findGitProviderById,
findProjectById,
findServerById,
findUserById,
findWebServer,
getComposeContainer,
loadServices,
randomizeComposeFile,
@@ -64,7 +64,7 @@ export const composeRouter = createTRPCRouter({
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -88,7 +88,7 @@ export const composeRouter = createTRPCRouter({
}
const newService = await createCompose(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -469,7 +469,7 @@ export const composeRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -487,8 +487,8 @@ export const composeRouter = createTRPCRouter({
const template = await fetchTemplateFiles(input.id, input.baseUrl);
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
const webServer = await findWebServer();
let serverIp = webServer.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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
const webServer = await findWebServer();
let serverIp = webServer.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 admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
const webServer = await findWebServer();
let serverIp = webServer.serverIp || "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
+6 -12
View File
@@ -13,9 +13,9 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
findWebServer,
generateTraefikMeDomain,
manageDomain,
removeDomain,
@@ -93,25 +93,19 @@ export const domainRouter = createTRPCRouter({
}),
generateDomain: protectedProcedure
.input(z.object({ appName: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
return generateTraefikMeDomain(
input.appName,
ctx.user.ownerId,
input.serverId,
);
.mutation(async ({ input }) => {
return generateTraefikMeDomain(input.appName, input.serverId);
}),
canGenerateTraefikMeDomains: protectedProcedure
.input(z.object({ serverId: z.string() }))
.query(async ({ input, ctx }) => {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
.query(async ({ input }) => {
const webServer = await findWebServer();
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
return organization?.owner.serverIp;
return webServer?.serverIp;
}),
update: protectedProcedure
+4 -4
View File
@@ -41,7 +41,7 @@ export const mariadbRouter = createTRPCRouter({
.input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mariadbRouter = createTRPCRouter({
});
}
const newMariadb = await createMariadb(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
+4 -4
View File
@@ -41,7 +41,7 @@ export const mongoRouter = createTRPCRouter({
.input(apiCreateMongo)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mongoRouter = createTRPCRouter({
});
}
const newMongo = await createMongo(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mongoId,
+4 -4
View File
@@ -44,7 +44,7 @@ export const mysqlRouter = createTRPCRouter({
.input(apiCreateMySql)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -69,7 +69,7 @@ export const mysqlRouter = createTRPCRouter({
}
const newMysql = await createMysql(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
@@ -24,7 +24,7 @@ import {
apiUpdateTelegram,
notifications,
server,
users_temp,
webServer,
} 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(users_temp)
.from(webServer)
.where(
sql`${users_temp.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
sql`${webServer.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
);
if (!result?.[0]?.id) {
if (!result?.[0]?.webServerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token not found",
});
}
organizationId = result?.[0]?.id;
organizationId = result?.[0]?.webServerId;
ServerName = "Dokploy";
} else {
const result = await db
@@ -32,8 +32,6 @@ export const organizationRouter = createTRPCRouter({
.returning()
.then((res) => res[0]);
console.log("result", result);
if (!result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
+4 -4
View File
@@ -41,7 +41,7 @@ export const postgresRouter = createTRPCRouter({
.input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const postgresRouter = createTRPCRouter({
});
}
const newPostgres = await createPostgres(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.postgresId,
+10 -7
View File
@@ -57,7 +57,7 @@ export const projectRouter = createTRPCRouter({
.input(apiCreateProject)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -78,7 +78,7 @@ export const projectRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkProjectAccess(
ctx.user.id,
"delete",
@@ -314,7 +314,7 @@ export const projectRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -649,7 +649,10 @@ export const projectRouter = createTRPCRouter({
}
}
if (!input.duplicateInSameProject && ctx.user.role === "member") {
if (
!input.duplicateInSameProject &&
(ctx.user.role.name === "member" || !ctx.user.role.isSystem)
) {
await addNewProject(
ctx.user.id,
targetProject.projectId,
+4 -4
View File
@@ -41,7 +41,7 @@ export const redisRouter = createTRPCRouter({
.input(apiCreateRedis)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const redisRouter = createTRPCRouter({
});
}
const newRedis = await createRedis(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
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 === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.redisId,
+75
View File
@@ -0,0 +1,75 @@
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),
// };
// }),
});
-147
View File
@@ -1,16 +1,12 @@
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,
@@ -23,7 +19,6 @@ import {
execAsync,
execAsyncRemote,
findServerById,
findUserById,
getDokployImage,
getDokployImageTag,
getLogCleanupStatus,
@@ -40,14 +35,9 @@ import {
readMainConfig,
readMonitoringConfig,
recreateDirectory,
sendDockerCleanupNotifications,
spawnAsync,
startLogCleanup,
stopLogCleanup,
updateLetsEncryptEmail,
updateServerById,
updateServerTraefik,
updateUser,
writeConfig,
writeMainConfig,
writeTraefikConfigInPath,
@@ -57,7 +47,6 @@ 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";
@@ -187,135 +176,6 @@ 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) {
@@ -470,13 +330,6 @@ 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> => {
+135 -26
View File
@@ -1,14 +1,13 @@
import {
IS_CLOUD,
createApiKey,
findAdmin,
findNotificationById,
findOrganizationById,
findUserById,
getUserByToken,
removeUserById,
sendEmailNotification,
updateUser,
findWebServer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -30,6 +29,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
const apiCreateApiKey = z.object({
name: z.string().min(1),
@@ -54,6 +54,7 @@ export const userRouter = createTRPCRouter({
where: eq(member.organizationId, ctx.session.activeOrganizationId),
with: {
user: true,
role: true,
},
orderBy: [asc(member.createdAt)],
});
@@ -86,7 +87,10 @@ 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 !== "owner") {
if (
memberResult.userId !== ctx.user.id &&
ctx.user.role?.name !== "owner"
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
@@ -102,6 +106,7 @@ export const userRouter = createTRPCRouter({
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
role: true,
user: {
with: {
apiKeys: true,
@@ -147,19 +152,6 @@ 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 }) => {
@@ -199,14 +191,6 @@ 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({
@@ -383,6 +367,83 @@ 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({
@@ -410,11 +471,11 @@ export const userRouter = createTRPCRouter({
});
}
const admin = await findAdmin();
const webServer = await findWebServer();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
: webServer.host;
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
@@ -438,4 +499,52 @@ 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;
}
}),
});
@@ -0,0 +1,169 @@
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;
}),
});
+3 -3
View File
@@ -6,7 +6,7 @@
// boolean,
// } from "drizzle-orm/pg-core";
// export const users_temp = pgTable("users_temp", {
// export const users = pgTable("users", {
// 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_temp.id, { onDelete: "cascade" }),
// .references(() => users.id, { onDelete: "cascade" }),
// activeOrganizationId: text("active_organization_id"),
// });
@@ -39,7 +39,7 @@
// providerId: text("provider_id").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => users_temp.id, { onDelete: "cascade" }),
// .references(() => users.id, { onDelete: "cascade" }),
// accessToken: text("access_token"),
// refreshToken: text("refresh_token"),
// idToken: text("id_token"),
@@ -0,0 +1,26 @@
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;
}
}
+26 -31
View File
@@ -9,7 +9,8 @@ import {
import { nanoid } from "nanoid";
import { projects } from "./project";
import { server } from "./server";
import { users_temp } from "./user";
import { users } from "./user";
// import { role } from "./rbac";
export const account = pgTable("account", {
id: text("id")
@@ -21,7 +22,7 @@ export const account = pgTable("account", {
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
@@ -39,9 +40,9 @@ export const account = pgTable("account", {
});
export const accountRelations = relations(account, ({ one }) => ({
user: one(users_temp, {
user: one(users, {
fields: [account.userId],
references: [users_temp.id],
references: [users.id],
}),
}));
@@ -65,15 +66,15 @@ export const organization = pgTable("organization", {
metadata: text("metadata"),
ownerId: text("owner_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => users.id, { onDelete: "cascade" }),
});
export const organizationRelations = relations(
organization,
({ one, many }) => ({
owner: one(users_temp, {
owner: one(users, {
fields: [organization.ownerId],
references: [users_temp.id],
references: [users.id],
}),
servers: many(server),
projects: many(projects),
@@ -90,24 +91,12 @@ export const member = pgTable("member", {
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
.references(() => users.id, { onDelete: "cascade" }),
role: text("role").$type<"owner" | "member" | "admin">(),
// roleId: text("roleId").references(() => role.roleId, { onDelete: "cascade" }),
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()
@@ -123,24 +112,30 @@ export const memberRelations = relations(member, ({ one }) => ({
fields: [member.organizationId],
references: [organization.id],
}),
user: one(users_temp, {
user: one(users, {
fields: [member.userId],
references: [users_temp.id],
references: [users.id],
}),
// role: one(role, {
// fields: [member.roleId],
// references: [role.roleId],
// }),
}));
export const invitation = pgTable("invitation", {
id: text("id").primaryKey(),
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role").$type<"owner" | "member" | "admin">(),
role: text("role"),
status: text("status").notNull(),
expiresAt: timestamp("expires_at").notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => users.id, { onDelete: "cascade" }),
teamId: text("team_id"),
});
@@ -157,7 +152,7 @@ export const twoFactor = pgTable("two_factor", {
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => users.id, { onDelete: "cascade" }),
});
export const apikey = pgTable("apikey", {
@@ -168,7 +163,7 @@ export const apikey = pgTable("apikey", {
key: text("key").notNull(),
userId: text("user_id")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
.references(() => users.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
@@ -187,8 +182,8 @@ export const apikey = pgTable("apikey", {
});
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(users_temp, {
user: one(users, {
fields: [apikey.userId],
references: [users_temp.id],
references: [users.id],
}),
}));
+4 -4
View File
@@ -19,7 +19,7 @@ import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users_temp } from "./user";
import { users } 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_temp.id),
userId: text("userId").references(() => users.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_temp, {
user: one(users, {
fields: [backups.userId],
references: [users_temp.id],
references: [users.id],
}),
compose: one(compose, {
fields: [backups.composeId],
@@ -8,7 +8,7 @@ import { bitbucket } from "./bitbucket";
import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { users_temp } from "./user";
import { users } 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_temp.id, { onDelete: "cascade" }),
.references(() => users.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_temp, {
user: one(users, {
fields: [gitProvider.userId],
references: [users_temp.id],
references: [users.id],
}),
}));
+2
View File
@@ -30,7 +30,9 @@ 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";
+58
View File
@@ -0,0 +1,58 @@
// 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),
// });
+4 -4
View File
@@ -7,7 +7,7 @@ import { applications } from "./application";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { server } from "./server";
import { users_temp } from "./user";
import { users } 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_temp.id, {
userId: text("userId").references(() => users.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_temp, {
user: one(users, {
fields: [schedules.userId],
references: [users_temp.id],
references: [users.id],
}),
deployments: many(deployments),
}));
+2 -2
View File
@@ -1,5 +1,5 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { users_temp } from "./user";
import { users } 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_temp.id, { onDelete: "cascade" }),
.references(() => users.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
});
+6 -104
View File
@@ -2,7 +2,6 @@ import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
@@ -14,7 +13,6 @@ 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
@@ -23,10 +21,8 @@ import { paths } from "@dokploy/server/constants";
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
// OLD TABLE
// TEMP
export const users_temp = pgTable("user_temp", {
export const users = pgTable("users", {
id: text("id")
.notNull()
.primaryKey()
@@ -36,10 +32,7 @@ export const users_temp = pgTable("user_temp", {
expirationDate: text("expirationDate")
.notNull()
.$defaultFn(() => new Date().toISOString()),
createdAt2: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
createdAt: timestamp("created_at").defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(),
// Auth
twoFactorEnabled: boolean("two_factor_enabled"),
email: text("email").notNull().unique(),
@@ -48,83 +41,19 @@ export const users_temp = pgTable("user_temp", {
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
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 * * *"),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
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_temp, ({ one, many }) => ({
export const usersRelations = relations(users, ({ one, many }) => ({
account: one(account, {
fields: [users_temp.id],
fields: [users.id],
references: [account.userId],
}),
organizations: many(organization),
@@ -134,7 +63,7 @@ export const usersRelations = relations(users_temp, ({ one, many }) => ({
schedules: many(schedules),
}));
const createSchema = createInsertSchema(users_temp, {
const createSchema = createInsertSchema(users, {
id: z.string().min(1),
isRegistered: z.boolean().optional(),
}).omit({
@@ -199,33 +128,6 @@ 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),
+104
View File
@@ -0,0 +1,104 @@
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(),
});
@@ -0,0 +1,13 @@
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();
+2
View File
@@ -13,6 +13,7 @@ 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";
@@ -34,6 +35,7 @@ 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";
+5 -12
View File
@@ -9,9 +9,9 @@ import { IS_CLOUD } from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin";
import { updateUser } from "../services/user";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
import { findWebServer, updateWebServer } from "../services/web-server";
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -31,19 +31,12 @@ const { handler, api } = betterAuth({
},
...(!IS_CLOUD && {
async trustedOrigins() {
const admin = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
with: {
user: true,
},
});
const admin = await findWebServer();
if (admin) {
return [
...(admin.user.serverIp
? [`http://${admin.user.serverIp}:3000`]
: []),
...(admin.user.host ? [`https://${admin.user.host}`] : []),
...(admin.serverIp ? [`http://${admin.serverIp}:3000`] : []),
...(admin.host ? [`https://${admin.host}`] : []),
];
}
return [];
@@ -118,7 +111,7 @@ const { handler, api } = betterAuth({
});
if (!IS_CLOUD) {
await updateUser(user.id, {
await updateWebServer({
serverIp: await getPublicIpWithFallback(),
});
}
+187
View File
@@ -0,0 +1,187 @@
import {
defaultStatements,
memberAc,
ownerAc,
adminAc,
} from "better-auth/plugins/organization/access";
import { createAccessControl } from "better-auth/plugins/access";
/**
* make sure to use `as const` so typescript can infer the type correctly
*/
const statement = {
...defaultStatements,
project: ["view", "create", "delete"],
service: ["view", "create", "delete"],
traefik_files: ["access"],
docker: ["access"],
api: ["access"],
schedules: ["access"],
git_providers: ["access"],
ssh_keys: ["access"],
} as const;
export const ac = createAccessControl(statement);
export const owner = ac.newRole({
...ownerAc.statements,
// inherit all the statements from the statements object
project: ["create", "view", "delete"],
service: ["create", "view", "delete"],
traefik_files: ["access"],
docker: ["access"],
api: ["access"],
schedules: ["access"],
git_providers: ["access"],
ssh_keys: ["access"],
});
export const admin = ac.newRole({
...adminAc.statements,
project: ["create", "view", "delete"],
service: ["create", "view", "delete"],
traefik_files: ["access"],
docker: ["access"],
api: ["access"],
schedules: ["access"],
git_providers: ["access"],
ssh_keys: ["access"],
});
export const member = ac.newRole({
...memberAc.statements,
project: ["create", "view", "delete"],
service: ["create", "view", "delete"],
});
export const PERMISSIONS = {
PROJECT: {
VIEW: {
name: "project:view",
description: "View projects",
},
CREATE: {
name: "project:create",
description: "Create projects",
},
DELETE: {
name: "project:delete",
description: "Delete projects",
},
},
SERVICE: {
VIEW: {
name: "service:view",
description: "View services",
},
CREATE: {
name: "service:create",
description: "Create services",
},
DELETE: {
name: "service:delete",
description: "Delete services",
},
},
TRAEFIK: {
ACCESS: {
name: "traefik_files:access",
description: "Access traefik files",
},
},
DOCKER: {
VIEW: {
name: "docker:view",
description: "View docker",
},
},
API: {
ACCESS: {
name: "api:access",
description: "Access API",
},
},
SCHEDULES: {
ACCESS: {
name: "schedules:access",
description: "Access schedules",
},
},
GIT_PROVIDERS: {
ACCESS: {
name: "git_providers:access",
description: "Access git providers",
},
},
SSH_KEYS: {
ACCESS: {
name: "ssh_keys:access",
description: "Access ssh keys",
},
},
} as const;
export const ownerPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
PERMISSIONS.GIT_PROVIDERS.ACCESS,
PERMISSIONS.SSH_KEYS.ACCESS,
] as const;
export const adminPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.DOCKER.VIEW,
PERMISSIONS.API.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
PERMISSIONS.GIT_PROVIDERS.ACCESS,
PERMISSIONS.SSH_KEYS.ACCESS,
] as const;
export const memberPermissions = [
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.TRAEFIK.ACCESS,
] as const;
export const defaultPermissions = [
{
name: "owner",
description: "Owner of the organization with full access to all features",
permissions: ownerPermissions,
},
{
name: "admin",
description:
"Administrator with access to manage projects, services and configurations",
permissions: adminPermissions,
},
{
name: "member",
description:
"Regular member with access to create projects and manage services",
permissions: memberPermissions,
},
] as const;
// Utility type to extract all permission names
type ExtractPermissionNames<T> = T extends { name: infer U }
? U
: T extends object
? {
[K in keyof T]: ExtractPermissionNames<T[K]>;
}[keyof T]
: never;
export type PermissionName = ExtractPermissionNames<typeof PERMISSIONS>;
+18 -19
View File
@@ -3,18 +3,16 @@ import {
invitation,
member,
organization,
users_temp,
users,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { findWebServer } from "./web-server";
export const findUserById = async (userId: string) => {
const user = await db.query.users_temp.findFirst({
where: eq(users_temp.id, userId),
// with: {
// account: true,
// },
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!user) {
throw new TRPCError({
@@ -46,21 +44,21 @@ export const isAdminPresent = async () => {
return true;
};
export const findAdmin = async () => {
const admin = await db.query.member.findFirst({
export const findOwner = async () => {
const owner = await db.query.member.findFirst({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
if (!admin) {
if (!owner) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Admin not found",
message: "Owner not found",
});
}
return admin;
return owner;
};
export const getUserByToken = async (token: string) => {
@@ -73,6 +71,7 @@ export const getUserByToken = async (token: string) => {
expiresAt: true,
role: true,
inviterId: true,
organizationId: true,
},
});
@@ -83,8 +82,8 @@ export const getUserByToken = async (token: string) => {
});
}
const userAlreadyExists = await db.query.users_temp.findFirst({
where: eq(users_temp.email, user?.email || ""),
const userAlreadyExists = await db.query.users.findFirst({
where: eq(users.email, user?.email || ""),
});
const { expiresAt, ...rest } = user;
@@ -97,8 +96,8 @@ export const getUserByToken = async (token: string) => {
export const removeUserById = async (userId: string) => {
await db
.delete(users_temp)
.where(eq(users_temp.id, userId))
.delete(users)
.where(eq(users.id, userId))
.returning()
.then((res) => res[0]);
};
@@ -107,10 +106,10 @@ export const getDokployUrl = async () => {
if (IS_CLOUD) {
return "https://app.dokploy.com";
}
const admin = await findAdmin();
const webServer = await findWebServer();
if (admin.user.host) {
return `https://${admin.user.host}`;
if (webServer.host) {
return `https://${webServer.host}`;
}
return `http://${admin.user.serverIp}:${process.env.PORT}`;
return `http://${webServer.serverIp}:${process.env.PORT}`;
};
+4 -10
View File
@@ -6,8 +6,8 @@ import { generateObject } from "ai";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findOrganizationById } from "./admin";
import { findServerById } from "./server";
import { findWebServer } from "./web-server";
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
@@ -53,18 +53,12 @@ export const deleteAiSettings = async (aiId: string) => {
};
interface Props {
organizationId: string;
aiId: string;
input: string;
serverId?: string | undefined;
}
export const suggestVariants = async ({
organizationId,
aiId,
input,
serverId,
}: Props) => {
export const suggestVariants = async ({ aiId, input, serverId }: Props) => {
try {
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
@@ -79,8 +73,8 @@ export const suggestVariants = async ({
let ip = "";
if (!IS_CLOUD) {
const organization = await findOrganizationById(organizationId);
ip = organization?.owner.serverIp || "";
const webServer = await findWebServer();
ip = webServer?.serverIp || "";
}
if (serverId) {
+3 -4
View File
@@ -6,10 +6,10 @@ import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { type apiCreateDomain, domains } from "../db/schema";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
import { findServerById } from "./server";
import { findWebServer } from "./web-server";
export type Domain = typeof domains.$inferSelect;
@@ -43,7 +43,6 @@ export const createDomain = async (input: typeof apiCreateDomain._type) => {
export const generateTraefikMeDomain = async (
appName: string,
userId: string,
serverId?: string,
) => {
if (serverId) {
@@ -60,9 +59,9 @@ export const generateTraefikMeDomain = async (
projectName: appName,
});
}
const admin = await findUserById(userId);
const webServer = await findWebServer();
return generateRandomDomain({
serverIp: admin?.serverIp || "",
serverIp: webServer?.serverIp || "",
projectName: appName,
});
};
@@ -2,7 +2,6 @@ import { db } from "@dokploy/server/db";
import {
type apiCreatePreviewDeployment,
deployments,
organization,
previewDeployments,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -13,11 +12,11 @@ import { removeDirectoryCode } from "../utils/filesystem/directory";
import { authGithub } from "../utils/providers/github";
import { removeTraefikConfig } from "../utils/traefik/application";
import { manageDomain } from "../utils/traefik/domain";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
import { createDomain } from "./domain";
import { type Github, getIssueComment } from "./github";
import { findWebServer } from "./web-server";
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
@@ -156,14 +155,10 @@ export const createPreviewDeployment = async (
const application = await findApplicationById(schema.applicationId);
const appName = `preview-${application.appName}-${generatePassword(6)}`;
const org = await db.query.organization.findFirst({
where: eq(organization.id, application.project.organizationId),
});
const generateDomain = await generateWildcardDomain(
application.previewWildcard || "*.traefik.me",
appName,
application.server?.ipAddress || "",
org?.ownerId || "",
);
const octokit = authGithub(application?.github as Github);
@@ -256,7 +251,6 @@ const generateWildcardDomain = async (
baseDomain: string,
appName: string,
serverIp: string,
userId: string,
): Promise<string> => {
if (!baseDomain.startsWith("*.")) {
throw new Error('The base domain must start with "*."');
@@ -274,8 +268,8 @@ const generateWildcardDomain = async (
}
if (!ip) {
const admin = await findUserById(userId);
ip = admin?.serverIp || "";
const webServer = await findWebServer();
ip = webServer?.serverIp || "";
}
const slugIp = ip.replaceAll(".", "-");
+119
View File
@@ -0,0 +1,119 @@
// import { eq } from "drizzle-orm";
// import { db } from "../db";
// import {
// type createRoleSchema,
// member,
// role,
// type updateRoleSchema,
// } from "../db/schema";
// import type { z } from "zod";
// import {
// adminPermissions,
// memberPermissions,
// ownerPermissions,
// } from "../lib/permissions";
// export const createRole = async (
// input: z.infer<typeof createRoleSchema>,
// organizationId: string,
// ) => {
// await db.transaction(async (tx) => {
// const { ...other } = input;
// const newRole = await tx
// .insert(role)
// .values({ ...other, organizationId })
// .returning()
// .then((res) => res[0]);
// if (!newRole) {
// throw new Error("Failed to create role");
// }
// return role;
// });
// };
// const findRoleById = async (roleId: string) => {
// const result = await db.query.role.findFirst({
// where: eq(role.roleId, roleId),
// });
// if (!result) {
// throw new Error("Role not found");
// }
// return result;
// };
// export const removeRoleById = async (roleId: string) => {
// const currentRole = await findRoleById(roleId);
// if (!currentRole) {
// throw new Error("Role not found");
// }
// if (currentRole.isSystem) {
// throw new Error("Cannot delete system role");
// }
// const members = await db.query.member.findMany({
// where: eq(member.roleId, roleId),
// });
// if (members.length > 0) {
// throw new Error("Cannot delete role with assigned members");
// }
// await db.delete(role).where(eq(role.roleId, roleId));
// return currentRole;
// };
// export const updateRoleById = async (
// roleId: string,
// input: z.infer<typeof updateRoleSchema>,
// ) => {
// const currentRole = await findRoleById(roleId);
// if (!currentRole) {
// throw new Error("Role not found");
// }
// if (currentRole.isSystem) {
// throw new Error("Cannot update system role");
// }
// await db.update(role).set(input).where(eq(role.roleId, roleId));
// return currentRole;
// };
// export const createDefaultRoles = async (organizationId: string) => {
// await db.transaction(async (tx) => {
// await tx.insert(role).values({
// name: "owner",
// description: "Owner of the organization with full access to all features",
// organizationId,
// isSystem: true,
// permissions: ownerPermissions.map((permission) => permission.name),
// });
// await tx.insert(role).values({
// name: "admin",
// description:
// "Administrator with access to manage projects, services and configurations",
// organizationId,
// isSystem: true,
// permissions: adminPermissions.map((permission) => permission.name),
// });
// await tx.insert(role).values({
// name: "member",
// description:
// "Regular member with access to create projects and manage services",
// organizationId,
// isSystem: true,
// permissions: memberPermissions.map((permission) => permission.name),
// });
// });
// };
+26 -21
View File
@@ -1,10 +1,11 @@
import { db } from "@dokploy/server/db";
import { apikey, member, users_temp } from "@dokploy/server/db/schema";
import { apikey, member, users } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
import { PERMISSIONS } from "../lib/permissions";
export type User = typeof users_temp.$inferSelect;
export type User = typeof users.$inferSelect;
export const addNewProject = async (
userId: string,
@@ -44,13 +45,16 @@ export const canPerformCreationService = async (
projectId: string,
organizationId: string,
) => {
const { accessedProjects, canCreateServices } = await findMemberById(
const { accessedProjects, role } = await findMemberById(
userId,
organizationId,
);
const haveAccessToProject = accessedProjects.includes(projectId);
if (canCreateServices && haveAccessToProject) {
if (
role?.permissions?.includes(PERMISSIONS.SERVICE.CREATE.name) &&
haveAccessToProject
) {
return true;
}
@@ -77,13 +81,16 @@ export const canPeformDeleteService = async (
serviceId: string,
organizationId: string,
) => {
const { accessedServices, canDeleteServices } = await findMemberById(
const { accessedServices, role } = await findMemberById(
userId,
organizationId,
);
const haveAccessToService = accessedServices.includes(serviceId);
if (canDeleteServices && haveAccessToService) {
if (
role?.permissions?.includes(PERMISSIONS.SERVICE.DELETE.name) &&
haveAccessToService
) {
return true;
}
@@ -94,9 +101,9 @@ export const canPerformCreationProject = async (
userId: string,
organizationId: string,
) => {
const { canCreateProjects } = await findMemberById(userId, organizationId);
const { role } = await findMemberById(userId, organizationId);
if (canCreateProjects) {
if (role?.permissions?.includes(PERMISSIONS.PROJECT.CREATE.name)) {
return true;
}
@@ -107,9 +114,9 @@ export const canPerformDeleteProject = async (
userId: string,
organizationId: string,
) => {
const { canDeleteProjects } = await findMemberById(userId, organizationId);
const { role } = await findMemberById(userId, organizationId);
if (canDeleteProjects) {
if (role?.permissions?.includes(PERMISSIONS.PROJECT.DELETE.name)) {
return true;
}
@@ -135,11 +142,8 @@ export const canAccessToTraefikFiles = async (
userId: string,
organizationId: string,
) => {
const { canAccessToTraefikFiles } = await findMemberById(
userId,
organizationId,
);
return canAccessToTraefikFiles;
const { role } = await findMemberById(userId, organizationId);
return role?.permissions?.includes(PERMISSIONS.TRAEFIK.ACCESS.name);
};
export const checkServiceAccess = async (
@@ -183,7 +187,7 @@ export const checkServiceAccess = async (
};
export const checkProjectAccess = async (
authId: string,
userId: string,
action: "create" | "delete" | "access",
organizationId: string,
projectId?: string,
@@ -192,16 +196,16 @@ export const checkProjectAccess = async (
switch (action) {
case "access":
hasPermission = await canPerformAccessProject(
authId,
userId,
projectId as string,
organizationId,
);
break;
case "create":
hasPermission = await canPerformCreationProject(authId, organizationId);
hasPermission = await canPerformCreationProject(userId, organizationId);
break;
case "delete":
hasPermission = await canPerformDeleteProject(authId, organizationId);
hasPermission = await canPerformDeleteProject(userId, organizationId);
break;
default:
hasPermission = false;
@@ -225,6 +229,7 @@ export const findMemberById = async (
),
with: {
user: true,
role: true,
},
});
@@ -239,11 +244,11 @@ export const findMemberById = async (
export const updateUser = async (userId: string, userData: Partial<User>) => {
const user = await db
.update(users_temp)
.update(users)
.set({
...userData,
})
.where(eq(users_temp.id, userId))
.where(eq(users.id, userId))
.returning()
.then((res) => res[0]);
@@ -0,0 +1,42 @@
import { webServer, type updateWebServerSchema } from "../db/schema";
import { db } from "../db";
import type { z } from "zod";
import { TRPCError } from "@trpc/server";
export const createWebServer = async () => {
const exists = await findWebServer();
if (exists) {
return exists;
}
const server = await db?.insert(webServer).values({});
return server;
};
export const findWebServer = async () => {
const server = await db?.query.webServer.findFirst();
if (!server) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Web server not found",
});
}
return server;
};
export const updateWebServer = async (
input: z.infer<typeof updateWebServerSchema>,
) => {
const server = await findWebServer();
if (!server) {
await createWebServer();
}
const updated = await db
.update(webServer)
.set({
...input,
})
.returning()
.then(([updated]) => updated);
return updated;
};
@@ -1,11 +1,11 @@
import { findServerById } from "@dokploy/server/services/server";
import type { ContainerCreateOptions } from "dockerode";
import { IS_CLOUD } from "../constants";
import { findUserById } from "../services/admin";
import { getDokployImageTag } from "../services/settings";
import { pullImage, pullRemoteImage } from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { getRemoteDocker } from "../utils/servers/remote-docker";
import { findWebServer } from "../services/web-server";
export const setupMonitoring = async (serverId: string) => {
const server = await findServerById(serverId);
@@ -83,8 +83,8 @@ export const setupMonitoring = async (serverId: string) => {
}
};
export const setupWebMonitoring = async (userId: string) => {
const user = await findUserById(userId);
export const setupWebMonitoring = async () => {
const webServer = await findWebServer();
const containerName = "dokploy-monitoring";
let imageName = "dokploy/monitoring:latest";
@@ -99,7 +99,7 @@ export const setupWebMonitoring = async (userId: string) => {
const settings: ContainerCreateOptions = {
name: containerName,
Env: [`METRICS_CONFIG=${JSON.stringify(user?.metricsConfig)}`],
Env: [`METRICS_CONFIG=${JSON.stringify(webServer?.metricsConfig)}`],
Image: imageName,
HostConfig: {
// Memory: 100 * 1024 * 1024, // 100MB en bytes
@@ -110,9 +110,9 @@ export const setupWebMonitoring = async (userId: string) => {
Name: "always",
},
PortBindings: {
[`${user?.metricsConfig?.server?.port}/tcp`]: [
[`${webServer?.metricsConfig?.server?.port}/tcp`]: [
{
HostPort: user?.metricsConfig?.server?.port.toString(),
HostPort: webServer?.metricsConfig?.server?.port.toString(),
},
],
},
@@ -126,7 +126,7 @@ export const setupWebMonitoring = async (userId: string) => {
// NetworkMode: "host",
},
ExposedPorts: {
[`${user?.metricsConfig?.server?.port}/tcp`]: {},
[`${webServer?.metricsConfig?.server?.port}/tcp`]: {},
},
};
const docker = await getRemoteDocker();
+13 -10
View File
@@ -1,8 +1,11 @@
import { paths } from "@dokploy/server/constants";
import { findAdmin } from "@dokploy/server/services/admin";
import { updateUser } from "@dokploy/server/services/user";
import { findOwner } from "@dokploy/server/services/admin";
import { scheduleJob, scheduledJobs } from "node-schedule";
import { execAsync } from "../process/execAsync";
import {
findWebServer,
updateWebServer,
} from "@dokploy/server/services/web-server";
const LOG_CLEANUP_JOB_NAME = "access-log-cleanup";
@@ -29,9 +32,9 @@ export const startLogCleanup = async (
}
});
const admin = await findAdmin();
if (admin) {
await updateUser(admin.user.id, {
const owner = await findOwner();
if (owner) {
await updateWebServer({
logCleanupCron: cronExpression,
});
}
@@ -51,9 +54,9 @@ export const stopLogCleanup = async (): Promise<boolean> => {
}
// Update database
const admin = await findAdmin();
if (admin) {
await updateUser(admin.user.id, {
const owner = await findOwner();
if (owner) {
await updateWebServer({
logCleanupCron: null,
});
}
@@ -69,8 +72,8 @@ export const getLogCleanupStatus = async (): Promise<{
enabled: boolean;
cronExpression: string | null;
}> => {
const admin = await findAdmin();
const cronExpression = admin?.user.logCleanupCron ?? null;
const webServer = await findWebServer();
const cronExpression = webServer?.logCleanupCron ?? null;
return {
enabled: cronExpression !== null,
cronExpression,
+8 -5
View File
@@ -15,6 +15,7 @@ import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { eq } from "drizzle-orm";
import { startLogCleanup } from "../access-log/handler";
import { findWebServer } from "@dokploy/server/services/web-server";
export const initCronJobs = async () => {
console.log("Setting up cron jobs....");
@@ -26,11 +27,13 @@ export const initCronJobs = async () => {
},
});
if (!admin) {
const webServer = await findWebServer();
if (!webServer || !admin) {
return;
}
if (admin.user.enableDockerCleanup) {
if (webServer.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
@@ -87,9 +90,9 @@ export const initCronJobs = async () => {
}
}
if (admin?.user.logCleanupCron) {
console.log("Starting log requests cleanup", admin.user.logCleanupCron);
await startLogCleanup(admin.user.logCleanupCron);
if (webServer.logCleanupCron) {
console.log("Starting log requests cleanup", webServer.logCleanupCron);
await startLogCleanup(webServer.logCleanupCron);
}
};
@@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { User } from "@dokploy/server/services/user";
import type { WebServer } from "@dokploy/server/db/schema";
import { dump, load } from "js-yaml";
import {
loadOrCreateConfig,
@@ -12,10 +12,10 @@ import type { FileConfig } from "./file-types";
import type { MainTraefikConfig } from "./types";
export const updateServerTraefik = (
user: User | null,
webServer: WebServer | null,
newHost: string | null,
) => {
const { https, certificateType } = user || {};
const { https, certificateType } = webServer || {};
const appName = "dokploy";
const config: FileConfig = loadOrCreateConfig(appName);