import React, { useEffect, useMemo, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Project } from '../../entities/Project'; import { AccessLevel, grantShare, listShares, revokeShare, } from '../../utils/sharesService'; import { ChevronDownIcon, CheckIcon } from '@heroicons/react/24/outline'; import { getCurrentUser } from '../../utils/userUtils'; import { getApiPath } from '../../config/paths'; interface ProjectShareModalProps { isOpen: boolean; onClose: () => void; project: Project; } interface ShareRow { user_id: number; access_level: AccessLevel | 'owner'; created_at: string | null; email?: string; // best-effort; may stay undefined without a lookup API is_owner?: boolean; } interface UserItem { id: number; email: string; name?: string; surname?: string; role: 'admin' | 'user'; } const ProjectShareModal: React.FC = ({ isOpen, onClose, project, }) => { const { t } = useTranslation(); const [selectedUserId, setSelectedUserId] = useState(''); const [access, setAccess] = useState('ro'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [rows, setRows] = useState(null); const [loadingList, setLoadingList] = useState(false); const [users, setUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); const userDropdownRef = useRef(null); const currentUser = getCurrentUser(); const projectUid: string | null = useMemo(() => { // Prefer stable uid if present; fallback to id string if needed // Share APIs require resource_uid; projects list provides uid return (project as any).uid || null; }, [project]); useEffect(() => { if (!isOpen) return; setSelectedUserId(''); setAccess('ro'); setError(null); setIsUserDropdownOpen(false); // Load users const loadUsers = async () => { setLoadingUsers(true); try { const res = await fetch(getApiPath('users'), { credentials: 'include', headers: { Accept: 'application/json' }, }); if (!res.ok) throw new Error('Failed to load users'); const data = await res.json(); // Filter out the current user from the list const filteredUsers = data.filter( (user: UserItem) => currentUser && user.email !== currentUser.email ); setUsers(filteredUsers); } catch (err: any) { setError(err.message || 'Failed to load users'); setUsers([]); } finally { setLoadingUsers(false); } }; loadUsers(); if (!projectUid) return; const load = async () => { setLoadingList(true); try { const data = await listShares('project', projectUid); setRows(data); } catch (err: any) { setError(err.message || 'Failed to load shares'); setRows([]); } finally { setLoadingList(false); } }; load(); }, [isOpen, projectUid]); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node) ) { setIsUserDropdownOpen(false); } }; if (isUserDropdownOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isUserDropdownOpen]); // Filter out users who already have access const availableUsers = useMemo(() => { if (!rows) return users; const usersWithAccessIds = new Set(rows.map((row) => row.user_id)); return users.filter((user) => !usersWithAccessIds.has(user.id)); }, [users, rows]); if (!isOpen) return null; const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!projectUid) { setError(t('errors.generic', 'Something went wrong')); return; } if (!selectedUserId) { setError(t('errors.selectUser', 'Please select a user')); return; } const selectedUser = availableUsers.find( (u) => u.id.toString() === selectedUserId ); if (!selectedUser) { setError(t('errors.userNotFound', 'User not found')); return; } setSubmitting(true); try { await grantShare({ resource_type: 'project', resource_uid: projectUid, target_user_email: selectedUser.email, access_level: access, }); setSelectedUserId(''); // Refresh shares list - this will automatically update availableUsers via useMemo const data = await listShares('project', projectUid); setRows(data); } catch (err: any) { setError(err.message || 'Failed to share'); } finally { setSubmitting(false); } }; const onRevoke = async (userId: number) => { if (!projectUid) return; try { await revokeShare('project', projectUid, userId); // Refresh shares list - this will automatically update availableUsers via useMemo const data = await listShares('project', projectUid); setRows(data); } catch (err: any) { setError(err.message || 'Failed to revoke share'); } }; const accessLabel = (al: AccessLevel | 'owner') => al === 'owner' ? t('shares.owner', 'Owner') : al === 'rw' ? t('shares.readWrite', 'Read & write') : t('shares.readOnly', 'Read only'); return (
e.stopPropagation()} >

{t('shares.shareProject', 'Share project')}

{project?.name}

{isUserDropdownOpen && (
{availableUsers.length === 0 ? (
{t( 'shares.noAvailableUsers', 'No users available to share with' )}
) : ( availableUsers.map((user) => { const displayName = user.name && user.surname ? `${user.name} ${user.surname}` : user.name ? user.name : user.email; const isSelected = selectedUserId === user.id.toString(); return ( ); }) )}
)}
{error && (
{error}
)}
{t('shares.currentShares', 'Users with access')}
{loadingList ? (
{t('common.loading', 'Loading...')}
) : !rows || rows.length === 0 ? (
{t('shares.noShares', 'Not shared yet')}
) : (
    {rows.map((r) => (
  • {r.email || `#${r.user_id}`}
    {accessLabel(r.access_level)}
    {r.is_owner ? ( {t('shares.owner', 'Owner')} ) : ( )}
  • ))}
)}
); }; export default ProjectShareModal;