mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-25 00:55:30 +02:00
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.
This commit is contained in:
@@ -157,6 +157,7 @@ export const AddInvitation = () => {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
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,
|
||||
} 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 { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
const assignRoleSchema = z.object({
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
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 { 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: updateMemberRole, isLoading: isAssigningRole } =
|
||||
api.user.assignRole.useMutation();
|
||||
|
||||
const assignForm = useForm<AssignRoleForm>({
|
||||
resolver: zodResolver(assignRoleSchema),
|
||||
});
|
||||
|
||||
const createForm = useForm<CreateRoleForm>({
|
||||
resolver: zodResolver(createRoleSchema),
|
||||
defaultValues: {
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
assignForm.reset({
|
||||
roleId: userData.roleId,
|
||||
});
|
||||
}
|
||||
}, [userData, assignForm]);
|
||||
|
||||
const onAssignRole = async (data: AssignRoleForm) => {
|
||||
try {
|
||||
await updateMemberRole({
|
||||
userId,
|
||||
roleId: data.roleId,
|
||||
});
|
||||
toast.success("Role assigned successfully");
|
||||
await refetchUser();
|
||||
await refetchRoles();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to assign role";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateRole = async (data: CreateRoleForm) => {
|
||||
try {
|
||||
await createRole({
|
||||
...data,
|
||||
permissions: data.permissions,
|
||||
});
|
||||
toast.success("Role created successfully");
|
||||
refetchRoles();
|
||||
setActiveTab("assign");
|
||||
createForm.reset();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "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>
|
||||
);
|
||||
};
|
||||
|
||||
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-2xl">
|
||||
<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">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"
|
||||
>
|
||||
{/* 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",
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 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,
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={permission}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{permissionInfo?.name ||
|
||||
permission}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isAssigningRole}>
|
||||
{isAssigningRole ? "Assigning..." : "Assign 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>
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Select the permissions for this role
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isCreatingRole}>
|
||||
{isCreatingRole ? "Creating..." : "Create Role"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ 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: isCloud } = api.settings.isCloud.useQuery();
|
||||
@@ -92,12 +93,12 @@ export const ShowUsers = () => {
|
||||
<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">
|
||||
@@ -128,9 +129,14 @@ export const ShowUsers = () => {
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{member.role !== "owner" && (
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
/>
|
||||
<>
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
/>
|
||||
<AddUserPermissionsV2
|
||||
userId={member.user.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{member.role !== "owner" && (
|
||||
|
||||
84
apps/dokploy/drizzle/0103_misty_shockwave.sql
Normal file
84
apps/dokploy/drizzle/0103_misty_shockwave.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
CREATE TABLE "organization_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 NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"organizationId" text NOT NULL
|
||||
);
|
||||
|
||||
-- Create default roles for each organization
|
||||
DO $$
|
||||
DECLARE
|
||||
org RECORD;
|
||||
BEGIN
|
||||
FOR org IN SELECT id FROM "organization"
|
||||
LOOP
|
||||
-- Insert owner role
|
||||
INSERT INTO "organization_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"}',
|
||||
NOW(),
|
||||
NOW(),
|
||||
org.id
|
||||
);
|
||||
|
||||
-- Insert admin role
|
||||
INSERT INTO "organization_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"}',
|
||||
NOW(),
|
||||
NOW(),
|
||||
org.id
|
||||
);
|
||||
|
||||
-- Insert member role
|
||||
INSERT INTO "organization_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 "member" ADD COLUMN "roleId" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "organization_role" ADD CONSTRAINT "organization_role_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "member" ADD CONSTRAINT "member_roleId_organization_role_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."organization_role"("roleId") ON DELETE cascade ON UPDATE no action;
|
||||
|
||||
-- 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 "member" ALTER COLUMN "roleId" SET NOT NULL;
|
||||
3
apps/dokploy/drizzle/0104_dusty_miss_america.sql
Normal file
3
apps/dokploy/drizzle/0104_dusty_miss_america.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "organization_role" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint
|
||||
ALTER TABLE "organization_role" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint
|
||||
ALTER TABLE "organization_role" ADD CONSTRAINT "organization_role_name_unique" UNIQUE("name");
|
||||
6231
apps/dokploy/drizzle/meta/0103_snapshot.json
Normal file
6231
apps/dokploy/drizzle/meta/0103_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6241
apps/dokploy/drizzle/meta/0104_snapshot.json
Normal file
6241
apps/dokploy/drizzle/meta/0104_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -722,6 +722,20 @@
|
||||
"when": 1751848685503,
|
||||
"tag": "0102_opposite_grandmaster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 103,
|
||||
"version": "7",
|
||||
"when": 1752043274261,
|
||||
"tag": "0103_misty_shockwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 104,
|
||||
"version": "7",
|
||||
"when": 1752046360607,
|
||||
"tag": "0104_dusty_miss_america",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,6 +38,7 @@ 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";
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
*
|
||||
@@ -84,6 +85,7 @@ export const appRouter = createTRPCRouter({
|
||||
schedule: scheduleRouter,
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
role: roleRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
75
apps/dokploy/server/api/routers/role.ts
Normal file
75
apps/dokploy/server/api/routers/role.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiFindOneRole,
|
||||
createRoleSchema,
|
||||
role,
|
||||
updateRoleSchema,
|
||||
} from "@/server/db/schema";
|
||||
import { createRole, removeRoleById, updateRoleById } from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
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),
|
||||
),
|
||||
});
|
||||
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 result = await db.query.role.findMany({
|
||||
where: and(
|
||||
eq(role.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(role.isSystem, true),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
owner: result.find((r) => r.name === "owner"),
|
||||
admin: result.find((r) => r.name === "admin"),
|
||||
member: result.find((r) => r.name === "member"),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -54,6 +54,7 @@ export const userRouter = createTRPCRouter({
|
||||
where: eq(member.organizationId, ctx.session.activeOrganizationId),
|
||||
with: {
|
||||
user: true,
|
||||
role: true,
|
||||
},
|
||||
orderBy: [asc(member.createdAt)],
|
||||
});
|
||||
@@ -438,4 +439,46 @@ export const userRouter = createTRPCRouter({
|
||||
}
|
||||
return inviteLink;
|
||||
}),
|
||||
assignRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
roleId: z.string(),
|
||||
}),
|
||||
)
|
||||
.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 })
|
||||
.where(eq(member.id, memberResult.id));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
26
packages/server/src/db/migrations/create-default-roles.ts
Normal file
26
packages/server/src/db/migrations/create-default-roles.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { nanoid } from "nanoid";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { users_temp } from "./user";
|
||||
import { role } from "./rbac";
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id")
|
||||
@@ -92,6 +93,9 @@ 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
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
teamId: text("team_id"),
|
||||
// Permissions
|
||||
@@ -127,6 +131,10 @@ export const memberRelations = relations(member, ({ one }) => ({
|
||||
fields: [member.userId],
|
||||
references: [users_temp.id],
|
||||
}),
|
||||
role: one(role, {
|
||||
fields: [member.roleId],
|
||||
references: [role.roleId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitation = pgTable("invitation", {
|
||||
|
||||
@@ -30,6 +30,7 @@ export * from "./server";
|
||||
export * from "./utils";
|
||||
export * from "./preview-deployments";
|
||||
export * from "./ai";
|
||||
export * from "./rbac";
|
||||
export * from "./account";
|
||||
export * from "./schedule";
|
||||
export * from "./rollbacks";
|
||||
|
||||
147
packages/server/src/db/schema/rbac.ts
Normal file
147
packages/server/src/db/schema/rbac.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text, timestamp, boolean } 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 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",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const getAllPermissionNames = () => {
|
||||
return Object.values(PERMISSIONS).flatMap((category) =>
|
||||
Object.values(category).map((permission) => permission.name),
|
||||
);
|
||||
};
|
||||
|
||||
export const ownerPermissions = getAllPermissionNames();
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
export const memberPermissions = [
|
||||
PERMISSIONS.PROJECT.CREATE.name,
|
||||
PERMISSIONS.SERVICE.CREATE.name,
|
||||
PERMISSIONS.TRAEFIK.ACCESS.name,
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
export const role = pgTable("organization_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" }),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
13
packages/server/src/db/scripts/create-default-roles.ts
Normal file
13
packages/server/src/db/scripts/create-default-roles.ts
Normal file
@@ -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();
|
||||
@@ -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";
|
||||
|
||||
71
packages/server/src/services/role.ts
Normal file
71
packages/server/src/services/role.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import {
|
||||
type createRoleSchema,
|
||||
role,
|
||||
type updateRoleSchema,
|
||||
} from "../db/schema";
|
||||
import type { z } from "zod";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
await db.update(role).set(input).where(eq(role.roleId, roleId));
|
||||
|
||||
return currentRole;
|
||||
};
|
||||
Reference in New Issue
Block a user