feat(projects): expose implicit-access roles in projects and rates modal
This commit is contained in:
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "پروژه با موفقیت بهروزرسانی شد.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user