mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-29 02:55:22 +02:00
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.
This commit is contained in:
@@ -49,6 +49,7 @@ type AddInvitation = z.infer<typeof addInvitation>;
|
||||
export const AddInvitation = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { data: roles } = api.role.all.useQuery();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: emailProviders } =
|
||||
@@ -157,8 +158,13 @@ export const AddInvitation = () => {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<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>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
@@ -36,10 +37,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
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({
|
||||
@@ -59,8 +65,15 @@ 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,
|
||||
@@ -72,11 +85,19 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
|
||||
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>({
|
||||
@@ -89,20 +110,51 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
assignForm.reset({
|
||||
roleId: userData.roleId,
|
||||
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"); // Owner permission indicator
|
||||
|
||||
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 refetchRoles();
|
||||
await utils.user.all.invalidate();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to assign role";
|
||||
@@ -112,72 +164,56 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
|
||||
const onCreateRole = async (data: CreateRoleForm) => {
|
||||
try {
|
||||
await createRole({
|
||||
...data,
|
||||
permissions: data.permissions,
|
||||
});
|
||||
toast.success("Role created successfully");
|
||||
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 : "Failed to create role";
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: editingRole
|
||||
? "Failed to update role"
|
||||
: "Failed to create role";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to render a role item
|
||||
const renderRoleItem = (
|
||||
role: Role | undefined,
|
||||
roleKey: "owner" | "admin" | "member",
|
||||
) => {
|
||||
if (!role) {
|
||||
return (
|
||||
<AlertBlock type="warning">
|
||||
Default role '{roleKey}' not found. Please check your database setup.
|
||||
</AlertBlock>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormItem
|
||||
key={role.roleId}
|
||||
className="flex items-center space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={role.roleId} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium capitalize">{roleKey}</span>
|
||||
{roleKey === "owner" && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Full Access
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{role.permissions?.map((permission: string) => {
|
||||
const permissionInfo = defaultRoles?.owner?.permissions?.find(
|
||||
(p) => p === permission,
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={permission}
|
||||
variant={roleKey === "owner" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{permissionInfo?.name || permission}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
);
|
||||
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 (
|
||||
@@ -190,7 +226,7 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
Manage Roles
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Role Management</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -205,7 +241,9 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="assign">Assign Role</TabsTrigger>
|
||||
<TabsTrigger value="create">Create Role</TabsTrigger>
|
||||
<TabsTrigger value="create">
|
||||
{editingRole ? "Edit Role" : "Create Role"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="assign">
|
||||
@@ -224,21 +262,53 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
defaultValue={field.value}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Default Roles Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">
|
||||
Default Roles
|
||||
</h4>
|
||||
{defaultRoles && (
|
||||
<>
|
||||
{renderRoleItem(defaultRoles.owner, "owner")}
|
||||
{renderRoleItem(defaultRoles.admin, "admin")}
|
||||
{renderRoleItem(
|
||||
defaultRoles.member,
|
||||
"member",
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{defaultRoles?.roles?.map((role) => (
|
||||
<FormItem
|
||||
key={role.roleId}
|
||||
className="flex items-center space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={role.roleId || ""} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium capitalize">
|
||||
{role.name}
|
||||
</span>
|
||||
{role.name === "owner" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-xs"
|
||||
>
|
||||
Full Access
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{role.permissions?.map((permission) => (
|
||||
<Badge
|
||||
key={permission.name}
|
||||
variant={
|
||||
role.name === "owner"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{permission.description}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -255,39 +325,93 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
.map((role) => (
|
||||
<FormItem
|
||||
key={role.roleId}
|
||||
className="flex items-center space-x-3 space-y-0"
|
||||
className="flex items-center justify-between space-x-3 space-y-0"
|
||||
>
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{role.permissions?.map(
|
||||
(permission) => {
|
||||
const permissionInfo =
|
||||
defaultRoles?.owner?.permissions?.find(
|
||||
(p) => p === permission,
|
||||
<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>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={permission}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{permissionInfo?.name ||
|
||||
permission}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</FormLabel>
|
||||
},
|
||||
)}
|
||||
</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>
|
||||
@@ -297,6 +421,189 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
</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>
|
||||
@@ -322,6 +629,9 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Developer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Role name must be unique
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -339,6 +649,7 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -358,45 +669,43 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
{defaultRoles?.owner?.permissions?.map(
|
||||
(permission) => (
|
||||
<FormField
|
||||
key={permission}
|
||||
control={createForm.control}
|
||||
name="permissions"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
key={permission}
|
||||
className="flex flex-row items-start space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(
|
||||
permission,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([
|
||||
...(field.value || []),
|
||||
permission,
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) =>
|
||||
value !== permission,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{permission}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{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 />
|
||||
@@ -405,9 +714,23 @@ export const AddUserPermissionsV2 = ({ userId }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isCreatingRole}>
|
||||
{isCreatingRole ? "Creating..." : "Create Role"}
|
||||
<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>
|
||||
|
||||
@@ -93,12 +93,12 @@ export const ShowUsers = () => {
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
member.role.name === "owner"
|
||||
member?.role?.name === "owner"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{member.role.name}
|
||||
{member?.role?.name}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
|
||||
1
apps/dokploy/drizzle/0105_clammy_leopardon.sql
Normal file
1
apps/dokploy/drizzle/0105_clammy_leopardon.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "member" ALTER COLUMN "roleId" DROP NOT NULL;
|
||||
6241
apps/dokploy/drizzle/meta/0105_snapshot.json
Normal file
6241
apps/dokploy/drizzle/meta/0105_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -736,6 +736,13 @@
|
||||
"when": 1752046360607,
|
||||
"tag": "0104_dusty_miss_america",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 105,
|
||||
"version": "7",
|
||||
"when": 1752080331479,
|
||||
"tag": "0105_clammy_leopardon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/server/db";
|
||||
import { invitation, member, organization } from "@/server/db/schema";
|
||||
import { IS_CLOUD } from "@dokploy/server/index";
|
||||
import { invitation, member, organization, role } from "@/server/db/schema";
|
||||
import { createDefaultRoles, IS_CLOUD } from "@dokploy/server/index";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, exists } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -32,20 +32,24 @@ export const organizationRouter = createTRPCRouter({
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
console.log("result", result);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create organization",
|
||||
});
|
||||
}
|
||||
await createDefaultRoles(result.id);
|
||||
|
||||
const ownerRole = await db.query.role.findFirst({
|
||||
where: and(eq(role.name, "owner"), eq(role.organizationId, result.id)),
|
||||
});
|
||||
|
||||
await db.insert(member).values({
|
||||
organizationId: result.id,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
userId: ctx.user.id,
|
||||
roleId: ownerRole?.roleId || "",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
@@ -5,17 +5,11 @@ import {
|
||||
createRoleSchema,
|
||||
role,
|
||||
updateRoleSchema,
|
||||
defaultPermissions,
|
||||
} from "@/server/db/schema";
|
||||
import {
|
||||
adminPermissions,
|
||||
createRole,
|
||||
memberPermissions,
|
||||
ownerPermissions,
|
||||
removeRoleById,
|
||||
updateRoleById,
|
||||
} from "@dokploy/server";
|
||||
import { createRole, removeRoleById, updateRoleById } from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
export const roleRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -24,6 +18,7 @@ export const roleRouter = createTRPCRouter({
|
||||
eq(role.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(role.isSystem, false),
|
||||
),
|
||||
orderBy: [asc(role.createdAt)],
|
||||
});
|
||||
return roles;
|
||||
}),
|
||||
@@ -66,10 +61,26 @@ export const roleRouter = createTRPCRouter({
|
||||
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 {
|
||||
owner: ownerPermissions,
|
||||
admin: adminPermissions,
|
||||
member: memberPermissions,
|
||||
roles: rolesWithDescription,
|
||||
permissions: Array.from(set),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -444,6 +444,8 @@ export const userRouter = createTRPCRouter({
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
roleId: z.string(),
|
||||
accessedProjects: z.array(z.string()).optional(),
|
||||
accessedServices: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -475,7 +477,11 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
await db
|
||||
.update(member)
|
||||
.set({ roleId: input.roleId })
|
||||
.set({
|
||||
roleId: input.roleId,
|
||||
accessedProjects: input.accessedProjects || [],
|
||||
accessedServices: input.accessedServices || [],
|
||||
})
|
||||
.where(eq(member.id, memberResult.id));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -93,9 +93,7 @@ export const member = pgTable("member", {
|
||||
.notNull()
|
||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
|
||||
roleId: text("roleId")
|
||||
.notNull()
|
||||
.references(() => role.roleId, { onDelete: "cascade" }), // Referencia a la nueva tabla de roles
|
||||
roleId: text("roleId").references(() => role.roleId, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
teamId: text("team_id"),
|
||||
// Permissions
|
||||
|
||||
@@ -55,32 +55,32 @@ export const PERMISSIONS = {
|
||||
} as const;
|
||||
|
||||
export const ownerPermissions = [
|
||||
PERMISSIONS.PROJECT.VIEW.name,
|
||||
PERMISSIONS.PROJECT.CREATE.name,
|
||||
PERMISSIONS.PROJECT.DELETE.name,
|
||||
PERMISSIONS.SERVICE.VIEW.name,
|
||||
PERMISSIONS.SERVICE.CREATE.name,
|
||||
PERMISSIONS.SERVICE.DELETE.name,
|
||||
PERMISSIONS.TRAEFIK.ACCESS.name,
|
||||
];
|
||||
PERMISSIONS.PROJECT.VIEW,
|
||||
PERMISSIONS.PROJECT.CREATE,
|
||||
PERMISSIONS.PROJECT.DELETE,
|
||||
PERMISSIONS.SERVICE.VIEW,
|
||||
PERMISSIONS.SERVICE.CREATE,
|
||||
PERMISSIONS.SERVICE.DELETE,
|
||||
PERMISSIONS.TRAEFIK.ACCESS,
|
||||
] as const;
|
||||
|
||||
export const adminPermissions = [
|
||||
PERMISSIONS.PROJECT.VIEW.name,
|
||||
PERMISSIONS.PROJECT.CREATE.name,
|
||||
PERMISSIONS.PROJECT.DELETE.name,
|
||||
PERMISSIONS.SERVICE.VIEW.name,
|
||||
PERMISSIONS.SERVICE.CREATE.name,
|
||||
PERMISSIONS.SERVICE.DELETE.name,
|
||||
PERMISSIONS.TRAEFIK.ACCESS.name,
|
||||
PERMISSIONS.DOCKER.VIEW.name,
|
||||
PERMISSIONS.API.ACCESS.name,
|
||||
];
|
||||
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,
|
||||
] as const;
|
||||
|
||||
export const memberPermissions = [
|
||||
PERMISSIONS.PROJECT.CREATE.name,
|
||||
PERMISSIONS.SERVICE.CREATE.name,
|
||||
PERMISSIONS.TRAEFIK.ACCESS.name,
|
||||
];
|
||||
PERMISSIONS.PROJECT.CREATE,
|
||||
PERMISSIONS.SERVICE.CREATE,
|
||||
PERMISSIONS.TRAEFIK.ACCESS,
|
||||
] as const;
|
||||
|
||||
export const defaultPermissions = [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { IncomingMessage } from "node:http";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { APIError, createAuthMiddleware } from "better-auth/api";
|
||||
import { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
@@ -12,6 +12,7 @@ import { getUserByToken } from "../services/admin";
|
||||
import { updateUser } from "../services/user";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { createDefaultRoles } from "../services/role";
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -86,6 +87,48 @@ const { handler, api } = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path === "/organization/accept-invitation") {
|
||||
const invitationId = ctx.body.invitationId;
|
||||
|
||||
if (invitationId) {
|
||||
const user = await getUserByToken(invitationId);
|
||||
if (!user) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const role = await db.query.role.findFirst({
|
||||
where: and(
|
||||
eq(schema.role.name, user.role || "member"),
|
||||
eq(schema.role.organizationId, user.organizationId),
|
||||
),
|
||||
});
|
||||
|
||||
const userTemp = await db.query.users_temp.findFirst({
|
||||
where: eq(schema.users_temp.email, user.email),
|
||||
});
|
||||
|
||||
const member = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, userTemp?.id || ""),
|
||||
eq(schema.member.organizationId, user.organizationId),
|
||||
),
|
||||
});
|
||||
|
||||
await db
|
||||
.update(schema.member)
|
||||
.set({
|
||||
roleId: role?.roleId || "",
|
||||
})
|
||||
.where(eq(schema.member.userId, member?.userId || ""))
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
@@ -135,11 +178,21 @@ const { handler, api } = betterAuth({
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
await createDefaultRoles(organization?.id || "");
|
||||
|
||||
const ownerRole = await tx.query.role.findFirst({
|
||||
where: and(
|
||||
eq(schema.role.name, "owner"),
|
||||
eq(schema.role.organizationId, organization?.id || ""),
|
||||
),
|
||||
});
|
||||
|
||||
await tx.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: organization?.id || "",
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
roleId: ownerRole?.roleId || "",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export const getUserByToken = async (token: string) => {
|
||||
expiresAt: true,
|
||||
role: true,
|
||||
inviterId: true,
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import {
|
||||
adminPermissions,
|
||||
type createRoleSchema,
|
||||
member,
|
||||
memberPermissions,
|
||||
ownerPermissions,
|
||||
role,
|
||||
type updateRoleSchema,
|
||||
} from "../db/schema";
|
||||
@@ -50,6 +54,14 @@ export const removeRoleById = async (roleId: string) => {
|
||||
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 members");
|
||||
}
|
||||
|
||||
await db.delete(role).where(eq(role.roleId, roleId));
|
||||
|
||||
return currentRole;
|
||||
@@ -69,3 +81,33 @@ export const updateRoleById = async (
|
||||
|
||||
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),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user