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

@@ -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 ? (