feat(permissions): gate workspace resources by role

This commit is contained in:
2026-04-25 18:48:49 +03:30
parent c8c689e693
commit 7f0e00f09d
11 changed files with 511 additions and 200 deletions

View File

@@ -8,6 +8,7 @@ import { Select } from "../ui/Select";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { TextAreaInput } from "../ui/TextAreaInput"; import { TextAreaInput } from "../ui/TextAreaInput";
import { toast } from "sonner"; import { toast } from "sonner";
import { PROJECTS_CREATE, canWorkspace } from "../../lib/permissions";
interface ProjectCreateModalProps { interface ProjectCreateModalProps {
isOpen: boolean; isOpen: boolean;
@@ -17,6 +18,7 @@ interface ProjectCreateModalProps {
export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen, onClose }) => { export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen, onClose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]); const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -72,6 +74,8 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
</> </>
); );
if (!canCreateProject) return null;
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}> <Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">

View File

@@ -9,6 +9,7 @@ import { Select } from "../ui/Select";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { TextAreaInput } from "../ui/TextAreaInput"; import { TextAreaInput } from "../ui/TextAreaInput";
import { toast } from "sonner"; import { toast } from "sonner";
import { PROJECTS_ARCHIVE, PROJECTS_EDIT, canWorkspace } from "../../lib/permissions";
interface ProjectEditModalProps { interface ProjectEditModalProps {
isOpen: boolean; isOpen: boolean;
@@ -19,6 +20,8 @@ interface ProjectEditModalProps {
export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onClose, project }) => { export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onClose, project }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeWorkspace } = useWorkspace(); 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 [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]); const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -88,6 +91,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
const footer = ( const footer = (
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
{canArchiveProject ? (
<button <button
onClick={handleArchiveToggle} onClick={handleArchiveToggle}
type="button" type="button"
@@ -101,6 +105,9 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />} {project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
{project?.is_archived ? t.projects.restore : t.projects.archive} {project?.is_archived ? t.projects.restore : t.projects.archive}
</button> </button>
) : (
<div />
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600"> <button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
@@ -113,6 +120,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
</div> </div>
); );
if (!canEditProject) return null;
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}> <Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
<form onSubmit={handleSubmit} className="space-y-4 mb-6"> <form onSubmit={handleSubmit} className="space-y-4 mb-6">

197
src/lib/permissions.ts Normal file
View File

@@ -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<WorkspaceRole, Set<WorkspaceCapability>> = {
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<WorkspaceCapability>([
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;
};

View File

@@ -2,6 +2,12 @@ import { useEffect, useState } from "react"
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react" import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
import { useWorkspace } from "../context/WorkspaceContext" 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 { type Client } from "../types/client"
import { getClients } from "../api/clients" import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal" import CreateClientModal from "../components/CreateClientModal"
@@ -34,6 +40,10 @@ export default function Clients() {
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const isFa = lang === "fa" 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 = [ const orderingOptions = [
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" }, { value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
@@ -114,10 +124,12 @@ export default function Clients() {
</p> </p>
</div> </div>
{canCreateClient && (
<Button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2"> <Button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
{t.clients.addClient} {t.clients.addClient}
</Button> </Button>
)}
</div> </div>
<FilterBar <FilterBar
@@ -159,7 +171,9 @@ export default function Clients() {
</div> </div>
</div> </div>
{(canEditClient || canDeleteClient) && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{canEditClient && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -168,6 +182,8 @@ export default function Clients() {
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>
)}
{canDeleteClient && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -176,7 +192,9 @@ export default function Clients() {
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
)}
</div> </div>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -194,26 +212,32 @@ export default function Clients() {
/> />
)} )}
{canCreateClient && (
<CreateClientModal <CreateClientModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onSuccess={fetchClientsList} onSuccess={fetchClientsList}
workspaceId={activeWorkspace.id} workspaceId={activeWorkspace.id}
/> />
)}
{canEditClient && (
<EditClientModal <EditClientModal
isOpen={!!editClient} isOpen={!!editClient}
onClose={() => setEditClient(null)} onClose={() => setEditClient(null)}
onSuccess={fetchClientsList} onSuccess={fetchClientsList}
client={editClient} client={editClient}
/> />
)}
{canDeleteClient && (
<DeleteClientModal <DeleteClientModal
isOpen={!!deleteClient} isOpen={!!deleteClient}
onClose={() => setDeleteClient(null)} onClose={() => setDeleteClient(null)}
onSuccess={fetchClientsList} onSuccess={fetchClientsList}
client={deleteClient} client={deleteClient}
/> />
)}
</div> </div>
) )
} }

View File

@@ -16,6 +16,7 @@ import { searchUserByExactMobile, type SearchedUser } from "../api/users";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext"; 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 { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
@@ -58,6 +59,7 @@ export default function ProjectCreate() {
const { user } = useAppContext(); const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || ""; const currentUserId = user?.id || "";
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
// Project Detail States // Project Detail States
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -91,6 +93,13 @@ export default function ProjectCreate() {
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 }) => { useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) { if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?"); return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");

View File

@@ -16,6 +16,7 @@ import { searchUserByExactMobile, type SearchedUser } from "../api/users";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext"; 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 { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
@@ -59,6 +60,7 @@ export default function ProjectEdit() {
const { user } = useAppContext(); const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || ""; const currentUserId = user?.id || "";
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -89,6 +91,13 @@ export default function ProjectEdit() {
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 }) => { useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) { if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?"); return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");

View File

@@ -13,10 +13,22 @@ import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card"
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import {
PROJECTS_ARCHIVE,
PROJECTS_CREATE,
PROJECTS_DELETE,
PROJECTS_EDIT,
canWorkspace,
} from "../lib/permissions";
export const Projects: React.FC = () => { export const Projects: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeWorkspace } = useWorkspace(); 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<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -117,6 +129,7 @@ export const Projects: React.FC = () => {
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p> <p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
</div> </div>
<div className="flex items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
{canArchiveProject && (
<Button <Button
variant={isArchived ? "default" : "secondary"} variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)} onClick={() => setIsArchived(!isArchived)}
@@ -125,6 +138,8 @@ export const Projects: React.FC = () => {
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')} {isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</Button> </Button>
)}
{canCreateProject && (
<Button <Button
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
className="gap-2 shadow-sm flex-1 sm:flex-none" className="gap-2 shadow-sm flex-1 sm:flex-none"
@@ -132,6 +147,7 @@ export const Projects: React.FC = () => {
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
{t.projects?.createNew || 'Create New'} {t.projects?.createNew || 'Create New'}
</Button> </Button>
)}
</div> </div>
</div> </div>
@@ -172,7 +188,9 @@ export const Projects: React.FC = () => {
</div> </div>
{(canEditProject || canDeleteProject) && (
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{canEditProject && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -182,7 +200,9 @@ export const Projects: React.FC = () => {
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>
)}
{canDeleteProject && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -192,7 +212,9 @@ export const Projects: React.FC = () => {
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
)}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -216,14 +238,14 @@ export const Projects: React.FC = () => {
)} )}
{/* Modals */} {/* Modals */}
{isCreateModalOpen && ( {canCreateProject && isCreateModalOpen && (
<ProjectCreateModal <ProjectCreateModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
/> />
)} )}
{editingProject && ( {canEditProject && editingProject && (
<ProjectEditModal <ProjectEditModal
project={editingProject} project={editingProject}
isOpen={!!editingProject} isOpen={!!editingProject}

View File

@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags"; import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { TAGS_CREATE, TAGS_DELETE, TAGS_EDIT, canWorkspace } from "../lib/permissions";
import FilterBar from "../components/FilterBar"; import FilterBar from "../components/FilterBar";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
@@ -17,6 +18,10 @@ const DEFAULT_COLOR = "#3B82F6";
export default function Tags() { export default function Tags() {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeWorkspace } = useWorkspace(); 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<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -145,10 +150,12 @@ export default function Tags() {
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`} {t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
</p> </p>
</div> </div>
{canCreateTag && (
<Button onClick={openCreateModal} className="gap-2 shadow-sm"> <Button onClick={openCreateModal} className="gap-2 shadow-sm">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t.tags?.create || "Create Tag"} {t.tags?.create || "Create Tag"}
</Button> </Button>
)}
</div> </div>
<FilterBar <FilterBar
@@ -176,14 +183,20 @@ export default function Tags() {
</div> </div>
</div> </div>
{(canEditTag || canDeleteTag) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canEditTag && (
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}> <Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</Button> </Button>
)}
{canDeleteTag && (
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}> <Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
<Trash2 className="w-4 h-4 text-red-500" /> <Trash2 className="w-4 h-4 text-red-500" />
</Button> </Button>
)}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react'; import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces'; import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions';
export default function WorkspaceDetail() { export default function WorkspaceDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -44,7 +45,8 @@ export default function WorkspaceDetail() {
return <div className="p-8 text-center">{t.workspace?.loading}</div>; return <div className="p-8 text-center">{t.workspace?.loading}</div>;
} }
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 ( return (
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">
@@ -75,7 +77,7 @@ export default function WorkspaceDetail() {
> >
<Edit2 className="h-5 w-5" /> <Edit2 className="h-5 w-5" />
</button> </button>
{workspace.my_role === 'owner' && ( {canDelete && (
<button <button
onClick={handleDelete} onClick={handleDelete}
className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg" className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg"

View File

@@ -14,6 +14,14 @@ import {
} from '../api/workspaces'; } from '../api/workspaces';
import { searchUserByExactMobile, type SearchedUser } from '../api/users'; import { searchUserByExactMobile, type SearchedUser } from '../api/users';
import { useAppContext } from '../context/AppContext'; import { useAppContext } from '../context/AppContext';
import {
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_CHANGE_ROLE,
canChangeWorkspaceMember,
canWorkspace,
type WorkspaceRole,
} from '../lib/permissions';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { InfiniteScroll } from '../components/InfiniteScroll'; import { InfiniteScroll } from '../components/InfiniteScroll';
import { Select } from '../components/ui/Select'; import { Select } from '../components/ui/Select';
@@ -45,7 +53,7 @@ export default function EditWorkspace() {
// Workspace Info States // Workspace Info States
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [myRole, setMyRole] = useState<'owner' | 'admin' | 'member' | 'guest'>('member'); const [myRole, setMyRole] = useState<WorkspaceRole>('member');
const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>(''); const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -105,6 +113,13 @@ export default function EditWorkspace() {
if (id) loadData(); if (id) loadData();
}, [id]); }, [id]);
useEffect(() => {
if (!isLoading && id && !canWorkspace(myRole, WORKSPACE_EDIT)) {
toast.error("You do not have permission to edit this workspace.");
navigate(`/workspaces/${id}`);
}
}, [id, isLoading, myRole, navigate]);
const loadData = async () => { const loadData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -258,9 +273,16 @@ export default function EditWorkspace() {
} }
}; };
const canManageMembers = myRole === 'owner' || myRole === 'admin'; const canManageMembers = canWorkspace(myRole, WORKSPACE_MEMBERS_CHANGE_ROLE);
const isFirstOwner = currentUserId === workspaceOwnerId; const isFirstOwner = currentUserId === workspaceOwnerId;
const roleOptions = (allowOwner: boolean) => [
...(allowOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
{ value: "member", label: t.workspace?.roles?.member || "Member" },
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
];
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>; if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
return ( return (
@@ -312,7 +334,7 @@ export default function EditWorkspace() {
{ t.workspace?.members || "Members" } { t.workspace?.members || "Members" }
</h2> </h2>
{canManageMembers && ( {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
type="text" type="text"
@@ -357,10 +379,7 @@ export default function EditWorkspace() {
value={newMemberRole} value={newMemberRole}
onChange={(val) => setNewMemberRole(val as any)} onChange={(val) => setNewMemberRole(val as any)}
options={[ options={[
...(isFirstOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []), ...roleOptions(isFirstOwner),
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
{ value: "member", label: t.workspace?.roles?.member || "Member" },
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
]} ]}
className="flex-1 sm:flex-none" className="flex-1 sm:flex-none"
buttonClassName="w-full sm:w-[110px] px-3 py-1.5 text-sm" buttonClassName="w-full sm:w-[110px] px-3 py-1.5 text-sm"
@@ -395,7 +414,13 @@ export default function EditWorkspace() {
> >
{members.map((m) => { {members.map((m) => {
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId; const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner'); const canChangeThisUserRole = canChangeWorkspaceMember({
actorRole: myRole,
actorUserId: currentUserId,
targetRole: m.role,
targetUserId: m.user?.id,
ownerUserId: workspaceOwnerId,
});
return ( return (
<div key={m.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors"> <div key={m.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors">
@@ -420,12 +445,7 @@ export default function EditWorkspace() {
<Select <Select
value={m.role} value={m.role}
onChange={(val) => handleChangeRole(m.id, val)} onChange={(val) => handleChangeRole(m.id, val)}
options={[ options={roleOptions(isFirstOwner)}
...(isFirstOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
{ value: "member", label: t.workspace?.roles?.member || "Member" },
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
]}
buttonClassName="w-[110px] px-3 py-1.5 text-sm" buttonClassName="w-[110px] px-3 py-1.5 text-sm"
/> />
) : ( ) : (
@@ -437,7 +457,7 @@ export default function EditWorkspace() {
</span> </span>
)} )}
{canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && ( {canChangeThisUserRole && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View File

@@ -3,8 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { Plus, Trash2, Pencil, ChevronRight } from 'lucide-react'; import { Plus, Trash2, Pencil, ChevronRight } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces'; import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
import { useAppContext } from '../context/AppContext';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import {
WORKSPACE_DELETE,
WORKSPACE_EDIT,
canWorkspace,
type WorkspaceRole,
} from '../lib/permissions';
import FilterBar from '../components/FilterBar'; import FilterBar from '../components/FilterBar';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
@@ -12,8 +17,6 @@ import { Card, CardContent, CardTitle } from '../components/ui/card';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
type WorkspaceRole = "owner" | "admin" | "member" | "guest";
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => { const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!role) return null; if (!role) return null;
@@ -46,7 +49,6 @@ export default function Workspaces() {
const [deleteInput, setDeleteInput] = useState(''); const [deleteInput, setDeleteInput] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAppContext();
const { t } = useTranslation(); const { t } = useTranslation();
const orderingOptions = [ const orderingOptions = [
@@ -146,8 +148,8 @@ export default function Workspaces() {
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<div className="flex flex-col gap-4 mb-6"> <div className="flex flex-col gap-4 mb-6">
{workspaces.map((workspace) => { {workspaces.map((workspace) => {
const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner'; const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
const isAdmin = workspace.my_role === 'admin' || isOwner; const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
return ( return (
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm"> <Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
@@ -165,7 +167,7 @@ export default function Workspaces() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{isOwner && ( {canDeleteWorkspace && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -177,7 +179,7 @@ export default function Workspaces() {
</Button> </Button>
)} )}
{isAdmin && ( {canEditWorkspace && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"