diff --git a/src/components/projects/ProjectCreateModal.tsx b/src/components/projects/ProjectCreateModal.tsx index b9dd3db..fc7382b 100644 --- a/src/components/projects/ProjectCreateModal.tsx +++ b/src/components/projects/ProjectCreateModal.tsx @@ -3,11 +3,12 @@ import { useTranslation } from "../../hooks/useTranslation"; import { Modal } from "../Modal"; import { createProject } from "../../api/projects"; import { getClients } from "../../api/clients"; -import { useWorkspace } from "../../context/WorkspaceContext"; -import { Select } from "../ui/Select"; -import { Input } from "../ui/input"; -import { TextAreaInput } from "../ui/TextAreaInput"; -import { toast } from "sonner"; +import { useWorkspace } from "../../context/WorkspaceContext"; +import { Select } from "../ui/Select"; +import { Input } from "../ui/input"; +import { TextAreaInput } from "../ui/TextAreaInput"; +import { toast } from "sonner"; +import { PROJECTS_CREATE, canWorkspace } from "../../lib/permissions"; interface ProjectCreateModalProps { isOpen: boolean; @@ -15,8 +16,9 @@ interface ProjectCreateModalProps { } export const ProjectCreateModal: React.FC = ({ isOpen, onClose }) => { - const { t } = useTranslation(); - const { activeWorkspace } = useWorkspace(); + const { t } = useTranslation(); + const { activeWorkspace } = useWorkspace(); + const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE); const [loading, setLoading] = useState(false); const [clients, setClients] = useState([]); const [formData, setFormData] = useState({ @@ -61,7 +63,7 @@ export const ProjectCreateModal: React.FC = ({ isOpen, } }; - const footer = ( + const footer = ( <> - ); + ); + + if (!canCreateProject) return null; return ( diff --git a/src/components/projects/ProjectEditModal.tsx b/src/components/projects/ProjectEditModal.tsx index 99b5d94..2bf8ee0 100644 --- a/src/components/projects/ProjectEditModal.tsx +++ b/src/components/projects/ProjectEditModal.tsx @@ -5,10 +5,11 @@ import { updateProject, toggleArchiveProject } from "../../api/projects"; import { getClients } from "../../api/clients"; import { useWorkspace } from "../../context/WorkspaceContext"; import { Archive, RefreshCcw } from "lucide-react"; -import { Select } from "../ui/Select"; -import { Input } from "../ui/input"; -import { TextAreaInput } from "../ui/TextAreaInput"; -import { toast } from "sonner"; +import { Select } from "../ui/Select"; +import { Input } from "../ui/input"; +import { TextAreaInput } from "../ui/TextAreaInput"; +import { toast } from "sonner"; +import { PROJECTS_ARCHIVE, PROJECTS_EDIT, canWorkspace } from "../../lib/permissions"; interface ProjectEditModalProps { isOpen: boolean; @@ -17,8 +18,10 @@ interface ProjectEditModalProps { } export const ProjectEditModal: React.FC = ({ isOpen, onClose, project }) => { - const { t } = useTranslation(); - const { activeWorkspace } = useWorkspace(); + const { t } = useTranslation(); + const { activeWorkspace } = useWorkspace(); + const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT); + const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE); const [loading, setLoading] = useState(false); const [clients, setClients] = useState([]); const [formData, setFormData] = useState({ @@ -86,21 +89,25 @@ export const ProjectEditModal: React.FC = ({ isOpen, onCl } }; - const footer = ( -
- + const footer = ( +
+ {canArchiveProject ? ( + + ) : ( +
+ )}
- ); + ); + + if (!canEditProject) return null; return ( diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..151ac80 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,197 @@ +export type WorkspaceRole = "owner" | "admin" | "member" | "guest"; +export type ProjectRole = "manager" | "member" | string; + +export const WORKSPACE_VIEW = "workspace.view"; +export const WORKSPACE_EDIT = "workspace.edit"; +export const WORKSPACE_DELETE = "workspace.delete"; +export const WORKSPACE_MEMBERS_VIEW = "workspace.members.view"; +export const WORKSPACE_MEMBERS_ADD = "workspace.members.add"; +export const WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove"; +export const WORKSPACE_MEMBERS_CHANGE_ROLE = "workspace.members.change_role"; +export const CLIENTS_VIEW = "clients.view"; +export const CLIENTS_CREATE = "clients.create"; +export const CLIENTS_EDIT = "clients.edit"; +export const CLIENTS_DELETE = "clients.delete"; +export const TAGS_VIEW = "tags.view"; +export const TAGS_CREATE = "tags.create"; +export const TAGS_EDIT = "tags.edit"; +export const TAGS_DELETE = "tags.delete"; +export const PROJECTS_VIEW = "projects.view"; +export const PROJECTS_CREATE = "projects.create"; +export const PROJECTS_EDIT = "projects.edit"; +export const PROJECTS_DELETE = "projects.delete"; +export const PROJECTS_ARCHIVE = "projects.archive"; +export const PROJECT_MEMBERS_VIEW = "project_members.view"; +export const PROJECT_MEMBERS_ADD = "project_members.add"; +export const PROJECT_MEMBERS_REMOVE = "project_members.remove"; +export const PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role"; +export const TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"; +export const TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"; + +export type WorkspaceCapability = + | typeof WORKSPACE_VIEW + | typeof WORKSPACE_EDIT + | typeof WORKSPACE_DELETE + | typeof WORKSPACE_MEMBERS_VIEW + | typeof WORKSPACE_MEMBERS_ADD + | typeof WORKSPACE_MEMBERS_REMOVE + | typeof WORKSPACE_MEMBERS_CHANGE_ROLE + | typeof CLIENTS_VIEW + | typeof CLIENTS_CREATE + | typeof CLIENTS_EDIT + | typeof CLIENTS_DELETE + | typeof TAGS_VIEW + | typeof TAGS_CREATE + | typeof TAGS_EDIT + | typeof TAGS_DELETE + | typeof PROJECTS_VIEW + | typeof PROJECTS_CREATE + | typeof PROJECTS_EDIT + | typeof PROJECTS_DELETE + | typeof PROJECTS_ARCHIVE + | typeof PROJECT_MEMBERS_VIEW + | typeof PROJECT_MEMBERS_ADD + | typeof PROJECT_MEMBERS_REMOVE + | typeof PROJECT_MEMBERS_CHANGE_ROLE + | typeof TIME_ENTRIES_VIEW_OWN + | typeof TIME_ENTRIES_MANAGE_OWN; + +const CAPABILITIES_BY_ROLE: Record> = { + owner: new Set([ + WORKSPACE_VIEW, + WORKSPACE_EDIT, + WORKSPACE_DELETE, + WORKSPACE_MEMBERS_VIEW, + WORKSPACE_MEMBERS_ADD, + WORKSPACE_MEMBERS_REMOVE, + WORKSPACE_MEMBERS_CHANGE_ROLE, + CLIENTS_VIEW, + CLIENTS_CREATE, + CLIENTS_EDIT, + CLIENTS_DELETE, + TAGS_VIEW, + TAGS_CREATE, + TAGS_EDIT, + TAGS_DELETE, + PROJECTS_VIEW, + PROJECTS_CREATE, + PROJECTS_EDIT, + PROJECTS_DELETE, + PROJECTS_ARCHIVE, + PROJECT_MEMBERS_VIEW, + PROJECT_MEMBERS_ADD, + PROJECT_MEMBERS_REMOVE, + PROJECT_MEMBERS_CHANGE_ROLE, + TIME_ENTRIES_VIEW_OWN, + TIME_ENTRIES_MANAGE_OWN, + ]), + admin: new Set([ + WORKSPACE_VIEW, + WORKSPACE_EDIT, + WORKSPACE_MEMBERS_VIEW, + WORKSPACE_MEMBERS_ADD, + WORKSPACE_MEMBERS_REMOVE, + WORKSPACE_MEMBERS_CHANGE_ROLE, + CLIENTS_VIEW, + CLIENTS_CREATE, + CLIENTS_EDIT, + CLIENTS_DELETE, + TAGS_VIEW, + TAGS_CREATE, + TAGS_EDIT, + TAGS_DELETE, + PROJECTS_VIEW, + PROJECTS_CREATE, + PROJECTS_EDIT, + PROJECTS_DELETE, + PROJECTS_ARCHIVE, + PROJECT_MEMBERS_VIEW, + PROJECT_MEMBERS_ADD, + PROJECT_MEMBERS_REMOVE, + PROJECT_MEMBERS_CHANGE_ROLE, + TIME_ENTRIES_VIEW_OWN, + TIME_ENTRIES_MANAGE_OWN, + ]), + member: new Set([ + WORKSPACE_VIEW, + CLIENTS_VIEW, + TAGS_VIEW, + TAGS_CREATE, + PROJECTS_VIEW, + TIME_ENTRIES_VIEW_OWN, + TIME_ENTRIES_MANAGE_OWN, + ]), + guest: new Set([ + WORKSPACE_VIEW, + CLIENTS_VIEW, + TAGS_VIEW, + PROJECTS_VIEW, + TIME_ENTRIES_VIEW_OWN, + ]), +}; + +const PROJECT_MANAGER_CAPABILITIES = new Set([ + PROJECTS_EDIT, + PROJECTS_ARCHIVE, + PROJECT_MEMBERS_VIEW, + PROJECT_MEMBERS_ADD, + PROJECT_MEMBERS_REMOVE, + PROJECT_MEMBERS_CHANGE_ROLE, +]); + +export const canWorkspace = ( + role: WorkspaceRole | null | undefined, + capability: WorkspaceCapability, +) => { + if (!role) return false; + return CAPABILITIES_BY_ROLE[role]?.has(capability) ?? false; +}; + +export const canProject = ({ + workspaceRole, + projectRole, + capability, +}: { + workspaceRole: WorkspaceRole | null | undefined; + projectRole?: ProjectRole | null; + capability: WorkspaceCapability; +}) => { + if (canWorkspace(workspaceRole, capability)) return true; + if (workspaceRole === "member" || workspaceRole === "guest" || !workspaceRole) { + return false; + } + return projectRole === "manager" && PROJECT_MANAGER_CAPABILITIES.has(capability); +}; + +export const canChangeWorkspaceMember = ({ + actorRole, + actorUserId, + targetRole, + targetUserId, + ownerUserId, + newRole, +}: { + actorRole: WorkspaceRole | null | undefined; + actorUserId?: string | null; + targetRole: WorkspaceRole; + targetUserId?: string | null; + ownerUserId?: string | null; + newRole?: WorkspaceRole; +}) => { + if (!actorRole || !actorUserId || !targetUserId) return false; + if (actorUserId === targetUserId) return false; + + const targetIsCanonicalOwner = !!ownerUserId && targetUserId === ownerUserId; + if (actorRole === "admin") { + if (targetRole === "owner" || targetIsCanonicalOwner) return false; + if (newRole === "owner") return false; + return true; + } + + if (actorRole === "owner") { + if (targetIsCanonicalOwner) return false; + return true; + } + + return false; +}; diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 799a1f4..cab433f 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,7 +1,13 @@ import { useEffect, useState } from "react" import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react" import { useWorkspace } from "../context/WorkspaceContext" -import { useTranslation } from "../hooks/useTranslation" +import { useTranslation } from "../hooks/useTranslation" +import { + CLIENTS_CREATE, + CLIENTS_DELETE, + CLIENTS_EDIT, + canWorkspace, +} from "../lib/permissions" import { type Client } from "../types/client" import { getClients } from "../api/clients" import CreateClientModal from "../components/CreateClientModal" @@ -32,8 +38,12 @@ export default function Clients() { const [editClient, setEditClient] = useState(null) const [deleteClient, setDeleteClient] = useState(null) - const { t, lang } = useTranslation() - const isFa = lang === "fa" + const { t, lang } = useTranslation() + const isFa = lang === "fa" + const workspaceRole = activeWorkspace?.my_role + const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE) + const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT) + const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE) const orderingOptions = [ { value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" }, @@ -114,10 +124,12 @@ export default function Clients() {

- + {canCreateClient && ( + + )}
-
- - -
+ {(canEditClient || canDeleteClient) && ( +
+ {canEditClient && ( + + )} + {canDeleteClient && ( + + )} +
+ )} ))} @@ -194,26 +212,32 @@ export default function Clients() { /> )} - setIsCreateModalOpen(false)} - onSuccess={fetchClientsList} - workspaceId={activeWorkspace.id} - /> - - setEditClient(null)} - onSuccess={fetchClientsList} - client={editClient} - /> - - setDeleteClient(null)} - onSuccess={fetchClientsList} - client={deleteClient} - /> + {canCreateClient && ( + setIsCreateModalOpen(false)} + onSuccess={fetchClientsList} + workspaceId={activeWorkspace.id} + /> + )} + + {canEditClient && ( + setEditClient(null)} + onSuccess={fetchClientsList} + client={editClient} + /> + )} + + {canDeleteClient && ( + setDeleteClient(null)} + onSuccess={fetchClientsList} + client={deleteClient} + /> + )} ) } diff --git a/src/pages/ProjectCreate.tsx b/src/pages/ProjectCreate.tsx index 2f7913e..f53fbea 100644 --- a/src/pages/ProjectCreate.tsx +++ b/src/pages/ProjectCreate.tsx @@ -15,7 +15,8 @@ import { fetchWorkspaceMemberships } from "../api/workspaces"; import { searchUserByExactMobile, type SearchedUser } from "../api/users"; import { useAppContext } from "../context/AppContext"; import { useWorkspace } from "../context/WorkspaceContext"; -import { useTranslation } from "../hooks/useTranslation"; +import { useTranslation } from "../hooks/useTranslation"; +import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/Select"; @@ -57,7 +58,8 @@ export default function ProjectCreate() { const { t } = useTranslation(); const { user } = useAppContext(); const { activeWorkspace } = useWorkspace(); - const currentUserId = user?.id || ""; + const currentUserId = user?.id || ""; + const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE); // Project Detail States const [name, setName] = useState(""); @@ -89,7 +91,14 @@ export default function ProjectCreate() { const [memberIdToDelete, setMemberIdToDelete] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1; + const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1; + + useEffect(() => { + if (activeWorkspace && !canCreateProject) { + toast.error("You do not have permission to create projects."); + navigate("/projects"); + } + }, [activeWorkspace, canCreateProject, navigate]); useBlocker(({ currentLocation, nextLocation }) => { if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) { @@ -352,9 +361,9 @@ export default function ProjectCreate() { ...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user })) ]; - if (!activeWorkspace) { - return null; - } + if (!activeWorkspace) { + return null; + } return (
diff --git a/src/pages/ProjectEdit.tsx b/src/pages/ProjectEdit.tsx index d7a99ea..93a3489 100644 --- a/src/pages/ProjectEdit.tsx +++ b/src/pages/ProjectEdit.tsx @@ -15,7 +15,8 @@ import { fetchWorkspaceMemberships } from "../api/workspaces"; import { searchUserByExactMobile, type SearchedUser } from "../api/users"; import { useAppContext } from "../context/AppContext"; import { useWorkspace } from "../context/WorkspaceContext"; -import { useTranslation } from "../hooks/useTranslation"; +import { useTranslation } from "../hooks/useTranslation"; +import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/Select"; @@ -58,7 +59,8 @@ export default function ProjectEdit() { const { t } = useTranslation(); const { user } = useAppContext(); const { activeWorkspace } = useWorkspace(); - const currentUserId = user?.id || ""; + const currentUserId = user?.id || ""; + const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -87,7 +89,14 @@ export default function ProjectEdit() { const [memberIdToDelete, setMemberIdToDelete] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const hasUnsavedChanges = name.trim() !== ""; + const hasUnsavedChanges = name.trim() !== ""; + + useEffect(() => { + if (activeWorkspace && !canEditProject) { + toast.error("You do not have permission to edit projects."); + navigate("/projects"); + } + }, [activeWorkspace, canEditProject, navigate]); useBlocker(({ currentLocation, nextLocation }) => { if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) { diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index af837fa..489dc19 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -11,12 +11,24 @@ import FilterBar from "../components/FilterBar"; import { Button } from "../components/ui/button"; import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card"; import { Modal } from "../components/Modal"; -import { toast } from "sonner"; -import { Input } from "../components/ui/input"; +import { toast } from "sonner"; +import { Input } from "../components/ui/input"; +import { + PROJECTS_ARCHIVE, + PROJECTS_CREATE, + PROJECTS_DELETE, + PROJECTS_EDIT, + canWorkspace, +} from "../lib/permissions"; export const Projects: React.FC = () => { - const { t } = useTranslation(); - const { activeWorkspace } = useWorkspace(); + const { t } = useTranslation(); + const { activeWorkspace } = useWorkspace(); + const workspaceRole = activeWorkspace?.my_role; + const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE); + const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT); + const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE); + const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(false); @@ -117,21 +129,25 @@ export const Projects: React.FC = () => {

{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}

- - + {canArchiveProject && ( + + )} + {canCreateProject && ( + + )}
@@ -172,27 +188,33 @@ export const Projects: React.FC = () => { -
- - - -
+ {(canEditProject || canDeleteProject) && ( +
+ {canEditProject && ( + + )} + + {canDeleteProject && ( + + )} +
+ )} ))} @@ -216,15 +238,15 @@ export const Projects: React.FC = () => { )} {/* Modals */} - {isCreateModalOpen && ( - setIsCreateModalOpen(false)} - /> - )} - - {editingProject && ( - setIsCreateModalOpen(false)} + /> + )} + + {canEditProject && editingProject && ( + setEditingProject(null)} diff --git a/src/pages/Tags.tsx b/src/pages/Tags.tsx index 2113bb6..ed350a2 100644 --- a/src/pages/Tags.tsx +++ b/src/pages/Tags.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags"; import { useWorkspace } from "../context/WorkspaceContext"; import { useTranslation } from "../hooks/useTranslation"; +import { TAGS_CREATE, TAGS_DELETE, TAGS_EDIT, canWorkspace } from "../lib/permissions"; import FilterBar from "../components/FilterBar"; import { Modal } from "../components/Modal"; import { Pagination } from "../components/Pagination"; @@ -17,6 +18,10 @@ const DEFAULT_COLOR = "#3B82F6"; export default function Tags() { const { t } = useTranslation(); const { activeWorkspace } = useWorkspace(); + const workspaceRole = activeWorkspace?.my_role; + const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE); + const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT); + const canDeleteTag = canWorkspace(workspaceRole, TAGS_DELETE); const [tags, setTags] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -145,10 +150,12 @@ export default function Tags() { {t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}

- + {canCreateTag && ( + + )} -
- - -
+ {(canEditTag || canDeleteTag) && ( +
+ {canEditTag && ( + + )} + {canDeleteTag && ( + + )} +
+ )} ))} diff --git a/src/pages/WorkspaceDetail.tsx b/src/pages/WorkspaceDetail.tsx index ec7fa08..09edbf9 100644 --- a/src/pages/WorkspaceDetail.tsx +++ b/src/pages/WorkspaceDetail.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react'; -import { useTranslation } from '../hooks/useTranslation'; -import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces'; +import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react'; +import { useTranslation } from '../hooks/useTranslation'; +import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces'; +import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions'; export default function WorkspaceDetail() { const { id } = useParams<{ id: string }>(); @@ -44,7 +45,8 @@ export default function WorkspaceDetail() { return
{t.workspace?.loading}
; } - const canEdit = workspace.my_role === 'owner' || workspace.my_role === 'admin'; + const canEdit = canWorkspace(workspace.my_role, WORKSPACE_EDIT); + const canDelete = canWorkspace(workspace.my_role, WORKSPACE_DELETE); return (
@@ -75,8 +77,8 @@ export default function WorkspaceDetail() { > - {workspace.my_role === 'owner' && ( -