feat(permissions): gate workspace resources by role
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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,19 +91,23 @@ 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">
|
||||||
<button
|
{canArchiveProject ? (
|
||||||
onClick={handleArchiveToggle}
|
<button
|
||||||
type="button"
|
onClick={handleArchiveToggle}
|
||||||
disabled={loading}
|
type="button"
|
||||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border ${
|
disabled={loading}
|
||||||
project?.is_archived
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border ${
|
||||||
? "text-green-600 border-green-600 hover:bg-green-50"
|
project?.is_archived
|
||||||
: "text-amber-600 border-amber-600 hover:bg-amber-50"
|
? "text-green-600 border-green-600 hover:bg-green-50"
|
||||||
}`}
|
: "text-amber-600 border-amber-600 hover:bg-amber-50"
|
||||||
>
|
}`}
|
||||||
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
>
|
||||||
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
||||||
</button>
|
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
||||||
|
</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
197
src/lib/permissions.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|
||||||
<Button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2">
|
{canCreateClient && (
|
||||||
<Plus className="w-4 h-4" />
|
<Button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2">
|
||||||
{t.clients.addClient}
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
{t.clients.addClient}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -159,24 +171,30 @@ export default function Clients() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
{(canEditClient || canDeleteClient) && (
|
||||||
<Button
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
variant="ghost"
|
{canEditClient && (
|
||||||
size="icon"
|
<Button
|
||||||
onClick={() => setEditClient(client)}
|
variant="ghost"
|
||||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
size="icon"
|
||||||
>
|
onClick={() => setEditClient(client)}
|
||||||
<Pencil className="w-4 h-4" />
|
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Pencil className="w-4 h-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
)}
|
||||||
onClick={() => setDeleteClient(client)}
|
{canDeleteClient && (
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Trash2 className="w-4 h-4" />
|
size="icon"
|
||||||
</Button>
|
onClick={() => setDeleteClient(client)}
|
||||||
</div>
|
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -194,26 +212,32 @@ export default function Clients() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateClientModal
|
{canCreateClient && (
|
||||||
isOpen={isCreateModalOpen}
|
<CreateClientModal
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
isOpen={isCreateModalOpen}
|
||||||
onSuccess={fetchClientsList}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
workspaceId={activeWorkspace.id}
|
onSuccess={fetchClientsList}
|
||||||
/>
|
workspaceId={activeWorkspace.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<EditClientModal
|
{canEditClient && (
|
||||||
isOpen={!!editClient}
|
<EditClientModal
|
||||||
onClose={() => setEditClient(null)}
|
isOpen={!!editClient}
|
||||||
onSuccess={fetchClientsList}
|
onClose={() => setEditClient(null)}
|
||||||
client={editClient}
|
onSuccess={fetchClientsList}
|
||||||
/>
|
client={editClient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DeleteClientModal
|
{canDeleteClient && (
|
||||||
isOpen={!!deleteClient}
|
<DeleteClientModal
|
||||||
onClose={() => setDeleteClient(null)}
|
isOpen={!!deleteClient}
|
||||||
onSuccess={fetchClientsList}
|
onClose={() => setDeleteClient(null)}
|
||||||
client={deleteClient}
|
onSuccess={fetchClientsList}
|
||||||
/>
|
client={deleteClient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
|||||||
@@ -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,21 +129,25 @@ 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">
|
||||||
<Button
|
{canArchiveProject && (
|
||||||
variant={isArchived ? "default" : "secondary"}
|
<Button
|
||||||
onClick={() => setIsArchived(!isArchived)}
|
variant={isArchived ? "default" : "secondary"}
|
||||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
onClick={() => setIsArchived(!isArchived)}
|
||||||
>
|
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||||
<Archive className="h-4 w-4" />
|
>
|
||||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
<Archive className="h-4 w-4" />
|
||||||
</Button>
|
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||||
<Button
|
</Button>
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
)}
|
||||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
{canCreateProject && (
|
||||||
>
|
<Button
|
||||||
<Plus className="h-5 w-5" />
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
{t.projects?.createNew || 'Create New'}
|
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||||
</Button>
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
{t.projects?.createNew || 'Create New'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,27 +188,33 @@ export const Projects: React.FC = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
{(canEditProject || canDeleteProject) && (
|
||||||
<Button
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
variant="ghost"
|
{canEditProject && (
|
||||||
size="icon"
|
<Button
|
||||||
onClick={() => setEditingProject(project)}
|
variant="ghost"
|
||||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
size="icon"
|
||||||
title={t.actions?.edit || 'Edit'}
|
onClick={() => setEditingProject(project)}
|
||||||
>
|
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||||
<Pencil className="w-4 h-4" />
|
title={t.actions?.edit || 'Edit'}
|
||||||
</Button>
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
{canDeleteProject && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => setDeleteModal({ isOpen: true, project })}
|
size="icon"
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||||
title={t.actions?.delete || 'Delete'}
|
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||||
>
|
title={t.actions?.delete || 'Delete'}
|
||||||
<Trash2 className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="w-4 h-4" />
|
||||||
</div>
|
</Button>
|
||||||
|
)}
|
||||||
|
</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}
|
||||||
|
|||||||
@@ -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>
|
||||||
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
|
{canCreateTag && (
|
||||||
<Plus className="h-4 w-4" />
|
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
|
||||||
{t.tags?.create || "Create Tag"}
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
{t.tags?.create || "Create Tag"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -176,14 +183,20 @@ export default function Tags() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{(canEditTag || canDeleteTag) && (
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
<div className="flex items-center gap-2">
|
||||||
<Edit2 className="w-4 h-4" />
|
{canEditTag && (
|
||||||
</Button>
|
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
||||||
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
|
<Edit2 className="w-4 h-4" />
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
{canDeleteTag && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user