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 { export interface ProjectAccessState {
workspace: { id: string; name: string }; 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[]; items: ProjectAccessItem[];
} }

View File

@@ -69,6 +69,7 @@ type Labels = {
projectRateRemoved: string; projectRateRemoved: string;
projectRateSaveError: string; projectRateSaveError: string;
projectRateRemoveError: string; projectRateRemoveError: string;
implicitAccessHint: string;
}; };
type RateDraft = { type RateDraft = {
@@ -76,8 +77,6 @@ type RateDraft = {
currency: string; currency: string;
}; };
const MANAGEABLE_ROLES = new Set(["member", "guest"]);
function getMemberName(member: WorkspaceMembership) { function getMemberName(member: WorkspaceMembership) {
return ( return (
member.user?.name || member.user?.name ||
@@ -132,22 +131,23 @@ export function ProjectAccessModal({
const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null); const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]); const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({}); const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
const [selectedUserRole, setSelectedUserRole] = useState<"owner" | "admin" | "member" | "guest" | "">("");
const { lang } = useTranslation(); const { lang } = useTranslation();
const isRtl = const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl"; typeof document !== "undefined" && document.documentElement.dir === "rtl";
const defaultCurrency = priceUnits[0]?.code || "USD"; const defaultCurrency = priceUnits[0]?.code || "USD";
const manageableMembers = useMemo( const activeMembers = useMemo(
() => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)), () => members.filter((member) => member.is_active),
[members], [members],
); );
const filteredMembers = useMemo(() => { const filteredMembers = useMemo(() => {
const normalizedSearch = memberSearchQuery.trim().toLowerCase(); const normalizedSearch = memberSearchQuery.trim().toLowerCase();
const baseMembers = !normalizedSearch const baseMembers = !normalizedSearch
? manageableMembers ? activeMembers
: manageableMembers.filter((member) => { : activeMembers.filter((member) => {
const memberName = getMemberName(member).toLowerCase(); const memberName = getMemberName(member).toLowerCase();
const memberMobile = member.user?.mobile?.toLowerCase() ?? ""; const memberMobile = member.user?.mobile?.toLowerCase() ?? "";
return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch); return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch);
@@ -158,7 +158,9 @@ export function ProjectAccessModal({
if (b.user.id === selectedUserId) return 1; if (b.user.id === selectedUserId) return 1;
return 0; return 0;
}); });
}, [manageableMembers, memberSearchQuery, selectedUserId]); }, [activeMembers, memberSearchQuery, selectedUserId]);
const canManageExplicitAccess = selectedUserRole === "member" || selectedUserRole === "guest";
const clientOptions = useMemo(() => { const clientOptions = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -246,14 +248,14 @@ export function ProjectAccessModal({
}, [isOpen, labels.loadError, workspaceId]); }, [isOpen, labels.loadError, workspaceId]);
useEffect(() => { useEffect(() => {
if (!manageableMembers.length) { if (!activeMembers.length) {
setSelectedUserId(""); setSelectedUserId("");
return; return;
} }
if (!manageableMembers.some((member) => member.user.id === selectedUserId)) { if (!activeMembers.some((member) => member.user.id === selectedUserId)) {
setSelectedUserId(manageableMembers[0].user.id); setSelectedUserId(activeMembers[0].user.id);
} }
}, [manageableMembers, selectedUserId]); }, [activeMembers, selectedUserId]);
useEffect(() => { useEffect(() => {
if (!isOpen || !selectedUserId) { if (!isOpen || !selectedUserId) {
@@ -265,10 +267,12 @@ export function ProjectAccessModal({
setLoadingProjects(true); setLoadingProjects(true);
try { try {
const response = await getProjectAccessState(workspaceId, selectedUserId); const response = await getProjectAccessState(workspaceId, selectedUserId);
setSelectedUserRole(response.user.role);
setProjectItems(response.items); setProjectItems(response.items);
setSelectedProjectIds([]); setSelectedProjectIds([]);
} catch { } catch {
toast.error(labels.loadError); toast.error(labels.loadError);
setSelectedUserRole("");
setProjectItems([]); setProjectItems([]);
} finally { } finally {
setLoadingProjects(false); setLoadingProjects(false);
@@ -305,6 +309,7 @@ export function ProjectAccessModal({
}; };
const toggleProjectSelection = (projectId: string) => { const toggleProjectSelection = (projectId: string) => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) => setSelectedProjectIds((current) =>
current.includes(projectId) current.includes(projectId)
? current.filter((id) => id !== projectId) ? current.filter((id) => id !== projectId)
@@ -313,11 +318,12 @@ export function ProjectAccessModal({
}; };
const handleSelectAllVisible = () => { const handleSelectAllVisible = () => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds]))); setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
}; };
const handleSelectClientProjects = () => { const handleSelectClientProjects = () => {
if (!selectedClientId) return; if (!selectedClientId || !canManageExplicitAccess) return;
const clientProjectIds = visibleProjects const clientProjectIds = visibleProjects
.filter((item) => item.client?.id === selectedClientId) .filter((item) => item.client?.id === selectedClientId)
.map((item) => item.id); .map((item) => item.id);
@@ -442,7 +448,7 @@ export function ProjectAccessModal({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => void handleMutation("grant")} onClick={() => void handleMutation("grant")}
disabled={!selectedProjectIds.length || isSaving} disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2" className="gap-2"
> >
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />} {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
@@ -452,7 +458,7 @@ export function ProjectAccessModal({
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => void handleMutation("revoke")} onClick={() => void handleMutation("revoke")}
disabled={!selectedProjectIds.length || isSaving} disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2" className="gap-2"
> >
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />} {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"> <p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400">
{labels.description} {labels.description}
</p> </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"> <div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<section <section
@@ -518,7 +529,7 @@ export function ProjectAccessModal({
variant="secondary" variant="secondary"
size="icon" size="icon"
onClick={handleSelectAllVisible} onClick={handleSelectAllVisible}
disabled={!visibleProjects.length} disabled={!canManageExplicitAccess || !visibleProjects.length}
title={labels.selectAllVisible} title={labels.selectAllVisible}
aria-label={labels.selectAllVisible} aria-label={labels.selectAllVisible}
> >
@@ -542,7 +553,7 @@ export function ProjectAccessModal({
variant="secondary" variant="secondary"
size="icon" size="icon"
onClick={handleSelectClientProjects} onClick={handleSelectClientProjects}
disabled={!selectedClientId} disabled={!canManageExplicitAccess || !selectedClientId}
title={labels.selectClientProjects} title={labels.selectClientProjects}
aria-label={labels.selectClientProjects} aria-label={labels.selectClientProjects}
> >
@@ -583,7 +594,7 @@ export function ProjectAccessModal({
<button <button
type="button" type="button"
onClick={() => toggleProjectSelection(item.id)} 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 <div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${ 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) => { filteredMembers.map((member) => {
const isActive = member.user.id === selectedUserId; const isActive = member.user.id === selectedUserId;
const isImplicitUser = member.role === "owner" || member.role === "admin";
return ( return (
<button <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"> <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} {member.role}
</span> </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> </div>
{isActive ? ( {isActive ? (

View File

@@ -527,11 +527,11 @@ export const en = {
clearClientFilters: "Clear filters", clearClientFilters: "Clear filters",
namePlaceholder: "Project name...", namePlaceholder: "Project name...",
teamMembers: "Team Members", teamMembers: "Team Members",
manageAccess: "Manage access", manageAccess: "Projects & Rates",
accessModalTitle: "Project access", accessModalTitle: "Projects & Rates",
accessModalDescription: "Grant or revoke project access for workspace members.", accessModalDescription: "Manage project access for members and guests, and set project-specific rates for any workspace user.",
accessMemberLabel: "Member", accessMemberLabel: "User",
accessNoMembers: "No eligible members were found.", accessNoMembers: "No workspace users were found.",
accessNoProjects: "No projects found.", accessNoProjects: "No projects found.",
accessSelectVisible: "Select all visible", accessSelectVisible: "Select all visible",
accessClearSelection: "Clear selection", accessClearSelection: "Clear selection",
@@ -544,6 +544,7 @@ export const en = {
accessRevokeSuccess: "Project access revoked.", accessRevokeSuccess: "Project access revoked.",
accessLoadError: "Failed to load project access state.", accessLoadError: "Failed to load project access state.",
accessSaveError: "Failed to update project access.", 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", creator: "Creator",
addUser: "Add user by mobile", addUser: "Add user by mobile",
addFromWorkspace: "Add from workspace", addFromWorkspace: "Add from workspace",

View File

@@ -536,11 +536,11 @@ export const fa = {
}, },
namePlaceholder: "نام پروژه...", namePlaceholder: "نام پروژه...",
teamMembers: "اعضای تیم", teamMembers: "اعضای تیم",
manageAccess: "مدیریت دسترسی", manageAccess: "پروژه‌ها و نرخ‌ها",
accessModalTitle: "دسترسی پروژه‌ها", accessModalTitle: "پروژهها و نرخها",
accessModalDescription: "دسترسی اعضای ورک‌اسپیس به پروژه‌ها را اعطا یا لغو کنید.", accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.",
accessMemberLabel: "عضو", accessMemberLabel: "کاربر",
accessNoMembers: "عضو واجد شرایطی پیدا نشد.", accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.",
accessNoProjects: "پروژه‌ای پیدا نشد.", accessNoProjects: "پروژه‌ای پیدا نشد.",
accessSelectVisible: "انتخاب همه موارد قابل مشاهده", accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
accessClearSelection: "پاک کردن انتخاب", accessClearSelection: "پاک کردن انتخاب",
@@ -553,6 +553,7 @@ export const fa = {
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.", accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.", accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.",
accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.", accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.",
implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.",
createSuccess: "پروژه با موفقیت ایجاد شد.", createSuccess: "پروژه با موفقیت ایجاد شد.",
createError: "خطا در ایجاد پروژه.", createError: "خطا در ایجاد پروژه.",
updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.", updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.",

View File

@@ -494,6 +494,9 @@ export const Projects: React.FC = () => {
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.", projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.", projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove 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 React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router-dom'; import { useBlocker, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from '../hooks/useTranslation'; 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 { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates'; 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 { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput'; import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields'; import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
const toEnglishDigits = (str: string) => { const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -82,6 +83,7 @@ export default function EditWorkspace() {
// Modal State // Modal State
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null); const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -339,6 +341,7 @@ export default function EditWorkspace() {
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 (
<>
<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"> <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"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.editTitle || "Edit Workspace"} {t.workspace?.editTitle || "Edit Workspace"}
@@ -432,9 +435,22 @@ 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="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"> <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"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{ t.workspace?.members || "Members" } <h2 className="text-lg font-semibold text-slate-900 dark:text-white">
</h2> { 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"> <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" /> <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
@@ -659,5 +675,57 @@ export default function EditWorkspace() {
</Dialog> </Dialog>
</Transition> </Transition>
</div> </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}
</>
); );
} }