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
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HiOutlineRectangleStack } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -11,6 +13,7 @@ import { api } from "~/utils/api";
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { data, isLoading } = api.board.all.useQuery(
|
||||
{
|
||||
@@ -41,9 +44,20 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("NEW_BOARD");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -17,12 +18,13 @@ import { NewBoardForm } from "./components/NewBoardForm";
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => openModal("NEW_BOARD"),
|
||||
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
@@ -39,22 +41,40 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
{!isTemplate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("IMPORT_BOARDS");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={createModalShortcutTooltipContent}>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard
|
||||
? t`You don't have permission`
|
||||
: createModalShortcutTooltipContent
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("NEW_BOARD");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
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";
|
||||
@@ -49,6 +50,7 @@ const Comment = ({
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { openModal } = useModal();
|
||||
const { canEditComment, canDeleteComment } = usePermissions();
|
||||
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
comment,
|
||||
@@ -80,7 +82,7 @@ const Comment = ({
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
...(isAuthor
|
||||
...(isAuthor && canEditComment
|
||||
? [
|
||||
{
|
||||
label: t`Edit comment`,
|
||||
@@ -89,7 +91,7 @@ const Comment = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAuthor || isAdmin
|
||||
...((isAuthor || canDeleteComment)
|
||||
? [
|
||||
{
|
||||
label: t`Delete comment`,
|
||||
|
||||
@@ -5,29 +5,53 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export default function BoardDropdown() {
|
||||
export default function CardDropdown({
|
||||
cardCreatedBy,
|
||||
}: {
|
||||
cardCreatedBy?: string | null;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const items = [
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Dropdown items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,14 @@ interface DueDateSelectorProps {
|
||||
cardPublicId: string;
|
||||
dueDate: Date | null | undefined;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DueDateSelector({
|
||||
cardPublicId,
|
||||
dueDate,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
@@ -105,9 +107,9 @@ export function DueDateSelector({
|
||||
<div className="relative flex w-full items-center text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={isLoading || disabled}
|
||||
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
@@ -118,7 +120,7 @@ export function DueDateSelector({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
{isOpen && !disabled && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||
<div
|
||||
|
||||
@@ -17,12 +17,14 @@ interface LabelSelectorProps {
|
||||
leftIcon: React.ReactNode;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function LabelSelector({
|
||||
cardPublicId,
|
||||
labels,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: LabelSelectorProps) {
|
||||
const utils = api.useUtils();
|
||||
const { openModal } = useModal();
|
||||
@@ -93,9 +95,10 @@ export default function LabelSelector({
|
||||
handleSelect={(_, label) => {
|
||||
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
||||
}}
|
||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
|
||||
createNewItemLabel={t`Create new label`}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
{selectedLabels.length ? (
|
||||
@@ -110,7 +113,7 @@ export default function LabelSelector({
|
||||
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add label`}
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,14 @@ interface ListSelectorProps {
|
||||
selected: boolean;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ListSelector({
|
||||
cardPublicId,
|
||||
lists,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: ListSelectorProps) {
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -77,9 +79,10 @@ export default function ListSelector({
|
||||
index: 0,
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
{selectedList?.value}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -19,12 +19,14 @@ interface MemberSelectorProps {
|
||||
imageUrl: string | undefined;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MemberSelector({
|
||||
cardPublicId,
|
||||
members,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: MemberSelectorProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
@@ -108,11 +110,12 @@ export default function MemberSelector({
|
||||
workspaceMemberPublicId: member.key,
|
||||
});
|
||||
}}
|
||||
handleCreate={handleInviteMember}
|
||||
handleCreate={disabled ? undefined : handleInviteMember}
|
||||
createNewItemLabel={t`Invite member`}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
@@ -15,6 +16,7 @@ interface FormValues {
|
||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { canCreateComment } = usePermissions();
|
||||
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
||||
values: {
|
||||
comment: "",
|
||||
@@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!canCreateComment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -14,6 +14,9 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -44,6 +47,8 @@ interface FormValues {
|
||||
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
@@ -52,6 +57,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const board = card?.list.board;
|
||||
const labels = board?.labels;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
@@ -116,6 +124,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
lists={formattedLists}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
@@ -124,6 +133,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
@@ -133,6 +143,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -142,6 +153,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
dueDate={card?.dueDate}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +174,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -174,6 +188,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
};
|
||||
@@ -298,7 +315,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown />
|
||||
<Dropdown cardCreatedBy={card?.createdBy} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -326,9 +343,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<textarea
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
|
||||
rows={1}
|
||||
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
disabled={!canEdit}
|
||||
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEdit ? "cursor-default" : ""}`}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
@@ -354,9 +372,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
||||
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -366,6 +385,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId}
|
||||
activeChecklistForm={activeChecklistForm}
|
||||
setActiveChecklistForm={setActiveChecklistForm}
|
||||
viewOnly={!canEdit}
|
||||
/>
|
||||
{!isTemplate && (
|
||||
<>
|
||||
@@ -374,12 +394,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<AttachmentThumbnails
|
||||
attachments={card.attachments}
|
||||
cardPublicId={cardId ?? ""}
|
||||
isReadOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { permissionCategories } from "@kan/shared";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
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 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 hasOverrides = (data?.overrides?.length ?? 0) > 0;
|
||||
const isBusy =
|
||||
grantMutation.isPending ||
|
||||
revokeMutation.isPending ||
|
||||
resetMutation.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`,
|
||||
|
||||
"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 text-light-1000 dark:bg-dark-100 dark:text-dark-1000">
|
||||
<div className="px-5 pt-5">
|
||||
<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}
|
||||
</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 pb-4 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">
|
||||
{label}
|
||||
</span>
|
||||
<Toggle
|
||||
label={label}
|
||||
showLabel={false}
|
||||
isChecked={effectivePermissions.includes(permission)}
|
||||
disabled={isBusy}
|
||||
onChange={() =>
|
||||
handleToggle(
|
||||
permission,
|
||||
!effectivePermissions.includes(permission),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!workspace.publicId || !entityId || isBusy) return;
|
||||
resetMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
}}
|
||||
disabled={isBusy || !hasOverrides}
|
||||
isLoading={resetMutation.isPending}
|
||||
>
|
||||
{t`Reset to role defaults`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import {
|
||||
HiBolt,
|
||||
HiChevronDown,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
} from "react-icons/hi2";
|
||||
@@ -19,16 +20,20 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
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();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
@@ -37,6 +42,31 @@ export default function MembersPage() {
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { canEditMember } = usePermissions();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
showPopup({
|
||||
header: t`Role updated`,
|
||||
message: t`The member's role has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update role`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
@@ -67,6 +97,16 @@ export default function MembersPage() {
|
||||
showSkeleton?: boolean;
|
||||
showPendingIcon?: boolean;
|
||||
}) => {
|
||||
const handleRoleChange = (newRole: "admin" | "member" | "guest") => {
|
||||
if (!memberPublicId) return;
|
||||
|
||||
updateRoleMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId,
|
||||
role: newRole,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td
|
||||
@@ -127,19 +167,45 @@ export default function MembersPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
showSkeleton &&
|
||||
<div className="flex items-center gap-2">
|
||||
{showSkeleton ? (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative inline-flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() +
|
||||
memberRole.slice(1)}
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<HiChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<select
|
||||
value={memberRole}
|
||||
onChange={(e) =>
|
||||
handleRoleChange(
|
||||
e.target.value as "admin" | "member" | "guest",
|
||||
)
|
||||
}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer appearance-none border-none bg-transparent p-0 text-[10px] leading-none opacity-0 focus:outline-none focus-visible:outline-none sm:text-[11px]"
|
||||
>
|
||||
<option value="admin">{t`Admin`}</option>
|
||||
<option value="member">{t`Member`}</option>
|
||||
<option value="guest">{t`Guest`}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(memberStatus === "invited" || memberStatus === "paused") && (
|
||||
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
|
||||
<span className="inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:text-[11px]">
|
||||
{memberStatus === "invited" ? t`Pending` : t`Paused`}
|
||||
</span>
|
||||
)}
|
||||
@@ -153,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: () =>
|
||||
@@ -166,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>
|
||||
)}
|
||||
@@ -321,6 +396,14 @@ export default function MembersPage() {
|
||||
>
|
||||
<DeleteMemberConfirmation />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
|
||||
centered
|
||||
>
|
||||
<EditMemberPermissionsModal />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
|
||||
101
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
101
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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 (
|
||||
<>
|
||||
<PageHead title={t`Settings | Permissions`} />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace permissions`}
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Configure which actions are allowed for each workspace role. These permissions apply to all members with that role.`}
|
||||
</p>
|
||||
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<RolePermissions />
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-4 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom permissions`}
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Clear any custom member permissions so that all members only inherit permissions from their role defaults.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!workspace.publicId || resetAllOverrides.isPending) {
|
||||
return;
|
||||
}
|
||||
openModal("CLEAR_CUSTOM_PERMISSIONS");
|
||||
}}
|
||||
disabled={resetAllOverrides.isPending}
|
||||
>
|
||||
{t`Clear custom permissions`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You need to be an admin to manage workspace permissions.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CLEAR_CUSTOM_PERMISSIONS"}
|
||||
>
|
||||
<ClearCustomPermissionsConfirmation
|
||||
resetAllOverrides={resetAllOverrides}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -25,6 +26,7 @@ import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation"
|
||||
export default function WorkspaceSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditWorkspace } = usePermissions();
|
||||
const router = useRouter();
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
@@ -61,6 +63,7 @@ export default function WorkspaceSettings() {
|
||||
<UpdateWorkspaceNameForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceName={workspace.name}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
@@ -70,6 +73,7 @@ export default function WorkspaceSettings() {
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceUrl={workspace.slug ?? ""}
|
||||
workspacePlan={workspace.plan ?? "free"}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
@@ -78,6 +82,7 @@ export default function WorkspaceSettings() {
|
||||
<UpdateWorkspaceDescriptionForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
@@ -85,7 +90,10 @@ export default function WorkspaceSettings() {
|
||||
</h2>
|
||||
<UpdateWorkspaceEmailVisibilityForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
|
||||
showEmailsToMembers={Boolean(
|
||||
workspaceData?.showEmailsToMembers ?? false,
|
||||
)}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
|
||||
@@ -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