feat: reset role defaults

This commit is contained in:
Henry
2026-01-28 23:00:10 +00:00
parent 3369092509
commit 95bfe41910
3 changed files with 161 additions and 40 deletions

View File

@@ -73,8 +73,34 @@ export function EditMemberPermissionsModal() {
}, },
}); });
const resetMutation = api.permission.resetMemberPermissions.useMutation({
onSuccess: async () => {
showPopup({
header: t`Permissions reset`,
message: t`This member's permissions have been reset to their role defaults.`,
icon: "success",
});
await utils.permission.getMemberPermissions.invalidate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
},
onError: () => {
showPopup({
header: t`Unable to reset permissions`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const effectivePermissions = (data?.permissions ?? []) as Permission[]; const effectivePermissions = (data?.permissions ?? []) as Permission[];
const isBusy = grantMutation.isPending || revokeMutation.isPending; const hasOverrides = (data?.overrides?.length ?? 0) > 0;
const isBusy =
grantMutation.isPending ||
revokeMutation.isPending ||
resetMutation.isPending;
const handleToggle = (permission: Permission, nextState: boolean) => { const handleToggle = (permission: Permission, nextState: boolean) => {
if (!workspace.publicId || !entityId) return; if (!workspace.publicId || !entityId) return;
@@ -152,47 +178,69 @@ export function EditMemberPermissionsModal() {
{t`Loading permissions...`} {t`Loading permissions...`}
</p> </p>
) : ( ) : (
<div className="max-h-80 space-y-3 overflow-y-auto pr-1"> <>
{Object.values(permissionCategories).map((category, index) => ( <div className="max-h-80 space-y-3 overflow-y-auto pr-1">
<div {Object.values(permissionCategories).map((category, index) => (
key={category.label} <div
className={`py-2 ${index > 0 ? "border-t border-light-300 dark:border-dark-300" : ""}`} key={category.label}
> className={`py-2 ${
<div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950"> index > 0
{category.label} ? "border-t border-light-300 dark:border-dark-300"
</div> : ""
<div className="space-y-1.5"> }`}
{category.permissions.map((permission) => { >
const label = <div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950">
permissionLabels[permission] ?? (permission as string); {category.label}
</div>
<div className="space-y-1.5">
{category.permissions.map((permission) => {
const label =
permissionLabels[permission] ?? (permission as string);
return ( return (
<div <div
key={permission} key={permission}
className="flex items-center justify-between gap-3 py-0.5" className="flex items-center justify-between gap-3 py-0.5"
> >
<span className="text-xs text-light-900 dark:text-dark-900 mb-"> <span className="text-xs text-light-900 dark:text-dark-900">
{label} {label}
</span> </span>
<Toggle <Toggle
label={label} label={label}
showLabel={false} showLabel={false}
isChecked={effectivePermissions.includes(permission)} isChecked={effectivePermissions.includes(permission)}
disabled={isBusy} disabled={isBusy}
onChange={() => onChange={() =>
handleToggle( handleToggle(
permission, permission,
!effectivePermissions.includes(permission), !effectivePermissions.includes(permission),
) )
} }
/> />
</div> </div>
); );
})} })}
</div>
</div> </div>
</div> ))}
))} </div>
</div> <div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => {
if (!workspace.publicId || !entityId || isBusy) return;
resetMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
}}
disabled={isBusy || !hasOverrides}
className="rounded-md border border-light-400 px-3 py-1.5 text-xs text-light-900 hover:bg-light-200 disabled:opacity-60 dark:border-dark-400 dark:text-dark-900 dark:hover:bg-dark-200"
>
{t`Reset to role defaults`}
</button>
</div>
</>
)} )}
</div> </div>

View File

@@ -289,6 +289,67 @@ export const permissionRouter = createTRPCRouter({
input.permission as Permission, input.permission as Permission,
); );
return { success: true };
}),
resetMemberPermissions: protectedProcedure
.meta({
openapi: {
summary: "Reset member permissions to role defaults",
method: "POST",
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/reset",
description:
"Clears all custom permission overrides for a member so their effective permissions come only from their role",
tags: ["Permissions"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
memberPublicId: z.string().min(12),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) {
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
}
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace) {
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
}
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
});
}
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
await permissionRepo.clearMemberPermissionOverrides(ctx.db, member.id);
return { success: true }; return { success: true };
}), }),
getWorkspaceRoles: protectedProcedure getWorkspaceRoles: protectedProcedure

View File

@@ -248,6 +248,18 @@ export const revokePermission = async (
return result; return result;
}; };
/**
* Clear all permission overrides for a workspace member
*/
export const clearMemberPermissionOverrides = async (
db: dbClient,
workspaceMemberId: number,
) => {
await db
.delete(workspaceMemberPermissions)
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
};
/** /**
* Get member with their role by userId and workspaceId * Get member with their role by userId and workspaceId
*/ */