From c6731590325b938fa18f8261f5d1ac96ecbaa59d Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 24 May 2026 10:31:32 +0330 Subject: [PATCH] feat(projects): expose implicit-access roles in projects and rates modal --- src/api/projects.ts | 2 +- .../projects/ProjectAccessModal.tsx | 51 ++++--- src/locales/en.ts | 11 +- src/locales/fa.ts | 11 +- src/pages/Projects.tsx | 3 + src/pages/WorkspaceEdit.tsx | 138 +++++++++++++----- 6 files changed, 153 insertions(+), 63 deletions(-) diff --git a/src/api/projects.ts b/src/api/projects.ts index 3db004d..27f2ad7 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -47,7 +47,7 @@ export interface ProjectAccessItem { export interface ProjectAccessState { workspace: { id: string; name: string }; - user: { id: string; name: string; mobile: string; role: "member" | "guest" }; + user: { id: string; name: string; mobile: string; role: "owner" | "admin" | "member" | "guest" }; items: ProjectAccessItem[]; } diff --git a/src/components/projects/ProjectAccessModal.tsx b/src/components/projects/ProjectAccessModal.tsx index 3123ed4..ad9f26d 100644 --- a/src/components/projects/ProjectAccessModal.tsx +++ b/src/components/projects/ProjectAccessModal.tsx @@ -69,6 +69,7 @@ type Labels = { projectRateRemoved: string; projectRateSaveError: string; projectRateRemoveError: string; + implicitAccessHint: string; }; type RateDraft = { @@ -76,8 +77,6 @@ type RateDraft = { currency: string; }; -const MANAGEABLE_ROLES = new Set(["member", "guest"]); - function getMemberName(member: WorkspaceMembership) { return ( member.user?.name || @@ -132,22 +131,23 @@ export function ProjectAccessModal({ const [savingRateProjectId, setSavingRateProjectId] = useState(null); const [priceUnits, setPriceUnits] = useState([]); const [rateDrafts, setRateDrafts] = useState>({}); + const [selectedUserRole, setSelectedUserRole] = useState<"owner" | "admin" | "member" | "guest" | "">(""); const { lang } = useTranslation(); const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"; const defaultCurrency = priceUnits[0]?.code || "USD"; - const manageableMembers = useMemo( - () => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)), + const activeMembers = useMemo( + () => members.filter((member) => member.is_active), [members], ); const filteredMembers = useMemo(() => { const normalizedSearch = memberSearchQuery.trim().toLowerCase(); const baseMembers = !normalizedSearch - ? manageableMembers - : manageableMembers.filter((member) => { + ? activeMembers + : activeMembers.filter((member) => { const memberName = getMemberName(member).toLowerCase(); const memberMobile = member.user?.mobile?.toLowerCase() ?? ""; return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch); @@ -158,7 +158,9 @@ export function ProjectAccessModal({ if (b.user.id === selectedUserId) return 1; return 0; }); - }, [manageableMembers, memberSearchQuery, selectedUserId]); + }, [activeMembers, memberSearchQuery, selectedUserId]); + + const canManageExplicitAccess = selectedUserRole === "member" || selectedUserRole === "guest"; const clientOptions = useMemo(() => { const map = new Map(); @@ -246,14 +248,14 @@ export function ProjectAccessModal({ }, [isOpen, labels.loadError, workspaceId]); useEffect(() => { - if (!manageableMembers.length) { + if (!activeMembers.length) { setSelectedUserId(""); return; } - if (!manageableMembers.some((member) => member.user.id === selectedUserId)) { - setSelectedUserId(manageableMembers[0].user.id); + if (!activeMembers.some((member) => member.user.id === selectedUserId)) { + setSelectedUserId(activeMembers[0].user.id); } - }, [manageableMembers, selectedUserId]); + }, [activeMembers, selectedUserId]); useEffect(() => { if (!isOpen || !selectedUserId) { @@ -265,10 +267,12 @@ export function ProjectAccessModal({ setLoadingProjects(true); try { const response = await getProjectAccessState(workspaceId, selectedUserId); + setSelectedUserRole(response.user.role); setProjectItems(response.items); setSelectedProjectIds([]); } catch { toast.error(labels.loadError); + setSelectedUserRole(""); setProjectItems([]); } finally { setLoadingProjects(false); @@ -305,6 +309,7 @@ export function ProjectAccessModal({ }; const toggleProjectSelection = (projectId: string) => { + if (!canManageExplicitAccess) return; setSelectedProjectIds((current) => current.includes(projectId) ? current.filter((id) => id !== projectId) @@ -313,11 +318,12 @@ export function ProjectAccessModal({ }; const handleSelectAllVisible = () => { + if (!canManageExplicitAccess) return; setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds]))); }; const handleSelectClientProjects = () => { - if (!selectedClientId) return; + if (!selectedClientId || !canManageExplicitAccess) return; const clientProjectIds = visibleProjects .filter((item) => item.client?.id === selectedClientId) .map((item) => item.id); @@ -442,7 +448,7 @@ export function ProjectAccessModal({ type="button" variant="outline" onClick={() => void handleMutation("grant")} - disabled={!selectedProjectIds.length || isSaving} + disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving} className="gap-2" > {isSaving ? : } @@ -452,7 +458,7 @@ export function ProjectAccessModal({ type="button" variant="destructive" onClick={() => void handleMutation("revoke")} - disabled={!selectedProjectIds.length || isSaving} + disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving} className="gap-2" > {isSaving ? : } @@ -473,6 +479,11 @@ export function ProjectAccessModal({

{labels.description}

+ {!canManageExplicitAccess && selectedUserRole ? ( +
+ {labels.implicitAccessHint} +
+ ) : null}
@@ -542,7 +553,7 @@ export function ProjectAccessModal({ variant="secondary" size="icon" onClick={handleSelectClientProjects} - disabled={!selectedClientId} + disabled={!canManageExplicitAccess || !selectedClientId} title={labels.selectClientProjects} aria-label={labels.selectClientProjects} > @@ -583,7 +594,7 @@ export function ProjectAccessModal({
{isActive ? ( diff --git a/src/locales/en.ts b/src/locales/en.ts index c496aaf..8016650 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -527,11 +527,11 @@ export const en = { clearClientFilters: "Clear filters", namePlaceholder: "Project name...", teamMembers: "Team Members", - manageAccess: "Manage access", - accessModalTitle: "Project access", - accessModalDescription: "Grant or revoke project access for workspace members.", - accessMemberLabel: "Member", - accessNoMembers: "No eligible members were found.", + manageAccess: "Projects & Rates", + accessModalTitle: "Projects & Rates", + accessModalDescription: "Manage project access for members and guests, and set project-specific rates for any workspace user.", + accessMemberLabel: "User", + accessNoMembers: "No workspace users were found.", accessNoProjects: "No projects found.", accessSelectVisible: "Select all visible", accessClearSelection: "Clear selection", @@ -544,6 +544,7 @@ export const en = { accessRevokeSuccess: "Project access revoked.", accessLoadError: "Failed to load project access state.", accessSaveError: "Failed to update project access.", + implicitAccessHint: "Owners and admins always have access to all projects. You can still set project-specific rate overrides here.", creator: "Creator", addUser: "Add user by mobile", addFromWorkspace: "Add from workspace", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 34b366b..dae59b4 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -536,11 +536,11 @@ export const fa = { }, namePlaceholder: "نام پروژه...", teamMembers: "اعضای تیم", - manageAccess: "مدیریت دسترسی", - accessModalTitle: "دسترسی پروژه‌ها", - accessModalDescription: "دسترسی اعضای ورک‌اسپیس به پروژه‌ها را اعطا یا لغو کنید.", - accessMemberLabel: "عضو", - accessNoMembers: "عضو واجد شرایطی پیدا نشد.", + manageAccess: "پروژه‌ها و نرخ‌ها", + accessModalTitle: "پروژه‌ها و نرخ‌ها", + accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.", + accessMemberLabel: "کاربر", + accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.", accessNoProjects: "پروژه‌ای پیدا نشد.", accessSelectVisible: "انتخاب همه موارد قابل مشاهده", accessClearSelection: "پاک کردن انتخاب", @@ -553,6 +553,7 @@ export const fa = { accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.", accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.", accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.", + implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.", createSuccess: "پروژه با موفقیت ایجاد شد.", createError: "خطا در ایجاد پروژه.", updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.", diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 752bd3c..2b30491 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -494,6 +494,9 @@ export const Projects: React.FC = () => { projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.", projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.", projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.", + implicitAccessHint: + t.projects?.implicitAccessHint || + "Owners and admins always have access to all projects. You can still set project-specific rate overrides here.", }} /> )} diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index f01895d..affcd1e 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react'; import { useBlocker, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from '../hooks/useTranslation'; -import { AlertCircle, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react'; +import { AlertCircle, ShieldCheck, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react'; import { Dialog, Transition } from '@headlessui/react'; import { toast } from 'sonner'; import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates'; @@ -29,6 +29,7 @@ import { Select } from '../components/ui/Select'; import { Input } from '../components/ui/input'; import { TextAreaInput } from '../components/ui/TextAreaInput'; import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields'; +import { ProjectAccessModal } from '../components/projects/ProjectAccessModal'; const toEnglishDigits = (str: string) => { return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) @@ -82,6 +83,7 @@ export default function EditWorkspace() { // Modal State const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [memberIdToDelete, setMemberIdToDelete] = useState(null); + const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false); const searchTimeoutRef = useRef | null>(null); @@ -115,16 +117,16 @@ export default function EditWorkspace() { setThumbnailFile(null); return; } - const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; - if (!allowedTypes.includes(file.type)) { - toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); - return; - } - const maxBytes = 2 * 1024 * 1024; - if (file.size > maxBytes) { - toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); - return; - } + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); + return; + } + const maxBytes = 2 * 1024 * 1024; + if (file.size > maxBytes) { + toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); + return; + } setThumbnailFile(file); setClearThumbnail(false); }; @@ -338,8 +340,9 @@ export default function EditWorkspace() { if (isLoading) return
{t.workspace?.loading || "Loading..."}
; - return ( -
+ return ( + <> +

{t.workspace?.editTitle || "Edit Workspace"}

@@ -372,7 +375,7 @@ export default function EditWorkspace() {
+ ) : null} +
+ +
+ +

+ {t.workspace?.projectRateHint || + "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."} +

+
+ + {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
- ); -} + + {canWorkspace(myRole, WORKSPACE_EDIT) && id ? ( + setIsProjectAccessModalOpen(false)} + workspaceId={id} + onApplied={() => {}} + labels={{ + title: t.projects?.accessModalTitle || "Projects & Rates", + description: + t.projects?.accessModalDescription || + "Manage project access for members and guests, and set project-specific rates for any workspace user.", + close: t.actions?.cancel || "Close", + member: t.projects?.accessMemberLabel || "User", + projects: t.sidebar?.projects || "Projects", + loading: t.loading || "Loading...", + noMembers: t.projects?.accessNoMembers || "No workspace users were found.", + noProjects: t.projects?.accessNoProjects || "No projects found.", + searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...", + allClients: t.reports?.allClients || "All clients", + selectAllVisible: t.projects?.accessSelectVisible || "Select all visible", + clearSelection: t.projects?.accessClearSelection || "Clear selection", + selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client", + grantSelected: t.projects?.accessGrant || "Grant selected", + revokeSelected: t.projects?.accessRevoke || "Revoke selected", + accessGranted: t.projects?.accessGrantSuccess || "Project access granted.", + accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.", + memberRole: t.workspace?.roleLabel || "Role", + client: t.projects?.clientLabel || "Client", + noClient: t.projects?.noClient || "No client", + accessOn: t.projects?.accessOn || "Has access", + accessOff: t.projects?.accessOff || "No access", + loadError: t.projects?.accessLoadError || "Failed to load project access state.", + saveError: t.projects?.accessSaveError || "Failed to update project access.", + workspaceRate: t.rates?.workspaceRate || "Workspace rate", + projectOverride: t.rates?.projectOverride || "Project override", + inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate", + noRate: t.rates?.noRate || "No rate", + hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00", + currencyPlaceholder: t.rates?.currencyPlaceholder || "USD", + removeRate: t.rates?.removeRate || "Remove rate", + projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.", + projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.", + projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.", + projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.", + implicitAccessHint: + t.projects?.implicitAccessHint || + "Owners and admins always have access to all projects. You can still set project-specific rate overrides here.", + }} + /> + ) : null} + + ); +}