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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user