import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Project } from '../../entities/Project'; import { AccessLevel, grantShare, listShares, revokeShare, } from '../../utils/sharesService'; 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; } const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value); const ProjectShareModal: React.FC = ({ isOpen, onClose, project, }) => { const { t } = useTranslation(); const [targetEmail, setTargetEmail] = 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 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; setTargetEmail(''); setAccess('ro'); setError(null); 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]); 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 (!isValidEmail(targetEmail)) { setError(t('errors.invalidEmail', 'Invalid email address')); return; } setSubmitting(true); try { await grantShare({ resource_type: 'project', resource_uid: projectUid, target_user_email: targetEmail, access_level: access, }); setTargetEmail(''); // refresh list 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); 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('projects.shareProject', 'Share project')}

{project?.name}

setTargetEmail(e.target.value)} className="w-full rounded border px-3 py-2 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100" placeholder={ t( 'shares.emailPlaceholder', 'name@example.com' )! } />
{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;