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:
Henry
2026-02-01 21:17:18 +00:00
committed by GitHub
parent 5f2d409773
commit 7f5a1ab513
83 changed files with 10568 additions and 1442 deletions

View File

@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
disabled?: boolean;
}
export default function CheckboxDropdown({
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
handleEdit,
handleCreate,
asChild = true,
disabled = false,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
{items.length > 0 ? (
items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<input
id={item.key}
name={item.key}
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
<>
<Menu.Button
as={asChild ? "div" : undefined}
className="h-full w-full cursor-pointer focus-visible:outline-none"
disabled={disabled}
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
>
{children}
</Menu.Button>

View File

@@ -6,7 +6,7 @@ export default function Dropdown({
children,
disabled,
}: {
items: { label: string; action: () => void; icon?: React.ReactNode }[];
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
children: React.ReactNode;
disabled?: boolean;
}) {
@@ -33,10 +33,11 @@ export default function Dropdown({
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label}>
<Menu.Item key={item.label} disabled={item.disabled}>
<button
onClick={item.action}
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
disabled={item.disabled ?? !item.action}
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
>
{item.icon}
{item.label}

View File

@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
return (
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
>
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
<Transition
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4">
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4 relative">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex-shrink-0 mt-1">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-6 w-6 text-green-400"
className="h-5 w-5 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-6 w-6 text-red-400"
className="h-5 w-5 text-red-400"
/>
)}
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
{popupHeader}
</p>
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
{popupMessage}
</p>
</div>
<div className="ml-4 flex flex-shrink-0">
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
<button
type="button"
onClick={() => {
hidePopup();
}}
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-100 dark:hover:bg-dark-400"
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
>
<span className="sr-only">Close</span>
<HiXMark
aria-hidden="true"
className="h-5 w-5 text-dark-900"
className="h-4 w-4 text-dark-900"
/>
</button>
</div>

View File

@@ -14,8 +14,11 @@ import {
HiOutlineBanknotes,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
HiOutlineUser,
} from "react-icons/hi2";
import { usePermissions } from "~/hooks/usePermissions";
import { useWorkspace } from "~/providers/workspace";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -24,8 +27,12 @@ interface SettingsLayoutProps {
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
const router = useRouter();
const { workspace } = useWorkspace();
const { canViewWorkspace, canEditWorkspace } = usePermissions();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const isAdmin = workspace.role === "admin";
const settingsTabs = [
{
key: "account",
@@ -37,13 +44,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: true,
condition: canViewWorkspace,
},
{
key: "permissions",
icon: <HiOutlineShieldCheck />,
label: t`Permissions`,
condition: isAdmin,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
},
{
key: "api",
@@ -55,7 +68,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: true,
condition: canEditWorkspace,
},
];
@@ -97,7 +110,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"

View File

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

View File

@@ -77,7 +77,7 @@ export default function UserMenu({
) : (
<Menu.Button
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
title={isCollapsed ? displayName ?? email : undefined}
title={isCollapsed ? (displayName || email) : undefined}
>
{avatarUrl ? (
<Image
@@ -104,7 +104,7 @@ export default function UserMenu({
isCollapsed && "md:hidden",
)}
>
{displayName ?? email}
{displayName || email}
</span>
</Menu.Button>
)}

View File

@@ -9,6 +9,7 @@ interface Props {
positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
closeOnClickOutside?: boolean;
centered?: boolean;
}
const Modal: React.FC<Props> = ({
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
positionFromTop = "md",
isVisible,
closeOnClickOutside,
centered = false,
}) => {
const {
isOpen,
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
</Transition.Child>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
>
{children}
</Dialog.Panel>

View File

@@ -0,0 +1,75 @@
import type { Permission } from "@kan/shared";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
interface UsePermissionsResult {
permissions: Permission[];
role: string | null;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
canViewCard: boolean;
canCreateCard: boolean;
canEditCard: boolean;
canDeleteCard: boolean;
canCreateList: boolean;
canEditList: boolean;
canDeleteList: boolean;
canCreateBoard: boolean;
canEditBoard: boolean;
canDeleteBoard: boolean;
canViewComment: boolean;
canCreateComment: boolean;
canEditComment: boolean;
canDeleteComment: boolean;
canInviteMember: boolean;
canEditMember: boolean;
canRemoveMember: boolean;
canViewWorkspace: boolean;
canEditWorkspace: boolean;
}
export function usePermissions(): UsePermissionsResult {
const { workspace } = useWorkspace();
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
{ workspacePublicId: workspace.publicId },
{
enabled: !!workspace.publicId,
},
);
const permissions = (data?.permissions ?? []) as Permission[];
const role = data?.role ?? null;
const hasPermission = (permission: Permission): boolean => {
return permissions.includes(permission);
};
return {
permissions,
role,
isLoading,
hasPermission,
canViewCard: hasPermission("card:view"),
canCreateCard: hasPermission("card:create"),
canEditCard: hasPermission("card:edit"),
canDeleteCard: hasPermission("card:delete"),
canCreateList: hasPermission("list:create"),
canEditList: hasPermission("list:edit"),
canDeleteList: hasPermission("list:delete"),
canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"),
canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"),
canDeleteComment: hasPermission("comment:delete"),
canInviteMember: hasPermission("member:invite"),
canEditMember: hasPermission("member:edit"),
canRemoveMember: hasPermission("member:remove"),
canViewWorkspace: hasPermission("workspace:view"),
canEditWorkspace: hasPermission("workspace:edit"),
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import ApiSettings from "~/views/settings/ApiSettings";
import Popup from "~/components/Popup";
const ApiSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="api">
<ApiSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import BillingSettings from "~/views/settings/BillingSettings";
import Popup from "~/components/Popup";
const BillingSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="billing">
<BillingSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
import Popup from "~/components/Popup";
const IntegrationsSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="integrations">
<IntegrationsSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -0,0 +1,23 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import Popup from "~/components/Popup";
import PermissionsSettings from "~/views/settings/PermissionsSettings";
const PermissionsSettingsPage: NextPageWithLayout = () => {
return (
<>
<SettingsLayout currentTab="permissions">
<PermissionsSettings />
<Popup />
</SettingsLayout>
</>
);
};
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default PermissionsSettingsPage;

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
import Popup from "~/components/Popup";
const WorkspaceSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="workspace">
<WorkspaceSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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" />
}

View File

@@ -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`,

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }) => (

View File

@@ -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)}

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>
</>

View 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>
</>
);
}

View File

@@ -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" &&

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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)}

View File

@@ -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>
);

View File

@@ -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)}

View File

@@ -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)}