From 62725ffa3807c414a346b06fabdca5e2ed28d67e Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 28 Jan 2026 23:18:44 +0000 Subject: [PATCH] feat: clear all permission overrides --- apps/web/src/pages/settings/permissions.tsx | 10 ++- .../views/settings/PermissionsSettings.tsx | 67 ++++++++++++++++++- .../ClearCustomPermissionsConfirmation.tsx | 55 +++++++++++++++ packages/api/src/routers/permission.ts | 49 ++++++++++++++ packages/db/src/repository/permission.repo.ts | 28 +++++++- 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/views/settings/components/ClearCustomPermissionsConfirmation.tsx diff --git a/apps/web/src/pages/settings/permissions.tsx b/apps/web/src/pages/settings/permissions.tsx index b4364347..17676dd9 100644 --- a/apps/web/src/pages/settings/permissions.tsx +++ b/apps/web/src/pages/settings/permissions.tsx @@ -1,13 +1,17 @@ import type { NextPageWithLayout } from "~/pages/_app"; import { getDashboardLayout } from "~/components/Dashboard"; import { SettingsLayout } from "~/components/SettingsLayout"; +import Popup from "~/components/Popup"; import PermissionsSettings from "~/views/settings/PermissionsSettings"; const PermissionsSettingsPage: NextPageWithLayout = () => { return ( - - - + <> + + + + + ); }; diff --git a/apps/web/src/views/settings/PermissionsSettings.tsx b/apps/web/src/views/settings/PermissionsSettings.tsx index 01c30138..10e98750 100644 --- a/apps/web/src/views/settings/PermissionsSettings.tsx +++ b/apps/web/src/views/settings/PermissionsSettings.tsx @@ -1,14 +1,47 @@ import { t } from "@lingui/core/macro"; import { PageHead } from "~/components/PageHead"; +import Button from "~/components/Button"; +import Modal from "~/components/modal"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; +import { ClearCustomPermissionsConfirmation } from "./components/ClearCustomPermissionsConfirmation"; import { RolePermissions } from "./components/RolePermissions"; export default function PermissionsSettings() { const { workspace } = useWorkspace(); + const { openModal, isOpen, modalContentType } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); const isAdmin = workspace.role === "admin"; + const resetAllOverrides = api.permission.resetWorkspaceMemberPermissions.useMutation( + { + onSuccess: async () => { + showPopup({ + header: t`Overrides cleared`, + message: t`All member permission overrides have been reset to their role defaults.`, + icon: "success", + }); + + // Refresh any relevant workspace data + await utils.workspace.byId.invalidate({ + workspacePublicId: workspace.publicId, + }); + }, + onError: () => { + showPopup({ + header: t`Unable to clear overrides`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + }, + ); + return ( <> @@ -22,13 +55,45 @@ export default function PermissionsSettings() {

{isAdmin ? ( - + <> + +
+

+ {t`Custom permissions`} +

+

+ {t`Clear any custom member permissions so that all members only inherit permissions from their role defaults.`} +

+ +
+ ) : (

{t`You need to be an admin to manage workspace permissions.`}

)} + + + + ); } diff --git a/apps/web/src/views/settings/components/ClearCustomPermissionsConfirmation.tsx b/apps/web/src/views/settings/components/ClearCustomPermissionsConfirmation.tsx new file mode 100644 index 00000000..5f505091 --- /dev/null +++ b/apps/web/src/views/settings/components/ClearCustomPermissionsConfirmation.tsx @@ -0,0 +1,55 @@ +import { t } from "@lingui/core/macro"; + +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { useWorkspace } from "~/providers/workspace"; +import type { api } from "~/utils/api"; + +type ResetMutation = ReturnType< + typeof api.permission.resetWorkspaceMemberPermissions.useMutation +>; + +export function ClearCustomPermissionsConfirmation({ + resetAllOverrides, +}: { + resetAllOverrides: ResetMutation; +}) { + const { closeModal } = useModal(); + const { workspace } = useWorkspace(); + + const handleConfirm = () => { + if (!workspace.publicId || resetAllOverrides.isPending) return; + resetAllOverrides.mutate({ + workspacePublicId: workspace.publicId, + }); + closeModal(); + }; + + return ( +
+
+

+ {t`Clear all custom permissions?`} +

+

+ {t`This will remove all custom member permissions in this workspace. Members will inherit permissions only from their roles.`} +

+
+
+ + +
+
+ ); +} + + diff --git a/packages/api/src/routers/permission.ts b/packages/api/src/routers/permission.ts index 45397950..f62e1082 100644 --- a/packages/api/src/routers/permission.ts +++ b/packages/api/src/routers/permission.ts @@ -350,6 +350,55 @@ export const permissionRouter = createTRPCRouter({ await permissionRepo.clearMemberPermissionOverrides(ctx.db, member.id); + return { success: true }; + }), + resetWorkspaceMemberPermissions: protectedProcedure + .meta({ + openapi: { + summary: "Reset all member permission overrides in a workspace", + method: "POST", + path: "/workspaces/{workspacePublicId}/members/permissions/reset", + description: + "Clears all custom permission overrides for all members in a workspace so their effective permissions come only from their roles", + tags: ["Permissions"], + protect: true, + }, + }) + .input( + z.object({ + workspacePublicId: 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"); + + await permissionRepo.clearAllMemberPermissionOverridesForWorkspace( + ctx.db, + workspace.id, + ); + return { success: true }; }), getWorkspaceRoles: protectedProcedure diff --git a/packages/db/src/repository/permission.repo.ts b/packages/db/src/repository/permission.repo.ts index c940db55..9b0cb2ce 100644 --- a/packages/db/src/repository/permission.repo.ts +++ b/packages/db/src/repository/permission.repo.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, inArray } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import { @@ -260,6 +260,32 @@ export const clearMemberPermissionOverrides = async ( .where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId)); }; +/** + * Clear all permission overrides for all members in a workspace + */ +export const clearAllMemberPermissionOverridesForWorkspace = async ( + db: dbClient, + workspaceId: number, +) => { + const memberIds = await db + .select({ id: workspaceMembers.id }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + isNull(workspaceMembers.deletedAt), + ), + ); + + if (memberIds.length === 0) return; + + const ids = memberIds.map((m) => m.id); + + await db + .delete(workspaceMemberPermissions) + .where(inArray(workspaceMemberPermissions.workspaceMemberId, ids)); +}; + /** * Get member with their role by userId and workspaceId */