feat(permissions): gate workspace resources by role
This commit is contained in:
@@ -3,11 +3,12 @@ import { useTranslation } from "../../hooks/useTranslation";
|
|||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import { createProject } from "../../api/projects";
|
import { createProject } from "../../api/projects";
|
||||||
import { getClients } from "../../api/clients";
|
import { getClients } from "../../api/clients";
|
||||||
import { useWorkspace } from "../../context/WorkspaceContext";
|
import { useWorkspace } from "../../context/WorkspaceContext";
|
||||||
import { Select } from "../ui/Select";
|
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;
|
||||||
@@ -15,8 +16,9 @@ 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({
|
||||||
@@ -61,7 +63,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<>
|
<>
|
||||||
<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 dark:hover:bg-slate-700">
|
<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 dark:hover:bg-slate-700">
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
@@ -70,7 +72,9 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
{loading ? "..." : t.projects?.create}
|
{loading ? "..." : t.projects?.create}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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}>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { updateProject, toggleArchiveProject } from "../../api/projects";
|
|||||||
import { getClients } from "../../api/clients";
|
import { getClients } from "../../api/clients";
|
||||||
import { useWorkspace } from "../../context/WorkspaceContext";
|
import { useWorkspace } from "../../context/WorkspaceContext";
|
||||||
import { Archive, RefreshCcw } from "lucide-react";
|
import { Archive, RefreshCcw } from "lucide-react";
|
||||||
import { Select } from "../ui/Select";
|
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;
|
||||||
@@ -17,8 +18,10 @@ 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({
|
||||||
@@ -86,21 +89,25 @@ 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">
|
||||||
@@ -111,7 +118,9 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useEffect, useState } from "react"
|
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"
|
||||||
@@ -32,8 +38,12 @@ export default function Clients() {
|
|||||||
const [editClient, setEditClient] = useState<Client | null>(null)
|
const [editClient, setEditClient] = useState<Client | null>(null)
|
||||||
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
|
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
|
||||||
|
|
||||||
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
|
)}
|
||||||
isOpen={!!editClient}
|
|
||||||
onClose={() => setEditClient(null)}
|
{canEditClient && (
|
||||||
onSuccess={fetchClientsList}
|
<EditClientModal
|
||||||
client={editClient}
|
isOpen={!!editClient}
|
||||||
/>
|
onClose={() => setEditClient(null)}
|
||||||
|
onSuccess={fetchClientsList}
|
||||||
<DeleteClientModal
|
client={editClient}
|
||||||
isOpen={!!deleteClient}
|
/>
|
||||||
onClose={() => setDeleteClient(null)}
|
)}
|
||||||
onSuccess={fetchClientsList}
|
|
||||||
client={deleteClient}
|
{canDeleteClient && (
|
||||||
/>
|
<DeleteClientModal
|
||||||
|
isOpen={!!deleteClient}
|
||||||
|
onClose={() => setDeleteClient(null)}
|
||||||
|
onSuccess={fetchClientsList}
|
||||||
|
client={deleteClient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { fetchWorkspaceMemberships } 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 { 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";
|
||||||
@@ -57,7 +58,8 @@ export default function ProjectCreate() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
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("");
|
||||||
@@ -89,7 +91,14 @@ export default function ProjectCreate() {
|
|||||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
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 }) => {
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
|
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 }))
|
...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user }))
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!activeWorkspace) {
|
if (!activeWorkspace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { fetchWorkspaceMemberships } 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 { 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";
|
||||||
@@ -58,7 +59,8 @@ export default function ProjectEdit() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
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("");
|
||||||
@@ -87,7 +89,14 @@ export default function ProjectEdit() {
|
|||||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
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 }) => {
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||||
|
|||||||
@@ -11,12 +11,24 @@ import FilterBar from "../components/FilterBar";
|
|||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card";
|
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>
|
||||||
variant="ghost"
|
)}
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeleteModal({ isOpen: true, project })}
|
{canDeleteProject && (
|
||||||
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
|
||||||
title={t.actions?.delete || 'Delete'}
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<Trash2 className="w-4 h-4" />
|
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||||
</Button>
|
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"
|
||||||
</div>
|
title={t.actions?.delete || 'Delete'}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -216,15 +238,15 @@ 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}
|
||||||
onClose={() => setEditingProject(null)}
|
onClose={() => setEditingProject(null)}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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,8 +77,8 @@ 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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ import {
|
|||||||
getWorkspace
|
getWorkspace
|
||||||
} 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);
|
||||||
@@ -101,9 +109,16 @@ export default function EditWorkspace() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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 {
|
||||||
@@ -258,10 +273,17 @@ 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;
|
||||||
|
|
||||||
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
|
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>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||||
@@ -312,8 +334,8 @@ 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"
|
||||||
placeholder={t.workspace?.searchMemberPlaceholder || "Search user by exact mobile number..."}
|
placeholder={t.workspace?.searchMemberPlaceholder || "Search user by exact mobile number..."}
|
||||||
@@ -357,11 +379,8 @@ 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"
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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,9 +17,7 @@ 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;
|
||||||
|
|
||||||
@@ -45,9 +48,8 @@ export default function Workspaces() {
|
|||||||
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
||||||
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 = [
|
||||||
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
||||||
@@ -120,13 +122,13 @@ export default function Workspaces() {
|
|||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate('/workspaces/create')}
|
onClick={() => navigate('/workspaces/create')}
|
||||||
className="gap-2 shadow-sm"
|
className="gap-2 shadow-sm"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{t.workspace?.createNew || 'Create New'}
|
{t.workspace?.createNew || 'Create New'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -145,11 +147,11 @@ 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">
|
||||||
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 px-6 gap-4">
|
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 px-6 gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -165,8 +167,8 @@ 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"
|
||||||
onClick={() => setDeleteModal({ isOpen: true, workspace })}
|
onClick={() => setDeleteModal({ isOpen: true, workspace })}
|
||||||
@@ -177,8 +179,8 @@ export default function Workspaces() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{canEditWorkspace && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
|
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
|
||||||
|
|||||||
Reference in New Issue
Block a user