feat: allow editing individual permissions

This commit is contained in:
Henry
2026-01-28 22:42:04 +00:00
parent f210921d54
commit 3369092509
3 changed files with 227 additions and 4 deletions

View File

@@ -6,16 +6,20 @@ const Toggle = ({
onChange,
label,
disabled,
showLabel = true,
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
{showLabel && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
<Switch
checked={isChecked}
onChange={onChange}

View File

@@ -0,0 +1,202 @@
import { t } from "@lingui/core/macro";
import { HiXMark } from "react-icons/hi2";
import type { Permission } from "@kan/shared";
import { permissionCategories } from "@kan/shared";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function EditMemberPermissionsModal() {
const { workspace } = useWorkspace();
const { modalContentType, entityId, entityLabel, closeModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const { data, isLoading } = api.permission.getMemberPermissions.useQuery(
{
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
},
{
enabled:
modalContentType === "EDIT_MEMBER_PERMISSIONS" && !!entityId,
},
);
const grantMutation = api.permission.grantPermission.useMutation({
onSuccess: () => {
showPopup({
header: t`Permissions updated`,
message: t`The member's permissions have been updated.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Unable to update permissions`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.permission.getMemberPermissions.invalidate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
},
});
const revokeMutation = api.permission.revokePermission.useMutation({
onSuccess: () => {
showPopup({
header: t`Permissions updated`,
message: t`The member's permissions have been updated.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Unable to update permissions`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.permission.getMemberPermissions.invalidate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
},
});
const effectivePermissions = (data?.permissions ?? []) as Permission[];
const isBusy = grantMutation.isPending || revokeMutation.isPending;
const handleToggle = (permission: Permission, nextState: boolean) => {
if (!workspace.publicId || !entityId) return;
if (nextState) {
grantMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
permission,
});
} else {
revokeMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
permission,
});
}
};
const permissionLabels: Record<Permission, string> = {
"workspace:view": t`Can view workspace`,
"workspace:edit": t`Can edit workspace`,
"workspace:delete": t`Can delete workspace`,
"workspace:manage": t`Can manage workspace settings and billing`,
"board:view": t`Can view boards`,
"board:create": t`Can create boards`,
"board:edit": t`Can edit boards`,
"board:delete": t`Can delete boards`,
"list:view": t`Can view lists`,
"list:create": t`Can create lists`,
"list:edit": t`Can edit lists`,
"list:delete": t`Can delete lists`,
"card:view": t`Can view cards`,
"card:create": t`Can create cards`,
"card:edit": t`Can edit cards`,
"card:delete": t`Can delete cards`,
"comment:view": t`Can view comments`,
"comment:create": t`Can add comments`,
"comment:edit": t`Can edit comments`,
"comment:delete": t`Can delete comments`,
"member:view": t`Can view members`,
"member:invite": t`Can invite members`,
"member:edit": t`Can edit member roles and permissions`,
"member:remove": t`Can remove members`,
};
return (
<div className="w-full rounded-md bg-light-50 p-6 text-light-1000 dark:bg-dark-100 dark:text-dark-1000">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="mb-1 text-sm font-semibold">
{t`Edit permissions`}
</h2>
<p className="min-h-[16px] text-xs text-light-900 dark:text-dark-900">
{entityLabel || "\u00A0"}
</p>
</div>
<button
type="button"
onClick={closeModal}
className="ml-2 inline-flex h-6 w-6 items-center justify-center rounded-md text-light-900 hover:bg-light-200 focus:outline-none dark:text-dark-900 dark:hover:bg-dark-200"
aria-label={t`Close`}
>
<HiXMark className="h-3.5 w-3.5" />
</button>
</div>
{isLoading ? (
<p className="text-xs text-light-900 dark:text-dark-900">
{t`Loading permissions...`}
</p>
) : (
<div className="max-h-80 space-y-3 overflow-y-auto pr-1">
{Object.values(permissionCategories).map((category, index) => (
<div
key={category.label}
className={`py-2 ${index > 0 ? "border-t border-light-300 dark:border-dark-300" : ""}`}
>
<div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950">
{category.label}
</div>
<div className="space-y-1.5">
{category.permissions.map((permission) => {
const label =
permissionLabels[permission] ?? (permission as string);
return (
<div
key={permission}
className="flex items-center justify-between gap-3 py-0.5"
>
<span className="text-xs text-light-900 dark:text-dark-900 mb-">
{label}
</span>
<Toggle
label={label}
showLabel={false}
isChecked={effectivePermissions.includes(permission)}
disabled={isBusy}
onChange={() =>
handleToggle(
permission,
!effectivePermissions.includes(permission),
)
}
/>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -28,6 +28,7 @@ import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
export default function MembersPage() {
const { modalContentType, openModal, isOpen } = useModal();
@@ -218,6 +219,15 @@ export default function MembersPage() {
{session?.user.id !== memberId && (
<Dropdown
items={[
{
label: t`Edit permissions`,
action: () =>
openModal(
"EDIT_MEMBER_PERMISSIONS",
memberPublicId,
memberEmail ?? "",
),
},
{
label: t`Remove member`,
action: () =>
@@ -231,7 +241,7 @@ export default function MembersPage() {
>
<HiEllipsisHorizontal
size={20}
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
className="text-light-900 dark:text-dark-900 sm:size-[20px]"
/>
</Dropdown>
)}
@@ -386,6 +396,13 @@ export default function MembersPage() {
>
<DeleteMemberConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
>
<EditMemberPermissionsModal />
</Modal>
</>
</div>
</>