feat(projects): expose implicit-access roles in projects and rates modal
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user