feat(projects): expose implicit-access roles in projects and rates modal

This commit is contained in:
2026-05-24 10:31:32 +03:30
parent 9a217fcd54
commit c673159032
6 changed files with 153 additions and 63 deletions

View File

@@ -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[];
}

View File

@@ -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<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
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<string, string>();
@@ -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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
@@ -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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />}
@@ -473,6 +479,11 @@ export function ProjectAccessModal({
<p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400">
{labels.description}
</p>
{!canManageExplicitAccess && selectedUserRole ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100">
{labels.implicitAccessHint}
</div>
) : null}
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<section
@@ -518,7 +529,7 @@ export function ProjectAccessModal({
variant="secondary"
size="icon"
onClick={handleSelectAllVisible}
disabled={!visibleProjects.length}
disabled={!canManageExplicitAccess || !visibleProjects.length}
title={labels.selectAllVisible}
aria-label={labels.selectAllVisible}
>
@@ -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({
<button
type="button"
onClick={() => toggleProjectSelection(item.id)}
className="flex w-full items-start gap-3 text-start"
className={`flex w-full items-start gap-3 text-start ${!canManageExplicitAccess ? "cursor-default" : ""}`}
>
<div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
@@ -747,6 +758,7 @@ export function ProjectAccessModal({
) : (
filteredMembers.map((member) => {
const isActive = member.user.id === selectedUserId;
const isImplicitUser = member.role === "owner" || member.role === "admin";
return (
<button
@@ -779,6 +791,11 @@ export function ProjectAccessModal({
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{member.role}
</span>
{isImplicitUser ? (
<span className="shrink-0 rounded-full bg-sky-100 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-200">
{labels.accessOn}
</span>
) : null}
</div>
{isActive ? (

View File

@@ -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",

View File

@@ -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: "پروژه با موفقیت به‌روزرسانی شد.",

View File

@@ -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.",
}}
/>
)}

View File

@@ -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<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
return (
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
return (
<>
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.editTitle || "Edit Workspace"}
</h1>
@@ -372,7 +375,7 @@ export default function EditWorkspace() {
</div>
<div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700">
@@ -392,9 +395,9 @@ export default function EditWorkspace() {
<div className="flex flex-col items-center gap-2 text-center">
<UploadCloud className="h-10 w-10 text-slate-500 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{t.workspace?.uploadImage || "Click to upload image"}
</span>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{t.workspace?.uploadImage || "Click to upload image"}
</span>
</div>
)}
@@ -415,9 +418,9 @@ export default function EditWorkspace() {
}}
className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400"
>
{t.workspace?.removeImage || "Remove image"}
</button>
)}
{t.workspace?.removeImage || "Remove image"}
</button>
)}
</div>
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
<Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}>
@@ -432,19 +435,32 @@ export default function EditWorkspace() {
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" }
</h2>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="leading-6">
{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."}
</p>
</div>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{ t.workspace?.members || "Members" }
</h2>
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsProjectAccessModalOpen(true)}
className="gap-2 self-start sm:self-auto"
>
<ShieldCheck className="h-4 w-4" />
{t.projects?.manageAccess || "Projects & Rates"}
</Button>
) : null}
</div>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="leading-6">
{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."}
</p>
</div>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3">
<Input
type="text"
@@ -659,5 +675,57 @@ export default function EditWorkspace() {
</Dialog>
</Transition>
</div>
);
}
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<ProjectAccessModal
isOpen={isProjectAccessModalOpen}
onClose={() => 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}
</>
);
}