feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles * chore: regen migration * feat: add publicId to workspace roles * feat: setup default permissions * feat: add repo funcs * feat: setup basic router interactions * feat: add card permissions * feat: assert permissions for lists * feat: assert board permissions * feat: assert permission for remaining routes * feat: add permissions page to settings * feat: enable updating member roles * feat: order members by role and createdAt * feat: allow editing individual permissions * feat: reset role defaults * feat: clear all permission overrides * feat: allow users to delete entities they have created * feat: set roleId when inviting new members * feat: disable UI elements if user does not have permissions * feat: allow admins to assign the admin role to other users * feat: allow delete:list as default * refactor: centre permissions modal * chore: translations
This commit is contained in:
@@ -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 (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Clear all custom permissions?`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||
{t`This will remove all custom member permissions in this workspace. Members will inherit permissions only from their roles.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button size="sm" variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
isLoading={resetAllOverrides.isPending}
|
||||
>
|
||||
{t`Clear custom permissions`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
196
apps/web/src/views/settings/components/RolePermissions.tsx
Normal file
196
apps/web/src/views/settings/components/RolePermissions.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { permissionCategories, roles } from "@kan/shared";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
function formatRoleLabel(role: Role) {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
}
|
||||
|
||||
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`,
|
||||
|
||||
"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`,
|
||||
};
|
||||
|
||||
export function RolePermissions() {
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data, isLoading } =
|
||||
api.permission.getWorkspaceRolePermissions.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId },
|
||||
);
|
||||
|
||||
const systemRoles = (data?.roles ?? []).filter((role) =>
|
||||
(roles).includes(role.name as Role),
|
||||
);
|
||||
|
||||
const orderedRoleNames: Role[] = ["admin", "member", "guest"].filter(
|
||||
(role) => systemRoles.some((r) => r.name === role),
|
||||
) as Role[];
|
||||
|
||||
const grantMutation = api.permission.grantRolePermission.useMutation({
|
||||
onSettled: async () => {
|
||||
if (!workspace.publicId) return;
|
||||
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = api.permission.revokeRolePermission.useMutation({
|
||||
onSettled: async () => {
|
||||
if (!workspace.publicId) return;
|
||||
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBusy = grantMutation.isPending || revokeMutation.isPending;
|
||||
|
||||
const handleToggle = (
|
||||
rolePublicId: string,
|
||||
permission: Permission,
|
||||
checked: boolean,
|
||||
) => {
|
||||
if (!workspace.publicId || !rolePublicId) return;
|
||||
|
||||
if (checked) {
|
||||
grantMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
rolePublicId,
|
||||
permission,
|
||||
});
|
||||
} else {
|
||||
revokeMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
rolePublicId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{orderedRoleNames.length === 0 && !isLoading ? (
|
||||
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-800">
|
||||
{t`No roles found for this workspace yet.`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-100">
|
||||
<table className="min-w-full table-fixed divide-y divide-light-600 overflow-visible text-left text-sm dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-300">
|
||||
<tr>
|
||||
<th className="w-1/2 rounded-tl-lg px-4 py-3 text-left text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900">
|
||||
{t`Permission`}
|
||||
</th>
|
||||
{orderedRoleNames.map((role) => (
|
||||
<th
|
||||
key={role}
|
||||
className="w-1/6 px-4 py-3 text-center text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{formatRoleLabel(role)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{Object.values(permissionCategories).map((category) => (
|
||||
<tbody
|
||||
key={category.label}
|
||||
className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100"
|
||||
>
|
||||
<tr className="bg-light-100 dark:bg-dark-200">
|
||||
<td
|
||||
colSpan={1 + orderedRoleNames.length}
|
||||
className="px-4 py-2 text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{category.label}
|
||||
</td>
|
||||
</tr>
|
||||
{category.permissions.map((permission) => (
|
||||
<tr key={permission}>
|
||||
<td className="w-1/2 px-4 py-2 text-sm text-light-900 dark:text-dark-900">
|
||||
{permissionLabels[permission] ?? permission}
|
||||
</td>
|
||||
{orderedRoleNames.map((roleName) => {
|
||||
const role = systemRoles.find((r) => r.name === roleName);
|
||||
const checked = role?.permissions.includes(permission);
|
||||
const isAdminRole = roleName === "admin";
|
||||
const isBillingOrDeletePermission =
|
||||
permission === "workspace:manage" ||
|
||||
permission === "workspace:delete";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={roleName}
|
||||
className="w-1/6 px-4 py-2 text-center align-middle"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500 disabled:opacity-60"
|
||||
disabled={
|
||||
isAdminRole ||
|
||||
isBillingOrDeletePermission ||
|
||||
!role ||
|
||||
isLoading ||
|
||||
isBusy
|
||||
}
|
||||
checked={!!checked}
|
||||
onChange={(e) =>
|
||||
!isAdminRole &&
|
||||
!isBillingOrDeletePermission &&
|
||||
role &&
|
||||
handleToggle(
|
||||
role.publicId,
|
||||
permission,
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import { api } from "~/utils/api";
|
||||
const UpdateWorkspaceDescriptionForm = ({
|
||||
workspacePublicId,
|
||||
workspaceDescription,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceDescription: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -78,9 +80,10 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
<Input
|
||||
{...register("description")}
|
||||
errorMessage={errors.description?.message}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{isDirty && (
|
||||
{isDirty && !disabled && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { api } from "~/utils/api";
|
||||
export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
showEmailsToMembers: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
|
||||
@@ -27,6 +29,7 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
const newValue = !isChecked;
|
||||
setIsChecked(newValue);
|
||||
updateWorkspace.mutate({
|
||||
@@ -46,7 +49,7 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
isChecked={isChecked}
|
||||
onChange={handleToggle}
|
||||
label=""
|
||||
disabled={updateWorkspace.isPending}
|
||||
disabled={disabled || updateWorkspace.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,9 +20,11 @@ type FormValues = z.infer<typeof schema>;
|
||||
const UpdateWorkspaceNameForm = ({
|
||||
workspacePublicId,
|
||||
workspaceName,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceName: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -70,9 +72,13 @@ const UpdateWorkspaceNameForm = ({
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input {...register("name")} errorMessage={errors.name?.message} />
|
||||
<Input
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{isDirty && (
|
||||
{isDirty && !disabled && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -16,10 +16,12 @@ const UpdateWorkspaceUrlForm = ({
|
||||
workspacePublicId,
|
||||
workspaceUrl,
|
||||
workspacePlan,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceUrl: string;
|
||||
workspacePlan: "free" | "pro" | "enterprise";
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -136,9 +138,10 @@ const UpdateWorkspaceUrlForm = ({
|
||||
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
||||
) : null
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{isDirty && (
|
||||
{isDirty && !disabled && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
|
||||
Reference in New Issue
Block a user