// Workspace.jsx — Workspaces, Sub-Teams, Roles, Invites, Guest Access (() => { const { Card, Badge, Avatar, Switch } = window.TechyFuelOSDesignSystem_be0222; // Roles MUST match the backend validation (owner/admin/manager/member/guest). // A value the API rejects makes the PATCH 422 and the dropdown snap back to // its old value — which is exactly what "manager" did before it was added. const ROLES = [ { value: 'owner', label: 'Owner', desc: 'Full control — billing, delete workspace, all settings', color: '#7c3aed', icon: 'crown' }, { value: 'admin', label: 'Admin', desc: 'Manage members, teams, projects. No billing access', color: '#2563eb', icon: 'shield' }, { value: 'manager', label: 'Project Manager', desc: 'Runs projects, milestones and tasks. No payroll or invoices', color: '#0891b2', icon: 'briefcase' }, { value: 'team_lead', label: 'Team Lead', desc: 'Creates and assigns tasks for their team. No financials', color: '#d97706', icon: 'users' }, { value: 'member', label: 'Team Member', desc: 'Works on assigned projects and tasks only', color: '#0d9488', icon: 'user' }, { value: 'finance', label: 'Finance', desc: 'Invoices, costs and revenue. Limited project editing', color: '#be123c', icon: 'dollar-sign' }, { value: 'client', label: 'Client', desc: 'Sees only their own projects, via the client portal', color: '#65a30d', icon: 'eye' }, { value: 'guest', label: 'Guest', desc: 'Client-facing view only — limited read access', color: '#65a30d', icon: 'eye' }, ]; const TEAM_COLORS = ['#3b82f6','#8b5cf6','#f59e0b','#10b981','#ef4444','#f97316','#06b6d4','#ec4899']; const TEAM_ICONS = [ 'users','code','pen-tool','megaphone','crown','briefcase','bar-chart','globe', // Video editing / production 'video','film','clapperboard','scissors','camera','mic', // Sales / growth 'trending-up','dollar-sign','handshake','target','shopping-cart','badge-percent', // General 'palette','rocket','shield','wrench','headphones','phone','mail','heart','star','zap','package','pen-line', ]; const PERM_MATRIX = [ { feature: 'View projects & tasks', owner: true, admin: true, manager: true, member: true, guest: true }, { feature: 'Create & edit tasks', owner: true, admin: true, manager: true, member: true, guest: false }, { feature: 'Create projects', owner: true, admin: true, manager: true, member: true, guest: false }, { feature: 'Access Team Chat', owner: true, admin: true, manager: true, member: true, guest: false }, { feature: 'View Finance & Invoices', owner: true, admin: true, manager: true, member: false, guest: false }, { feature: 'Manage team members', owner: true, admin: true, manager: false, member: false, guest: false }, { feature: 'Manage integrations', owner: true, admin: true, manager: false, member: false, guest: false }, { feature: 'View Meta Ads & Reports', owner: true, admin: true, manager: true, member: true, guest: false }, { feature: 'Approve / reject deliverables',owner: true, admin: true, manager: true, member: false, guest: true }, { feature: 'Upload feedback files', owner: true, admin: true, manager: true, member: false, guest: true }, { feature: 'Billing & workspace settings', owner: true, admin: false, manager: false, member: false, guest: false }, { feature: 'Delete workspace', owner: true, admin: false, manager: false, member: false, guest: false }, ]; function uid() { return Math.random().toString(36).slice(2); } // ── Role Badge ───────────────────────────────────────────────────────────────── function RoleBadge({ role }) { const r = ROLES.find(x => x.value === role) || ROLES.find(x => x.value === 'member') || ROLES[0]; return ( {r.label} ); } // ── Invite Modal ─────────────────────────────────────────────────────────────── function InviteModal({ workspaceId, onClose, onInvited }) { const [tab, setTab] = React.useState('email'); // 'email' | 'link' const [email, setEmail] = React.useState(''); const [inviteName, setInviteName] = React.useState(''); const [role, setRole] = React.useState('member'); const [link, setLink] = React.useState(''); const [copied, setCopied] = React.useState(false); const [sending, setSending] = React.useState(false); const [sent, setSent] = React.useState(false); const [emailSent, setEmailSent] = React.useState(false); const [notice, setNotice] = React.useState(''); async function generateLink() { // The backend now returns the real join link (and sends the email if a // mailbox is connected). Use it instead of a made-up /join?token= URL. if (window.API?.createWorkspaceInvite) { try { const res = await window.API.createWorkspaceInvite({ workspace_id: workspaceId, role }); if (res?.invite_link) { setLink(res.invite_link); return; } if (res?.data?.token) { setLink(`${window.location.origin}/?invite=${res.data.token}`); return; } } catch {} } setLink(`${window.location.origin}/?invite=${uid() + uid()}`); } async function sendInvite() { if (!email.trim()) return; setSending(true); setNotice(''); try { // Create the team_members row with the chosen role *before* they sign // up, so the auth trigger links their account to this row (and this // role) instead of defaulting a "no invite found" signup to owner. if (window.API?.addTeamMember) { try { await window.API.addTeamMember({ name: (inviteName.trim() || email.trim().split('@')[0]), email: email.trim(), role, status: 'active' }); } catch (e) { // Already invited/exists with this email — fine, they're already set up. } } // Single call: the backend creates the invite, emails it if a mailbox is // connected, and always returns the join link so we can show it here. let res = null; if (window.API?.createWorkspaceInvite) { res = await window.API.createWorkspaceInvite({ workspace_id: workspaceId, email: email.trim(), role }); } if (res?.invite_link) setLink(res.invite_link); setEmailSent(!!res?.email_sent); if (!res?.email_sent) { // No mailbox connected (or send failed): fall back to the copyable link. setNotice(res?.email_error || 'Could not send the email — copy the link below and share it.'); } setSent(true); onInvited?.(); } catch (e) { setNotice('Something went wrong creating the invite. Please try again.'); } setSending(false); } function copyLink() { navigator.clipboard.writeText(link).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 2000); } return (
e.stopPropagation()}>

Invite to workspace

Members get access based on their role

{/* Tabs */}
{[['email','mail','Email invite'],['link','link','Invite link']].map(([t, icon, label]) => ( ))}
{/* Role selector */}
{ROLES.map(r => ( ))}
{tab === 'email' ? ( sent ? (
{emailSent ? '✅' : '🔗'}

{emailSent ? 'Invite sent!' : 'Invite created'}

{emailSent ?

{email} will receive an email invite

:

{notice || 'Share this link with them to join:'}

} {!emailSent && link && (
)}
) : (
setInviteName(e.target.value)} placeholder="e.g. Ahsan Ather" style={{ width: '100%', height: 40, padding: '0 14px', border: '1px solid var(--slate-200)', borderRadius: 9, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', boxSizing: 'border-box', marginBottom: 14 }} /> setEmail(e.target.value)} placeholder="colleague@company.com" type="email" onKeyDown={e => e.key === 'Enter' && sendInvite()} style={{ width: '100%', height: 40, padding: '0 14px', border: '1px solid var(--slate-200)', borderRadius: 9, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', boxSizing: 'border-box', marginBottom: 14 }} />
) ) : (
{!link ? ( ) : (

Link expires in 7 days · Anyone with this link can join as {role}

)}
)}
); } // ── Team Card ────────────────────────────────────────────────────────────────── function TeamCard({ team, allMembers, onAddMember, onRemoveMember, onDelete }) { const members = (team.team_memberships || []).map(m => m.team_members).filter(Boolean); const [hover, setHover] = React.useState(false); const [showAdd, setShowAdd] = React.useState(false); const [addId, setAddId] = React.useState(''); const available = allMembers.filter(m => !members.some(tm => tm?.id === m.id)); return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ border: '1px solid var(--slate-200)', borderRadius: 14, overflow: 'hidden', background: 'white', transition: 'box-shadow 0.15s', boxShadow: hover ? 'var(--shadow-md)' : 'none' }}>

{team.name}

{members.length} member{members.length !== 1 ? 's' : ''}

{members.map(m => m && (
{m.name?.split(' ')[0]}
))} {members.length === 0 &&

No members yet

}
{showAdd ? (
) : ( )}
); } // ── Permission Matrix ────────────────────────────────────────────────────────── function PermissionMatrix() { return (
{ROLES.map(r => ( ))} {PERM_MATRIX.map((row, i) => ( {ROLES.map(r => ( ))} ))}
Feature
{r.label}
{row.feature} {row[r.value] ? : }
); } // ── Main Workspace Screen ────────────────────────────────────────────────────── function Workspace() { useLucide(); const [tab, setTab] = React.useState('members'); // 'members' | 'teams' | 'invites' | 'permissions' const [workspaces, setWorkspaces] = React.useState([]); const [activeWs, setActiveWs] = React.useState(null); const [members, setMembers] = React.useState([]); const [teams, setTeams] = React.useState([]); const [invites, setInvites] = React.useState([]); const [loading, setLoading] = React.useState(true); const [showInviteModal, setShowInviteModal] = React.useState(false); const [showNewTeam, setShowNewTeam] = React.useState(false); const [showNewWs, setShowNewWs] = React.useState(false); const [newTeam, setNewTeam] = React.useState({ name: '', color: TEAM_COLORS[0], icon: 'users' }); const [newWs, setNewWs] = React.useState({ name: '', description: '' }); const [search, setSearch] = React.useState(''); const [roleFilter, setRoleFilter] = React.useState('all'); const [saving, setSaving] = React.useState(false); React.useEffect(() => { if (!window.API) { setLoading(false); return; } (async () => { try { const { data: ws } = await window.API.getWorkspaces(); const wsArr = ws || []; setWorkspaces(wsArr); const activeId = await window.API.getActiveWorkspaceId().catch(() => null); setActiveWs(wsArr.find(w => w.id === activeId) || wsArr[0] || null); } catch {} try { const { data } = await window.API.getTeam(); setMembers(data || []); } catch {} setLoading(false); })(); }, []); React.useEffect(() => { if (!activeWs || !window.API) return; (async () => { try { const { data } = await window.API.getTeams(activeWs.id); setTeams(data || []); } catch {} try { const { data } = await window.API.getWorkspaceInvites(activeWs.id); setInvites(data || []); } catch {} })(); }, [activeWs?.id]); async function createWorkspace() { if (!newWs.name.trim() || !window.API) return; setSaving(true); try { const { data, error } = await window.API.createWorkspace({ name: newWs.name.trim(), description: newWs.description }); if (error) { alert('Could not create workspace:\n\n' + (error.message || JSON.stringify(error))); return; } // Creating a workspace makes it the new active one server-side — // reload so every screen refetches data scoped to it. window.location.reload(); } catch (err) { alert('Error: ' + (err.message || JSON.stringify(err))); } finally { setSaving(false); } } async function switchWorkspace(workspaceId) { if (!workspaceId || workspaceId === activeWs?.id || !window.API) return; try { const { error } = await window.API.switchWorkspace(workspaceId); if (error) { alert('Could not switch workspace:\n\n' + (error.message || JSON.stringify(error))); return; } window.location.reload(); } catch (err) { alert('Error: ' + (err.message || JSON.stringify(err))); } } // Delete workspace — owner or super-admin only, with type-to-confirm. const authUser = (() => { try { return JSON.parse(localStorage.getItem('tf_auth_user') || '{}'); } catch { return {}; } })(); const isSuperAdmin = !!authUser.is_super_admin; const canDeleteWs = isSuperAdmin || window.TFMyRole === 'owner'; const [showDeleteWs, setShowDeleteWs] = React.useState(false); const [deleteConfirm, setDeleteConfirm] = React.useState(''); const [deleting, setDeleting] = React.useState(false); async function deleteWorkspace() { if (!activeWs || !window.API || !window.API.deleteWorkspace) return; if (deleteConfirm.trim() !== activeWs.name) return; setDeleting(true); try { const { error } = await window.API.deleteWorkspace(activeWs.id); if (error) { alert('Could not delete workspace:\n\n' + (error.message || JSON.stringify(error))); setDeleting(false); return; } window.location.reload(); } catch (err) { alert('Error: ' + (err.message || JSON.stringify(err))); setDeleting(false); } } async function createTeam() { if (!newTeam.name.trim() || !activeWs || !window.API) return; setSaving(true); try { const { data } = await window.API.createTeam({ ...newTeam, workspace_id: activeWs.id }); if (data) { setTeams(prev => [...prev, { ...data, team_memberships: [] }]); setNewTeam({ name: '', color: TEAM_COLORS[0], icon: 'users' }); setShowNewTeam(false); } } catch {} setSaving(false); } async function deleteTeam(id) { if (!window.API) return; try { await window.API.deleteTeam(id); setTeams(prev => prev.filter(t => t.id !== id)); } catch {} } async function addTeamMember(teamId, memberId) { if (!window.API) return; try { await window.API.addTeamMembership({ team_id: teamId, member_id: memberId }); const member = members.find(m => m.id === memberId); setTeams(prev => prev.map(t => t.id === teamId ? { ...t, team_memberships: [...(t.team_memberships || []), { member_id: memberId, team_members: member }] } : t)); } catch {} } async function removeMember(teamId, memberId) { if (!window.API) return; try { await window.API.removeTeamMembership(teamId, memberId); setTeams(prev => prev.map(t => t.id === teamId ? { ...t, team_memberships: (t.team_memberships || []).filter(m => m.member_id !== memberId) } : t)); } catch {} } async function updateMemberRole(memberId, newRole) { if (!window.API) return; try { await window.API.updateTeamMember(memberId, { role: newRole }); setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: newRole } : m)); } catch {} } // Rename a member — invited-by-email members default to the email prefix as // their name, which reads badly; let an owner/admin set a proper name. async function renameMember(m) { if (!window.API) return; const next = window.prompt('Edit member name:', m.name || ''); if (next === null) return; const name = next.trim(); if (!name || name === m.name) return; try { await window.API.updateTeamMember(m.id, { name }); setMembers(prev => prev.map(x => x.id === m.id ? { ...x, name } : x)); } catch { alert('Could not update the name. Please try again.'); } } async function revokeInvite(id) { if (!window.API) return; try { await window.API.revokeInvite(id); setInvites(prev => prev.filter(i => i.id !== id)); } catch {} } const filteredMembers = members.filter(m => { const matchSearch = !search || m.name?.toLowerCase().includes(search.toLowerCase()) || m.email?.toLowerCase().includes(search.toLowerCase()); const matchRole = roleFilter === 'all' || m.role === roleFilter || m.access_level === roleFilter; return matchSearch && matchRole; }); const TABS = [ { id: 'members', label: 'Members', icon: 'users', count: members.length }, { id: 'teams', label: 'Sub-Teams', icon: 'layout-grid', count: teams.length }, { id: 'invites', label: 'Invites', icon: 'mail', count: invites.filter(i => !i.accepted_at).length }, { id: 'permissions', label: 'Permissions', icon: 'shield' }, ]; if (loading) return
Loading workspace...
; return (
{/* Header */}

Workspace

Manage members, teams, and access for your workspace

{/* Workspace switcher */}

{activeWs?.name || 'No workspace'}

{activeWs?.plan || 'free'} plan

{workspaces.length > 1 && ( )} {canDeleteWs && activeWs && ( )}
{/* Delete workspace — owner / super-admin only, type-to-confirm */} {showDeleteWs && (
!deleting && setShowDeleteWs(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
e.stopPropagation()} style={{ background: 'white', borderRadius: 14, padding: 22, width: '100%', maxWidth: 440, boxShadow: 'var(--shadow-xl)' }}>

Delete workspace

This permanently deletes {activeWs?.name} and removes all its members. This cannot be undone. Type the workspace name to confirm.

setDeleteConfirm(e.target.value)} placeholder={activeWs?.name} style={{ width: '100%', height: 40, padding: '0 12px', border: '1px solid var(--slate-200)', borderRadius: 9, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', boxSizing: 'border-box', marginBottom: 16 }} />
)} {/* Stats row */}
{[ { label: 'Total members', value: members.length, icon: 'users', color: 'var(--blue-600)' }, { label: 'Sub-teams', value: teams.length, icon: 'layout-grid', color: 'var(--purple-600)' }, { label: 'Pending invites', value: invites.filter(i => !i.accepted_at).length, icon: 'mail', color: 'var(--amber-600)' }, { label: 'Guest accounts', value: members.filter(m => m.role === 'guest' || m.access_level === 'guest').length, icon: 'eye', color: 'var(--green-600)' }, ].map(s => (

{s.value}

{s.label}

))}
{/* Tabs */}
{TABS.map(t => ( ))}
{/* ── Members tab ── */} {tab === 'members' && (
setSearch(e.target.value)} placeholder="Search members..." style={{ width: '100%', paddingLeft: 32, height: 38, border: '1px solid var(--slate-200)', borderRadius: 9, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', boxSizing: 'border-box' }} />
{/* Header */}
MemberDepartmentRoleAccess
{filteredMembers.map((m, i) => (

{m.name}

{m.email || '—'}

{m.department || '—'}
))} {filteredMembers.length === 0 && (
No members found
)}
)} {/* ── Sub-Teams tab ── */} {tab === 'teams' && (
{/* New team form */} {showNewTeam && (

Create sub-team

setNewTeam(t => ({ ...t, name: e.target.value }))} placeholder="Team name (e.g. Design, Dev...)" style={{ flex: 1, minWidth: 200, height: 38, padding: '0 12px', border: '1px solid var(--slate-200)', borderRadius: 8, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none' }} />
{TEAM_COLORS.map(c => (
{/* Live preview of the chosen icon in the chosen colour. */}
)}
{teams.map(team => ( ))} {teams.length === 0 && (

No sub-teams yet

Create teams like Design, Dev, Marketing to organize your members

)}
)} {/* ── Invites tab ── */} {tab === 'invites' && (
{invites.length === 0 ? (

No pending invites

Invite your team via email or share an invite link

) : invites.map((inv, i) => (

{inv.email || 'Link invite'}

Invited by {inv.team_members?.name || 'Unknown'} · {new Date(inv.created_at).toLocaleDateString()} {inv.expires_at && ` · expires ${new Date(inv.expires_at).toLocaleDateString()}`}

{inv.accepted_at ? 'Accepted' : 'Pending'} {!inv.accepted_at && ( )}
))}
)} {/* ── Permissions tab ── */} {tab === 'permissions' && (

Role Permissions

What each role can do in this workspace. Guest = client-facing limited access.

Guest (Client) Access

Clients invited as Guest see only the Client Portal — project progress, invoices, and deliverables pending their approval. They cannot access Team Chat, Finance details, or any internal workspace data.

)} {/* New Workspace Modal */} {showNewWs && (
setShowNewWs(false)}>
e.stopPropagation()}>

New workspace

setNewWs(w => ({ ...w, name: e.target.value }))} placeholder="e.g. Brand Studio, Client X..." style={{ width: '100%', height: 40, padding: '0 14px', border: '1px solid var(--slate-200)', borderRadius: 9, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', boxSizing: 'border-box', marginBottom: 12 }} /> setNewWs(w => ({ ...w, description: e.target.value }))} placeholder="What this workspace is for..." style={{ width: '100%', height: 38, padding: '0 14px', border: '1px solid var(--slate-200)', borderRadius: 9, fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', boxSizing: 'border-box', marginBottom: 20 }} />
)} {/* Invite Modal */} {showInviteModal && ( setShowInviteModal(false)} onInvited={() => { /* refresh invites */ }} /> )}
); } Object.assign(window, { Workspace }); })();