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:
Mauricio Siu
2025-07-11 22:27:06 -06:00
parent 8b8dc8c94f
commit 427674dd64
14 changed files with 6884 additions and 191 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE "member" ALTER COLUMN "roleId" DROP NOT NULL;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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;
}),

View File

@@ -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),
};
}),
});

View File

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

View File

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

View File

@@ -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 = [
{

View File

@@ -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 || "",
});
});
}

View File

@@ -73,6 +73,7 @@ export const getUserByToken = async (token: string) => {
expiresAt: true,
role: true,
inviterId: true,
organizationId: true,
},
});

View File

@@ -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),
});
});
};