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:
@@ -7,6 +7,7 @@ import {
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -23,34 +24,48 @@ export default function BoardDropdown({
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]),
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
|
||||
const items = [
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: canCreateBoard ? () => openModal("CREATE_TEMPLATE") : undefined,
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
disabled: !canCreateBoard,
|
||||
},
|
||||
...(canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -23,6 +27,7 @@ interface ListProps {
|
||||
interface List {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
@@ -39,8 +44,14 @@ export default function List({
|
||||
setSelectedPublicListId,
|
||||
}: ListProps) {
|
||||
const { openModal } = useModal();
|
||||
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||
const canEdit = canEditList || isCreator;
|
||||
const canDrag = canEditList || isCreator;
|
||||
|
||||
const openNewCardForm = (publicListId: PublicListId) => {
|
||||
if (!canCreateCard) return;
|
||||
openModal("NEW_CARD");
|
||||
setSelectedPublicListId(publicListId);
|
||||
};
|
||||
@@ -59,6 +70,7 @@ export default function List({
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (!canEdit) return;
|
||||
updateList.mutate({
|
||||
listPublicId: values.listPublicId,
|
||||
name: values.name,
|
||||
@@ -71,7 +83,12 @@ export default function List({
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
||||
<Draggable
|
||||
key={list.publicId}
|
||||
draggableId={list.publicId}
|
||||
index={index}
|
||||
isDragDisabled={!canDrag}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
key={list.publicId}
|
||||
@@ -90,41 +107,65 @@ export default function List({
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
readOnly={!canEdit}
|
||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateCard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<HiOutlinePlusSmall
|
||||
className="h-5 w-5 text-dark-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div className="relative mr-1 inline-block">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
disabled={!canCreateCard}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<HiOutlinePlusSmall
|
||||
className="h-5 w-5 text-dark-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
const dropdownItems = [
|
||||
...(canCreateCard
|
||||
? [
|
||||
{
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteList || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (dropdownItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mr-1 inline-block">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiLink } from "react-icons/hi";
|
||||
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
|
||||
const UpdateBoardSlugButton = ({
|
||||
handleOnClick,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: {
|
||||
handleOnClick: () => void;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
isLoading: boolean;
|
||||
canEdit: boolean;
|
||||
}) => {
|
||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||
|
||||
@@ -22,10 +27,14 @@ const UpdateBoardSlugButton = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleOnClick}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
<Tooltip
|
||||
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={canEdit ? handleOnClick : undefined}
|
||||
disabled={!canEdit || isLoading}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
@@ -41,13 +50,20 @@ const UpdateBoardSlugButton = ({
|
||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!canEdit) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</Link>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default UpdateBoardSlugButton;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -29,6 +31,7 @@ const VisibilityButton = ({
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard } = usePermissions();
|
||||
const utils = api.useUtils();
|
||||
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
||||
visibility,
|
||||
@@ -60,38 +63,47 @@ const VisibilityButton = ({
|
||||
},
|
||||
});
|
||||
|
||||
const canEdit = canEditBoard || isAdmin;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
<Tooltip
|
||||
content={
|
||||
!canEdit && !isLoading ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !isAdmin}
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
if (!canEdit) return;
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !canEdit}
|
||||
>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -63,11 +64,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => boardId && openNewListForm(boardId),
|
||||
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||
description: t`Create new list`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
@@ -260,14 +263,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "LIST") {
|
||||
if (type === "LIST" && canEditList) {
|
||||
updateListMutation.mutate({
|
||||
listPublicId: draggableId,
|
||||
index: destination.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "CARD") {
|
||||
if (type === "CARD" && canEditCard) {
|
||||
updateCardMutation.mutate({
|
||||
cardPublicId: draggableId,
|
||||
|
||||
@@ -412,10 +415,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -438,6 +443,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
canEdit={canEditBoard}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
@@ -460,7 +466,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={createListShortcutTooltipContent}>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateList
|
||||
? t`You don't have permission`
|
||||
: createListShortcutTooltipContent
|
||||
}
|
||||
>
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
@@ -469,9 +481,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
if (boardId && canCreateList) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!boardData}
|
||||
disabled={!boardData || !canCreateList}
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
@@ -506,16 +518,25 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`No lists`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new list`}
|
||||
{canCreateList
|
||||
? t`Get started by creating a new list`
|
||||
: t`No lists have been created yet`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateList ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId && canCreateList) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!canCreateList}
|
||||
>
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
@@ -555,6 +576,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
key={card.publicId}
|
||||
draggableId={card.publicId}
|
||||
index={index}
|
||||
isDragDisabled={!canEditCard}
|
||||
>
|
||||
{(provided) => (
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user