cesjavi commited on
Commit
fd19d92
·
1 Parent(s): 8f4a599

Phase 8: Enterprise Multi-Tenancy (Teams, Audit Logs, Team-Aware Projects)

Browse files
frontend/src/App.tsx CHANGED
@@ -11,7 +11,9 @@ import {
11
  ShoppingBag,
12
  Volume2,
13
  Box,
14
- Activity
 
 
15
  } from 'lucide-react';
16
  import { motion, AnimatePresence } from 'framer-motion';
17
  import { useAuth } from './context/useAuth';
@@ -28,12 +30,14 @@ import ProjectDetail from './components/ProjectDetail';
28
  import AgentsView from './components/AgentsView';
29
  import AgentConsole from './components/AgentConsole';
30
  import SplashScreen from './components/SplashScreen';
 
 
31
  import { useEffect } from 'react';
32
  import { getUiMode, saveUiMode } from './services/uiMode';
33
  import type { UiMode } from './services/uiMode';
34
  import { getAppVersion } from './services/runtimeConfig';
35
 
36
- type AppTab = 'dashboard' | 'project-detail' | 'agents' | 'marketplace' | 'debate' | 'voice' | 'spatial' | 'monitoring' | 'new-project' | 'settings';
37
 
38
  const App: React.FC = () => {
39
  const { session, loading, signOut, profile, user } = useAuth();
@@ -92,16 +96,22 @@ const App: React.FC = () => {
92
  exit={{ x: -280 }}
93
  className="glass-panel app-sidebar"
94
  style={{
95
- width: '280px',
96
- margin: 'var(--space-md)',
97
  display: 'flex',
98
- flexDirection: 'column',
99
- zIndex: 100
100
  }}
101
  >
102
  <div className="sidebar-brand">
103
- <Bot size={32} color="var(--accent)" />
104
- <h1 style={{ fontSize: '1.5rem', margin: 0 }}>Aubm</h1>
 
 
 
 
 
 
 
 
 
105
  </div>
106
 
107
  <nav className="sidebar-nav">
@@ -155,6 +165,18 @@ const App: React.FC = () => {
155
  active={activeTab === 'monitoring'}
156
  onClick={() => navigateTo('monitoring')}
157
  />
 
 
 
 
 
 
 
 
 
 
 
 
158
  </>
159
  )}
160
  <SidebarItem
@@ -237,6 +259,8 @@ const App: React.FC = () => {
237
  />
238
  )}
239
  {activeTab === 'monitoring' && uiMode === 'expert' && <MonitoringView />}
 
 
240
  {activeTab === 'new-project' && <NewProject uiMode={uiMode} onCreated={() => navigateTo('dashboard')} />}
241
  {activeTab === 'settings' && <SettingsView uiMode={uiMode} onUiModeChange={updateUiMode} />}
242
  </section>
 
11
  ShoppingBag,
12
  Volume2,
13
  Box,
14
+ Activity,
15
+ Users,
16
+ ShieldCheck
17
  } from 'lucide-react';
18
  import { motion, AnimatePresence } from 'framer-motion';
19
  import { useAuth } from './context/useAuth';
 
30
  import AgentsView from './components/AgentsView';
31
  import AgentConsole from './components/AgentConsole';
32
  import SplashScreen from './components/SplashScreen';
33
+ import TeamsView from './components/TeamsView';
34
+ import AuditView from './components/AuditView';
35
  import { useEffect } from 'react';
36
  import { getUiMode, saveUiMode } from './services/uiMode';
37
  import type { UiMode } from './services/uiMode';
38
  import { getAppVersion } from './services/runtimeConfig';
39
 
40
+ type AppTab = 'dashboard' | 'project-detail' | 'agents' | 'marketplace' | 'debate' | 'voice' | 'spatial' | 'monitoring' | 'teams' | 'audit' | 'new-project' | 'settings';
41
 
42
  const App: React.FC = () => {
43
  const { session, loading, signOut, profile, user } = useAuth();
 
96
  exit={{ x: -280 }}
97
  className="glass-panel app-sidebar"
98
  style={{
 
 
99
  display: 'flex',
100
+ flexDirection: 'column'
 
101
  }}
102
  >
103
  <div className="sidebar-brand">
104
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
105
+ <Bot size={32} color="var(--accent)" />
106
+ <h1 style={{ fontSize: '1.5rem', margin: 0 }}>Aubm</h1>
107
+ </div>
108
+ <button
109
+ className="mobile-only sidebar-close"
110
+ onClick={() => setIsSidebarOpen(false)}
111
+ style={{ color: 'var(--text-dim)', padding: '4px' }}
112
+ >
113
+ <X size={24} />
114
+ </button>
115
  </div>
116
 
117
  <nav className="sidebar-nav">
 
165
  active={activeTab === 'monitoring'}
166
  onClick={() => navigateTo('monitoring')}
167
  />
168
+ <SidebarItem
169
+ icon={<Users size={20} />}
170
+ label="Teams"
171
+ active={activeTab === 'teams'}
172
+ onClick={() => navigateTo('teams')}
173
+ />
174
+ <SidebarItem
175
+ icon={<ShieldCheck size={20} />}
176
+ label="Audit Logs"
177
+ active={activeTab === 'audit'}
178
+ onClick={() => navigateTo('audit')}
179
+ />
180
  </>
181
  )}
182
  <SidebarItem
 
259
  />
260
  )}
261
  {activeTab === 'monitoring' && uiMode === 'expert' && <MonitoringView />}
262
+ {activeTab === 'teams' && uiMode === 'expert' && <TeamsView />}
263
+ {activeTab === 'audit' && uiMode === 'expert' && <AuditView />}
264
  {activeTab === 'new-project' && <NewProject uiMode={uiMode} onCreated={() => navigateTo('dashboard')} />}
265
  {activeTab === 'settings' && <SettingsView uiMode={uiMode} onUiModeChange={updateUiMode} />}
266
  </section>
frontend/src/components/AuditView.tsx ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ ShieldCheck,
4
+ Search,
5
+ Filter,
6
+ Download,
7
+ Calendar,
8
+ User,
9
+ Bot,
10
+ FileText,
11
+ ChevronDown,
12
+ ChevronUp,
13
+ RefreshCw,
14
+ ExternalLink
15
+ } from 'lucide-react';
16
+ import { supabase } from '../services/supabase';
17
+
18
+ interface AuditLog {
19
+ id: string;
20
+ created_at: string;
21
+ user_id: string | null;
22
+ action: string;
23
+ agent_id: string | null;
24
+ task_id: string | null;
25
+ metadata: any;
26
+ profiles?: {
27
+ full_name: string | null;
28
+ email: string | null;
29
+ };
30
+ agents?: {
31
+ name: string | null;
32
+ };
33
+ tasks?: {
34
+ title: string | null;
35
+ };
36
+ }
37
+
38
+ const AuditView: React.FC = () => {
39
+ const [logs, setLogs] = useState<AuditLog[]>([]);
40
+ const [loading, setLoading] = useState(true);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [searchTerm, setSearchTerm] = useState('');
43
+ const [expandedLog, setExpandedLog] = useState<string | null>(null);
44
+ const [page, setPage] = useState(0);
45
+ const pageSize = 50;
46
+
47
+ useEffect(() => {
48
+ fetchLogs();
49
+ }, [page]);
50
+
51
+ const fetchLogs = async () => {
52
+ setLoading(true);
53
+ try {
54
+ const { data, error: fetchError } = await supabase
55
+ .from('audit_logs')
56
+ .select(`
57
+ *,
58
+ profiles:user_id(full_name, email),
59
+ agents:agent_id(name),
60
+ tasks:task_id(title)
61
+ `)
62
+ .order('created_at', { ascending: false })
63
+ .range(page * pageSize, (page + 1) * pageSize - 1);
64
+
65
+ if (fetchError) throw fetchError;
66
+ setLogs(data || []);
67
+ } catch (err: any) {
68
+ setError(err.message);
69
+ } finally {
70
+ setLoading(false);
71
+ }
72
+ };
73
+
74
+ const exportCSV = () => {
75
+ const headers = ['Timestamp', 'Action', 'User', 'Agent', 'Task', 'Metadata'];
76
+ const rows = logs.map(log => [
77
+ log.created_at,
78
+ log.action,
79
+ log.profiles?.email || 'System',
80
+ log.agents?.name || 'N/A',
81
+ log.tasks?.title || 'N/A',
82
+ JSON.stringify(log.metadata)
83
+ ]);
84
+
85
+ const csvContent = [headers, ...rows].map(e => e.join(',')).join('\n');
86
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
87
+ const url = URL.createObjectURL(blob);
88
+ const link = document.createElement('a');
89
+ link.setAttribute('href', url);
90
+ link.setAttribute('download', `aubm_audit_logs_${new Date().toISOString()}.csv`);
91
+ link.click();
92
+ };
93
+
94
+ const filteredLogs = logs.filter(log =>
95
+ log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
96
+ (log.profiles?.email || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
97
+ (log.agents?.name || '').toLowerCase().includes(searchTerm.toLowerCase())
98
+ );
99
+
100
+ const formatTimestamp = (ts: string) => {
101
+ return new Date(ts).toLocaleString();
102
+ };
103
+
104
+ const getActionBadgeColor = (action: string) => {
105
+ if (action.includes('error') || action.includes('failed')) return 'var(--danger)';
106
+ if (action.includes('created') || action.includes('added')) return 'var(--success)';
107
+ if (action.includes('approved')) return 'var(--accent)';
108
+ return 'var(--text-dim)';
109
+ };
110
+
111
+ return (
112
+ <div className="audit-view">
113
+ <header className="view-header">
114
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
115
+ <ShieldCheck size={32} color="var(--accent)" />
116
+ <div>
117
+ <h2 style={{ margin: 0 }}>Audit Explorer</h2>
118
+ <p style={{ color: 'var(--text-dim)', margin: 0 }}>Track system actions and governance events</p>
119
+ </div>
120
+ </div>
121
+ <div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
122
+ <button className="btn btn-glass" onClick={() => fetchLogs()} disabled={loading}>
123
+ <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
124
+ Refresh
125
+ </button>
126
+ <button className="btn btn-glass" onClick={exportCSV}>
127
+ <Download size={18} />
128
+ Export CSV
129
+ </button>
130
+ </div>
131
+ </header>
132
+
133
+ <div className="glass-panel" style={{ marginTop: 'var(--space-lg)', padding: 'var(--space-md)' }}>
134
+ <div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-md)' }}>
135
+ <div className="search-bar" style={{ flex: 1, position: 'relative' }}>
136
+ <Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-dim)' }} />
137
+ <input
138
+ type="text"
139
+ className="glass-input"
140
+ placeholder="Search actions, users, or agents..."
141
+ value={searchTerm}
142
+ onChange={(e) => setSearchTerm(e.target.value)}
143
+ style={{ width: '100%', paddingLeft: '40px' }}
144
+ />
145
+ </div>
146
+ <button className="btn btn-glass">
147
+ <Filter size={18} />
148
+ Filters
149
+ </button>
150
+ </div>
151
+
152
+ <div className="audit-table-container" style={{ overflowX: 'auto' }}>
153
+ <table className="audit-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
154
+ <thead>
155
+ <tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)', textAlign: 'left' }}>
156
+ <th style={{ padding: 'var(--space-md)', color: 'var(--text-dim)', fontSize: '0.85rem' }}>Timestamp</th>
157
+ <th style={{ padding: 'var(--space-md)', color: 'var(--text-dim)', fontSize: '0.85rem' }}>Action</th>
158
+ <th style={{ padding: 'var(--space-md)', color: 'var(--text-dim)', fontSize: '0.85rem' }}>Actor</th>
159
+ <th style={{ padding: 'var(--space-md)', color: 'var(--text-dim)', fontSize: '0.85rem' }}>Context</th>
160
+ <th style={{ padding: 'var(--space-md)', color: 'var(--text-dim)', fontSize: '0.85rem' }}></th>
161
+ </tr>
162
+ </thead>
163
+ <tbody>
164
+ {filteredLogs.map(log => (
165
+ <React.Fragment key={log.id}>
166
+ <tr
167
+ style={{
168
+ borderBottom: '1px solid rgba(255,255,255,0.05)',
169
+ cursor: 'pointer',
170
+ background: expandedLog === log.id ? 'rgba(255,255,255,0.05)' : 'transparent'
171
+ }}
172
+ onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
173
+ >
174
+ <td style={{ padding: 'var(--space-md)', whiteSpace: 'nowrap', fontSize: '0.9rem' }}>
175
+ <Calendar size={14} style={{ marginRight: '8px', opacity: 0.5 }} />
176
+ {formatTimestamp(log.created_at)}
177
+ </td>
178
+ <td style={{ padding: 'var(--space-md)' }}>
179
+ <span style={{
180
+ padding: '2px 8px',
181
+ borderRadius: '12px',
182
+ fontSize: '0.75rem',
183
+ fontWeight: 600,
184
+ background: 'rgba(255,255,255,0.1)',
185
+ color: getActionBadgeColor(log.action),
186
+ textTransform: 'uppercase'
187
+ }}>
188
+ {log.action.replace(/_/g, ' ')}
189
+ </span>
190
+ </td>
191
+ <td style={{ padding: 'var(--space-md)', fontSize: '0.9rem' }}>
192
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
193
+ <User size={14} style={{ opacity: 0.5 }} />
194
+ {log.profiles?.email || <span style={{ color: 'var(--text-dim)', fontSize: '0.8rem' }}>System</span>}
195
+ </div>
196
+ </td>
197
+ <td style={{ padding: 'var(--space-md)', fontSize: '0.9rem' }}>
198
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
199
+ {log.agents && (
200
+ <div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--accent)', fontSize: '0.8rem' }}>
201
+ <Bot size={12} />
202
+ {log.agents.name}
203
+ </div>
204
+ )}
205
+ {log.tasks && (
206
+ <div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--text-dim)', fontSize: '0.8rem' }}>
207
+ <FileText size={12} />
208
+ {log.tasks.title}
209
+ </div>
210
+ )}
211
+ {!log.agents && !log.tasks && <span style={{ color: 'var(--text-dim)', fontSize: '0.8rem' }}>-</span>}
212
+ </div>
213
+ </td>
214
+ <td style={{ padding: 'var(--space-md)', textAlign: 'right' }}>
215
+ {expandedLog === log.id ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
216
+ </td>
217
+ </tr>
218
+ {expandedLog === log.id && (
219
+ <tr>
220
+ <td colSpan={5} style={{ padding: 'var(--space-md)', background: 'rgba(0,0,0,0.3)' }}>
221
+ <div style={{ display: 'flex', gap: 'var(--space-lg)' }}>
222
+ <div style={{ flex: 1 }}>
223
+ <h5 style={{ margin: '0 0 8px 0', color: 'var(--text-dim)' }}>Metadata</h5>
224
+ <pre style={{
225
+ background: 'rgba(0,0,0,0.2)',
226
+ padding: 'var(--space-md)',
227
+ borderRadius: '8px',
228
+ fontSize: '0.8rem',
229
+ maxHeight: '200px',
230
+ overflowY: 'auto'
231
+ }}>
232
+ {JSON.stringify(log.metadata, null, 2)}
233
+ </pre>
234
+ </div>
235
+ <div style={{ width: '200px' }}>
236
+ <h5 style={{ margin: '0 0 8px 0', color: 'var(--text-dim)' }}>Quick Links</h5>
237
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
238
+ {log.task_id && (
239
+ <button className="btn btn-glass btn-sm" style={{ width: '100%', justifyContent: 'flex-start' }}>
240
+ <ExternalLink size={14} />
241
+ View Task
242
+ </button>
243
+ )}
244
+ {log.metadata?.project_id && (
245
+ <button className="btn btn-glass btn-sm" style={{ width: '100%', justifyContent: 'flex-start' }}>
246
+ <ExternalLink size={14} />
247
+ View Project
248
+ </button>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </td>
254
+ </tr>
255
+ )}
256
+ </React.Fragment>
257
+ ))}
258
+ </tbody>
259
+ </table>
260
+ </div>
261
+
262
+ <div className="pagination" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'var(--space-lg)', padding: '0 var(--space-md)' }}>
263
+ <div style={{ fontSize: '0.9rem', color: 'var(--text-dim)' }}>
264
+ Showing {filteredLogs.length} logs on this page
265
+ </div>
266
+ <div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
267
+ <button
268
+ className="btn btn-glass btn-sm"
269
+ onClick={() => setPage(p => Math.max(0, p - 1))}
270
+ disabled={page === 0 || loading}
271
+ >
272
+ Previous
273
+ </button>
274
+ <span style={{ display: 'flex', alignItems: 'center', padding: '0 var(--space-md)', fontWeight: 600 }}>{page + 1}</span>
275
+ <button
276
+ className="btn btn-glass btn-sm"
277
+ onClick={() => setPage(p => p + 1)}
278
+ disabled={logs.length < pageSize || loading}
279
+ >
280
+ Next
281
+ </button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ );
287
+ };
288
+
289
+ export default AuditView;
frontend/src/components/NewProject.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useRef, useState } from 'react';
2
- import { CheckCircle2, FileText, Link2, Paperclip, PlusCircle, StickyNote, Trash2 } from 'lucide-react';
3
  import { motion } from 'framer-motion';
4
  import { supabase } from '../services/supabase';
5
  import { useAuth } from '../context/useAuth';
@@ -80,6 +80,37 @@ const buildContextPayload = (baseContext: string, sources: ProjectSource[]) => {
80
  return sections.join('\n\n').trim();
81
  };
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMode, onCreated }) => {
84
  const { user } = useAuth();
85
  const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -92,9 +123,37 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
92
  const [noteContent, setNoteContent] = useState('');
93
  const [sources, setSources] = useState<ProjectSource[]>([]);
94
  const [isPublic, setIsPublic] = useState(false);
 
 
95
  const [showAdvancedSources, setShowAdvancedSources] = useState(uiMode === 'expert');
 
96
  const [saving, setSaving] = useState(false);
97
  const [message, setMessage] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  const appendSource = (source: ProjectSource) => {
100
  setSources((current) => [...current, source]);
@@ -191,6 +250,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
191
  description,
192
  context: contextPayload,
193
  owner_id: user.id,
 
194
  is_public: isPublic,
195
  status: 'active'
196
  });
@@ -207,6 +267,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
207
  setNoteContent('');
208
  setSources([]);
209
  setIsPublic(false);
 
210
  setMessage('Project created successfully.');
211
  window.setTimeout(() => onCreated?.(), 500);
212
  }
@@ -229,96 +290,137 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
229
  </div>
230
 
231
  <form className="glass-panel project-form" onSubmit={handleSubmit}>
232
- {uiMode === 'guided' && (
233
- <div className="default-agent-panel">
234
- <strong>Guided flow</strong>
235
- <p style={{ color: 'var(--text-dim)' }}>
236
- 1. Name the project. 2. Describe the outcome. 3. Add context and sources. 4. Create the workspace and generate the plan from the project page.
237
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  </div>
239
  )}
240
 
241
- <label>
242
- <span>Project Name</span>
243
- <input value={name} onChange={(event) => setName(event.target.value)} required placeholder="Customer onboarding automation" />
244
- </label>
 
 
 
 
 
 
 
245
 
246
- <label>
247
- <span>Description</span>
248
- <textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What should this project accomplish?" rows={4} />
249
- </label>
 
 
 
 
 
 
 
250
 
251
- <label>
252
- <span>Context</span>
253
- <textarea value={context} onChange={(event) => setContext(event.target.value)} placeholder="Business constraints, preferred tone, source links, acceptance criteria..." rows={6} />
254
- </label>
 
 
 
 
 
 
 
255
 
256
- <div className="default-agent-panel" style={{ gap: 'var(--space-lg)' }}>
 
257
  <div className="settings-section-title">
258
  <Paperclip size={20} color="var(--accent)" />
259
  <h3>Project Sources</h3>
260
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- {uiMode === 'guided' && !showAdvancedSources && (
263
- <>
264
- <p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}>
265
- Add the links, notes, or files that should shape the plan. Skip this if the description already contains enough context.
266
- </p>
267
- <button className="btn btn-glass" type="button" onClick={() => setShowAdvancedSources(true)}>
268
- <Paperclip size={16} />
269
- Add Sources
270
- </button>
271
- </>
272
- )}
 
273
 
274
- {(uiMode === 'expert' || showAdvancedSources) && (
275
- <>
276
- <div className="responsive-two-col">
277
- <label>
278
- <span>Link Label</span>
279
- <input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} placeholder="Market report" />
280
- </label>
281
- <label>
282
- <span>Link URL</span>
283
- <input value={sourceUrl} onChange={(event) => setSourceUrl(event.target.value)} placeholder="https://..." />
284
- </label>
285
- </div>
286
- <button className="btn btn-glass" type="button" onClick={handleAddLink}>
287
- <Link2 size={16} />
288
- Add Link
289
- </button>
290
 
291
- <label>
292
- <span>Quick Note</span>
293
- <input value={noteLabel} onChange={(event) => setNoteLabel(event.target.value)} placeholder="Stakeholder note" />
294
- </label>
295
- <label>
296
- <span>Note Content</span>
297
- <textarea value={noteContent} onChange={(event) => setNoteContent(event.target.value)} rows={3} placeholder="Paste text, markdown, requirements, or snippets..." />
298
- </label>
299
- <button className="btn btn-glass" type="button" onClick={handleAddNote}>
300
- <StickyNote size={16} />
301
- Add Text
302
- </button>
303
-
304
- <input
305
- ref={fileInputRef}
306
- type="file"
307
- multiple
308
- accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.csv,.json,.rtf"
309
- onChange={handleFileSelection}
310
- style={{ display: 'none' }}
311
- />
312
- <button className="btn btn-glass" type="button" onClick={() => fileInputRef.current?.click()}>
313
- <Paperclip size={16} />
314
- Add Files
315
- </button>
316
-
317
- <p style={{ color: 'var(--text-dim)', fontSize: '0.85rem', marginTop: '-0.25rem' }}>
318
- Text and markdown files are embedded into project context. PDF, Word, and Excel files are stored as named references in the context.
319
- </p>
320
- </>
321
- )}
322
 
323
  {sources.length > 0 && (
324
  <div className="task-list">
@@ -329,7 +431,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
329
  <p>
330
  {source.kind === 'link' && source.url}
331
  {source.kind === 'note' && source.content}
332
- {source.kind === 'file' && `${source.fileName} · ${formatFileSize(source.size)}${source.extracted ? ' · text imported' : ' · reference only'}`}
333
  </p>
334
  </div>
335
  <button className="btn btn-glass btn-sm" type="button" onClick={() => removeSource(source.id)}>
@@ -341,12 +443,68 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
341
  </div>
342
  )}
343
  </div>
 
344
 
345
- {uiMode === 'expert' && (
346
- <label className="toggle-row">
347
- <input type="checkbox" checked={isPublic} onChange={(event) => setIsPublic(event.target.checked)} />
348
- <span>Make project visible to authenticated users</span>
349
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  )}
351
 
352
  {message && (
@@ -356,10 +514,40 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
356
  </div>
357
  )}
358
 
359
- <button className="btn btn-primary" type="submit" disabled={saving}>
360
- <PlusCircle size={18} />
361
- {saving ? 'Creating...' : 'Create Project'}
362
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  </form>
364
  </motion.div>
365
  );
 
1
  import React, { useRef, useState } from 'react';
2
+ import { ArrowLeft, ArrowRight, CheckCircle2, FileText, Link2, Paperclip, PlusCircle, StickyNote, Trash2 } from 'lucide-react';
3
  import { motion } from 'framer-motion';
4
  import { supabase } from '../services/supabase';
5
  import { useAuth } from '../context/useAuth';
 
80
  return sections.join('\n\n').trim();
81
  };
82
 
83
+ const FieldHelp: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
84
+ <aside className="field-help">
85
+ <strong>{title}</strong>
86
+ <p>{children}</p>
87
+ </aside>
88
+ );
89
+
90
+ const wizardSteps = [
91
+ {
92
+ title: 'Basics',
93
+ description: 'Name the workspace and describe the business outcome. Agents use this to understand what success looks like.'
94
+ },
95
+ {
96
+ title: 'Context',
97
+ description: 'Add constraints, acceptance criteria, tone, risks, and assumptions. Good context reduces generic task plans.'
98
+ },
99
+ {
100
+ title: 'Sources',
101
+ description: 'Attach links, notes, or files that should influence planning. This step is optional when the description is enough.'
102
+ },
103
+ {
104
+ title: 'Review',
105
+ description: 'Check the setup before creating the project. You will generate tasks from the project page after creation.'
106
+ }
107
+ ];
108
+
109
+ const expertAccessStep = {
110
+ title: 'Workspace',
111
+ description: 'Decide whether this project is personal or belongs to a team workspace.'
112
+ };
113
+
114
  const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMode, onCreated }) => {
115
  const { user } = useAuth();
116
  const fileInputRef = useRef<HTMLInputElement | null>(null);
 
123
  const [noteContent, setNoteContent] = useState('');
124
  const [sources, setSources] = useState<ProjectSource[]>([]);
125
  const [isPublic, setIsPublic] = useState(false);
126
+ const [teams, setTeams] = useState<{ id: string; name: string }[]>([]);
127
+ const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
128
  const [showAdvancedSources, setShowAdvancedSources] = useState(uiMode === 'expert');
129
+ const [wizardStep, setWizardStep] = useState(0);
130
  const [saving, setSaving] = useState(false);
131
  const [message, setMessage] = useState<string | null>(null);
132
+
133
+ React.useEffect(() => {
134
+ if (uiMode === 'expert') {
135
+ fetchTeams();
136
+ }
137
+ }, [uiMode]);
138
+
139
+ const fetchTeams = async () => {
140
+ try {
141
+ const { data, error } = await supabase.from('teams').select('id, name');
142
+ if (error) throw error;
143
+ setTeams(data || []);
144
+ } catch (err) {
145
+ console.error('Failed to fetch teams:', err);
146
+ }
147
+ };
148
+ const isWizard = true;
149
+ const projectWizardSteps = uiMode === 'expert'
150
+ ? [wizardSteps[0], wizardSteps[1], wizardSteps[2], expertAccessStep, wizardSteps[3]]
151
+ : wizardSteps;
152
+ const reviewStepIndex = projectWizardSteps.length - 1;
153
+ const accessStepIndex = uiMode === 'expert' ? 3 : -1;
154
+ const currentWizardStep = projectWizardSteps[wizardStep] ?? projectWizardSteps[0];
155
+ const isFirstWizardStep = wizardStep === 0;
156
+ const isLastWizardStep = wizardStep === reviewStepIndex;
157
 
158
  const appendSource = (source: ProjectSource) => {
159
  setSources((current) => [...current, source]);
 
250
  description,
251
  context: contextPayload,
252
  owner_id: user.id,
253
+ team_id: selectedTeamId,
254
  is_public: isPublic,
255
  status: 'active'
256
  });
 
267
  setNoteContent('');
268
  setSources([]);
269
  setIsPublic(false);
270
+ setWizardStep(0);
271
  setMessage('Project created successfully.');
272
  window.setTimeout(() => onCreated?.(), 500);
273
  }
 
290
  </div>
291
 
292
  <form className="glass-panel project-form" onSubmit={handleSubmit}>
293
+ {isWizard && (
294
+ <div className="wizard-panel">
295
+ <div className="wizard-steps" aria-label="Create project steps">
296
+ {projectWizardSteps.map((step, index) => (
297
+ <button
298
+ key={step.title}
299
+ className={`wizard-step ${wizardStep === index ? 'active' : ''} ${wizardStep > index ? 'complete' : ''}`}
300
+ type="button"
301
+ onClick={() => setWizardStep(index)}
302
+ >
303
+ <span>{index + 1}</span>
304
+ {step.title}
305
+ </button>
306
+ ))}
307
+ </div>
308
+ <div className="wizard-explanation">
309
+ <strong>{currentWizardStep.title}</strong>
310
+ <p>{currentWizardStep.description}</p>
311
+ </div>
312
  </div>
313
  )}
314
 
315
+ {(!isWizard || wizardStep === 0) && (
316
+ <>
317
+ <div className="field-with-help">
318
+ <label>
319
+ <span>Project Name</span>
320
+ <input value={name} onChange={(event) => setName(event.target.value)} required placeholder="Customer onboarding automation" />
321
+ </label>
322
+ <FieldHelp title="What this controls">
323
+ This becomes the workspace title shown on the dashboard, task pages, reports, and spatial view. Use a short outcome-oriented name.
324
+ </FieldHelp>
325
+ </div>
326
 
327
+ <div className="field-with-help">
328
+ <label>
329
+ <span>Description</span>
330
+ <textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What should this project accomplish?" rows={4} />
331
+ </label>
332
+ <FieldHelp title="How agents use it">
333
+ The planner reads this as the main objective when decomposing work. Include the desired result, audience, and success criteria.
334
+ </FieldHelp>
335
+ </div>
336
+ </>
337
+ )}
338
 
339
+ {(!isWizard || wizardStep === 1) && (
340
+ <div className="field-with-help">
341
+ <label>
342
+ <span>Context</span>
343
+ <textarea value={context} onChange={(event) => setContext(event.target.value)} placeholder="Business constraints, preferred tone, source links, acceptance criteria..." rows={6} />
344
+ </label>
345
+ <FieldHelp title="When to add context">
346
+ Add constraints, assumptions, tone, links, examples, acceptance criteria, and known risks. This reduces generic agent output.
347
+ </FieldHelp>
348
+ </div>
349
+ )}
350
 
351
+ {(!isWizard || wizardStep === 2) && (
352
+ <div className="default-agent-panel project-sources-panel" style={{ gap: 'var(--space-lg)' }}>
353
  <div className="settings-section-title">
354
  <Paperclip size={20} color="var(--accent)" />
355
  <h3>Project Sources</h3>
356
  </div>
357
+ <div className="field-with-help field-with-help-compact">
358
+ <div>
359
+ {uiMode === 'guided' && !showAdvancedSources && (
360
+ <>
361
+ <p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}>
362
+ Add the links, notes, or files that should shape the plan. Skip this if the description already contains enough context.
363
+ </p>
364
+ <button className="btn btn-glass" type="button" onClick={() => setShowAdvancedSources(true)}>
365
+ <Paperclip size={16} />
366
+ Add Sources
367
+ </button>
368
+ </>
369
+ )}
370
+
371
+ {(uiMode === 'expert' || showAdvancedSources) && (
372
+ <>
373
+ <div className="responsive-two-col">
374
+ <label>
375
+ <span>Link Label</span>
376
+ <input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} placeholder="Market report" />
377
+ </label>
378
+ <label>
379
+ <span>Link URL</span>
380
+ <input value={sourceUrl} onChange={(event) => setSourceUrl(event.target.value)} placeholder="https://..." />
381
+ </label>
382
+ </div>
383
+ <button className="btn btn-glass" type="button" onClick={handleAddLink}>
384
+ <Link2 size={16} />
385
+ Add Link
386
+ </button>
387
 
388
+ <label>
389
+ <span>Quick Note</span>
390
+ <input value={noteLabel} onChange={(event) => setNoteLabel(event.target.value)} placeholder="Stakeholder note" />
391
+ </label>
392
+ <label>
393
+ <span>Note Content</span>
394
+ <textarea value={noteContent} onChange={(event) => setNoteContent(event.target.value)} rows={3} placeholder="Paste text, markdown, requirements, or snippets..." />
395
+ </label>
396
+ <button className="btn btn-glass" type="button" onClick={handleAddNote}>
397
+ <StickyNote size={16} />
398
+ Add Text
399
+ </button>
400
 
401
+ <input
402
+ ref={fileInputRef}
403
+ type="file"
404
+ multiple
405
+ accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.csv,.json,.rtf"
406
+ onChange={handleFileSelection}
407
+ style={{ display: 'none' }}
408
+ />
409
+ <button className="btn btn-glass" type="button" onClick={() => fileInputRef.current?.click()}>
410
+ <Paperclip size={16} />
411
+ Add Files
412
+ </button>
 
 
 
 
413
 
414
+ <p style={{ color: 'var(--text-dim)', fontSize: '0.85rem', marginTop: '-0.25rem' }}>
415
+ Text and markdown files are embedded into project context. PDF, Word, and Excel files are stored as named references in the context.
416
+ </p>
417
+ </>
418
+ )}
419
+ </div>
420
+ <FieldHelp title="What sources do">
421
+ Sources are appended to the project context before agents plan work. Use links for references, notes for stakeholder input, and files for specs or datasets.
422
+ </FieldHelp>
423
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
  {sources.length > 0 && (
426
  <div className="task-list">
 
431
  <p>
432
  {source.kind === 'link' && source.url}
433
  {source.kind === 'note' && source.content}
434
+ {source.kind === 'file' && `${source.fileName} - ${formatFileSize(source.size)}${source.extracted ? ' - text imported' : ' - reference only'}`}
435
  </p>
436
  </div>
437
  <button className="btn btn-glass btn-sm" type="button" onClick={() => removeSource(source.id)}>
 
443
  </div>
444
  )}
445
  </div>
446
+ )}
447
 
448
+ {uiMode === 'expert' && (!isWizard || wizardStep === accessStepIndex) && (
449
+ <div className="expert-access-fields" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' }}>
450
+ <div className="field-with-help">
451
+ <label>
452
+ <span>Team Workspace (Optional)</span>
453
+ <select
454
+ className="glass-input"
455
+ value={selectedTeamId || ''}
456
+ onChange={(e) => setSelectedTeamId(e.target.value || null)}
457
+ >
458
+ <option value="">Personal Project (No Team)</option>
459
+ {teams.map(team => (
460
+ <option key={team.id} value={team.id}>{team.name}</option>
461
+ ))}
462
+ </select>
463
+ </label>
464
+ <FieldHelp title="Shared Context">
465
+ Projects assigned to a team are visible to all team members according to their roles (admin, editor, viewer).
466
+ </FieldHelp>
467
+ </div>
468
+
469
+ <div className="field-with-help">
470
+ <label className="toggle-row">
471
+ <input type="checkbox" checked={isPublic} onChange={(event) => setIsPublic(event.target.checked)} />
472
+ <span>Make project visible to all authenticated users (Public)</span>
473
+ </label>
474
+ <FieldHelp title="Global Visibility">
475
+ Public projects can be read by any authenticated user in the entire platform. Use this for open templates or public datasets.
476
+ </FieldHelp>
477
+ </div>
478
+ </div>
479
+ )}
480
+
481
+ {isWizard && wizardStep === reviewStepIndex && (
482
+ <div className="wizard-review">
483
+ <div>
484
+ <span>Project name</span>
485
+ <strong>{name.trim() || 'Missing project name'}</strong>
486
+ </div>
487
+ <div>
488
+ <span>Description</span>
489
+ <p>{description.trim() || 'No description provided.'}</p>
490
+ </div>
491
+ <div>
492
+ <span>Workspace</span>
493
+ <p>{selectedTeamId ? `Team: ${teams.find(t => t.id === selectedTeamId)?.name}` : 'Personal project'}</p>
494
+ </div>
495
+ <div>
496
+ <span>Visibility</span>
497
+ <p>{isPublic ? 'Public' : 'Private'}</p>
498
+ </div>
499
+ <div>
500
+ <span>Context</span>
501
+ <p>{context.trim() || 'No extra context provided.'}</p>
502
+ </div>
503
+ <div>
504
+ <span>Sources</span>
505
+ <p>{sources.length > 0 ? `${sources.length} source${sources.length === 1 ? '' : 's'} attached.` : 'No sources attached.'}</p>
506
+ </div>
507
+ </div>
508
  )}
509
 
510
  {message && (
 
514
  </div>
515
  )}
516
 
517
+ <div className="field-with-help field-with-help-action">
518
+ {isWizard ? (
519
+ <div className="wizard-actions">
520
+ <button className="btn btn-glass" type="button" onClick={() => setWizardStep((step) => Math.max(0, step - 1))} disabled={isFirstWizardStep || saving}>
521
+ <ArrowLeft size={18} />
522
+ Back
523
+ </button>
524
+ {!isLastWizardStep ? (
525
+ <button
526
+ className="btn btn-primary"
527
+ type="button"
528
+ onClick={() => setWizardStep((step) => Math.min(projectWizardSteps.length - 1, step + 1))}
529
+ disabled={wizardStep === 0 && !name.trim()}
530
+ >
531
+ Next
532
+ <ArrowRight size={18} />
533
+ </button>
534
+ ) : (
535
+ <button className="btn btn-primary" type="submit" disabled={saving || !name.trim()}>
536
+ <PlusCircle size={18} />
537
+ {saving ? 'Creating...' : 'Create Project'}
538
+ </button>
539
+ )}
540
+ </div>
541
+ ) : (
542
+ <button className="btn btn-primary" type="submit" disabled={saving}>
543
+ <PlusCircle size={18} />
544
+ {saving ? 'Creating...' : 'Create Project'}
545
+ </button>
546
+ )}
547
+ <FieldHelp title="Next step">
548
+ After creation, open the project and run the orchestrator to generate tasks. Review outputs before approving the final report.
549
+ </FieldHelp>
550
+ </div>
551
  </form>
552
  </motion.div>
553
  );
frontend/src/components/TeamsView.tsx ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ Users,
4
+ UserPlus,
5
+ Settings,
6
+ Trash2,
7
+ Shield,
8
+ Mail,
9
+ Plus,
10
+ ArrowRight,
11
+ ShieldAlert,
12
+ Search
13
+ } from 'lucide-react';
14
+ import { supabase } from '../services/supabase';
15
+ import { useAuth } from '../context/useAuth';
16
+
17
+ interface Team {
18
+ id: string;
19
+ name: string;
20
+ created_at: string;
21
+ role?: 'admin' | 'editor' | 'viewer';
22
+ }
23
+
24
+ interface TeamMember {
25
+ id: string;
26
+ user_id: string;
27
+ role: 'admin' | 'editor' | 'viewer';
28
+ created_at: string;
29
+ full_name?: string;
30
+ email?: string;
31
+ }
32
+
33
+ const TeamsView: React.FC = () => {
34
+ const { user, profile } = useAuth();
35
+ const [teams, setTeams] = useState<Team[]>([]);
36
+ const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
37
+ const [members, setMembers] = useState<TeamMember[]>([]);
38
+ const [loading, setLoading] = useState(true);
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [showCreateModal, setShowCreateModal] = useState(false);
41
+ const [newTeamName, setNewTeamName] = useState('');
42
+ const [inviteEmail, setInviteEmail] = useState('');
43
+ const [inviteRole, setInviteRole] = useState<'admin' | 'editor' | 'viewer'>('viewer');
44
+ const [actionPending, setActionPending] = useState(false);
45
+
46
+ useEffect(() => {
47
+ fetchTeams();
48
+ }, []);
49
+
50
+ useEffect(() => {
51
+ if (selectedTeam) {
52
+ fetchMembers(selectedTeam.id);
53
+ }
54
+ }, [selectedTeam]);
55
+
56
+ const fetchTeams = async () => {
57
+ setLoading(true);
58
+ try {
59
+ // Fetch teams where user is a member
60
+ const { data, error } = await supabase
61
+ .from('teams')
62
+ .select(`
63
+ *,
64
+ team_members!inner(role)
65
+ `);
66
+
67
+ if (error) throw error;
68
+
69
+ const formattedTeams = data.map(t => ({
70
+ ...t,
71
+ role: t.team_members[0].role
72
+ }));
73
+
74
+ setTeams(formattedTeams);
75
+ if (formattedTeams.length > 0 && !selectedTeam) {
76
+ setSelectedTeam(formattedTeams[0]);
77
+ }
78
+ } catch (err: any) {
79
+ setError(err.message);
80
+ } finally {
81
+ setLoading(false);
82
+ }
83
+ };
84
+
85
+ const fetchMembers = async (teamId: string) => {
86
+ try {
87
+ const { data, error } = await supabase
88
+ .from('team_members')
89
+ .select(`
90
+ *,
91
+ profiles:user_id(full_name, email)
92
+ `)
93
+ .eq('team_id', teamId);
94
+
95
+ if (error) throw error;
96
+
97
+ const formattedMembers = data.map(m => ({
98
+ ...m,
99
+ full_name: m.profiles?.full_name,
100
+ email: m.profiles?.email
101
+ }));
102
+
103
+ setMembers(formattedMembers);
104
+ } catch (err: any) {
105
+ setError(err.message);
106
+ }
107
+ };
108
+
109
+ const createTeam = async () => {
110
+ if (!newTeamName.trim()) return;
111
+ setActionPending(true);
112
+ try {
113
+ const { data, error } = await supabase
114
+ .from('teams')
115
+ .insert([{ name: newTeamName, created_by: user?.id }])
116
+ .select()
117
+ .single();
118
+
119
+ if (error) throw error;
120
+
121
+ setNewTeamName('');
122
+ setShowCreateModal(false);
123
+ fetchTeams();
124
+ } catch (err: any) {
125
+ setError(err.message);
126
+ } finally {
127
+ setActionPending(false);
128
+ }
129
+ };
130
+
131
+ const inviteMember = async () => {
132
+ if (!selectedTeam || !inviteEmail.trim()) return;
133
+ setActionPending(true);
134
+ try {
135
+ // In a real app, we'd send an email or look up by email.
136
+ // For this demo, we'll assume we can look up by email in profiles.
137
+ const { data: userData, error: userError } = await supabase
138
+ .from('profiles')
139
+ .select('id')
140
+ .eq('email', inviteEmail.trim())
141
+ .single();
142
+
143
+ if (userError || !userData) {
144
+ throw new Error('User not found. Please ensure the user has signed up.');
145
+ }
146
+
147
+ const { error: inviteError } = await supabase
148
+ .from('team_members')
149
+ .insert([{
150
+ team_id: selectedTeam.id,
151
+ user_id: userData.id,
152
+ role: inviteRole
153
+ }]);
154
+
155
+ if (inviteError) throw inviteError;
156
+
157
+ setInviteEmail('');
158
+ fetchMembers(selectedTeam.id);
159
+ } catch (err: any) {
160
+ setError(err.message);
161
+ } finally {
162
+ setActionPending(false);
163
+ }
164
+ };
165
+
166
+ const removeMember = async (memberId: string) => {
167
+ if (!selectedTeam) return;
168
+ if (!window.confirm('Are you sure you want to remove this member?')) return;
169
+
170
+ try {
171
+ const { error } = await supabase
172
+ .from('team_members')
173
+ .delete()
174
+ .eq('id', memberId);
175
+
176
+ if (error) throw error;
177
+ fetchMembers(selectedTeam.id);
178
+ } catch (err: any) {
179
+ setError(err.message);
180
+ }
181
+ };
182
+
183
+ const updateMemberRole = async (memberId: string, newRole: string) => {
184
+ try {
185
+ const { error } = await supabase
186
+ .from('team_members')
187
+ .update({ role: newRole })
188
+ .eq('id', memberId);
189
+
190
+ if (error) throw error;
191
+ if (selectedTeam) fetchMembers(selectedTeam.id);
192
+ } catch (err: any) {
193
+ setError(err.message);
194
+ }
195
+ };
196
+
197
+ return (
198
+ <div className="teams-view">
199
+ <header className="view-header">
200
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
201
+ <Users size={32} color="var(--accent)" />
202
+ <div>
203
+ <h2 style={{ margin: 0 }}>Team Management</h2>
204
+ <p style={{ color: 'var(--text-dim)', margin: 0 }}>Manage shared workspaces and permissions</p>
205
+ </div>
206
+ </div>
207
+ <button className="btn btn-primary" onClick={() => setShowCreateModal(true)}>
208
+ <Plus size={18} />
209
+ Create Team
210
+ </button>
211
+ </header>
212
+
213
+ {error && (
214
+ <div className="glass-panel alert alert-danger" style={{ marginBottom: 'var(--space-md)' }}>
215
+ <ShieldAlert size={20} />
216
+ <span>{error}</span>
217
+ <button onClick={() => setError(null)} style={{ marginLeft: 'auto', background: 'none', border: 'none', color: 'inherit', cursor: 'pointer' }}>×</button>
218
+ </div>
219
+ )}
220
+
221
+ <div className="teams-layout" style={{ display: 'grid', gridTemplateColumns: '280px 1fr', gap: 'var(--space-lg)', marginTop: 'var(--space-lg)' }}>
222
+ {/* Teams List */}
223
+ <div className="teams-list">
224
+ <h3 style={{ fontSize: '0.9rem', textTransform: 'uppercase', color: 'var(--text-dim)', marginBottom: 'var(--space-md)' }}>Your Teams</h3>
225
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
226
+ {teams.length === 0 && !loading && (
227
+ <div className="glass-panel" style={{ padding: 'var(--space-md)', textAlign: 'center', color: 'var(--text-dim)' }}>
228
+ No teams yet.
229
+ </div>
230
+ )}
231
+ {teams.map(team => (
232
+ <button
233
+ key={team.id}
234
+ className={`glass-panel team-item ${selectedTeam?.id === team.id ? 'active' : ''}`}
235
+ onClick={() => setSelectedTeam(team)}
236
+ style={{
237
+ display: 'flex',
238
+ alignItems: 'center',
239
+ justifyContent: 'space-between',
240
+ padding: 'var(--space-md)',
241
+ width: '100%',
242
+ textAlign: 'left',
243
+ border: selectedTeam?.id === team.id ? '1px solid var(--accent)' : '1px solid transparent',
244
+ background: selectedTeam?.id === team.id ? 'rgba(var(--accent-rgb), 0.1)' : 'var(--glass-bg)'
245
+ }}
246
+ >
247
+ <div>
248
+ <div style={{ fontWeight: 600 }}>{team.name}</div>
249
+ <div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Role: {team.role}</div>
250
+ </div>
251
+ <ArrowRight size={16} style={{ opacity: selectedTeam?.id === team.id ? 1 : 0 }} />
252
+ </button>
253
+ ))}
254
+ </div>
255
+ </div>
256
+
257
+ {/* Selected Team Details */}
258
+ <div className="team-details">
259
+ {selectedTeam ? (
260
+ <div className="glass-panel" style={{ padding: 'var(--space-lg)', height: '100%' }}>
261
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 'var(--space-xl)' }}>
262
+ <div>
263
+ <h3 style={{ margin: 0, fontSize: '1.5rem' }}>{selectedTeam.name}</h3>
264
+ <p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}>Team ID: {selectedTeam.id}</p>
265
+ </div>
266
+ {selectedTeam.role === 'admin' && (
267
+ <button className="btn btn-glass btn-sm" style={{ color: 'var(--danger)' }}>
268
+ <Trash2 size={16} />
269
+ Delete Team
270
+ </button>
271
+ )}
272
+ </div>
273
+
274
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 350px', gap: 'var(--space-xl)' }}>
275
+ {/* Members Section */}
276
+ <section>
277
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
278
+ <h4 style={{ margin: 0 }}>Team Members ({members.length})</h4>
279
+ </div>
280
+ <div className="members-table" style={{ background: 'rgba(0,0,0,0.2)', borderRadius: '12px', overflow: 'hidden' }}>
281
+ {members.map(member => (
282
+ <div key={member.id} style={{
283
+ padding: 'var(--space-md)',
284
+ borderBottom: '1px solid rgba(255,255,255,0.05)',
285
+ display: 'flex',
286
+ alignItems: 'center',
287
+ gap: 'var(--space-md)'
288
+ }}>
289
+ <div style={{
290
+ width: 40, height: 40,
291
+ borderRadius: '50%',
292
+ background: 'var(--primary)',
293
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
294
+ fontWeight: 600
295
+ }}>
296
+ {(member.full_name || member.email || 'U').slice(0, 2).toUpperCase()}
297
+ </div>
298
+ <div style={{ flex: 1 }}>
299
+ <div style={{ fontWeight: 500 }}>{member.full_name || 'Anonymous User'}</div>
300
+ <div style={{ fontSize: '0.8rem', color: 'var(--text-dim)' }}>{member.email}</div>
301
+ </div>
302
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
303
+ <select
304
+ value={member.role}
305
+ onChange={(e) => updateMemberRole(member.id, e.target.value)}
306
+ disabled={selectedTeam.role !== 'admin' || member.user_id === user?.id}
307
+ className="glass-input"
308
+ style={{ padding: '4px 8px', fontSize: '0.8rem' }}
309
+ >
310
+ <option value="admin">Admin</option>
311
+ <option value="editor">Editor</option>
312
+ <option value="viewer">Viewer</option>
313
+ </select>
314
+ {selectedTeam.role === 'admin' && member.user_id !== user?.id && (
315
+ <button
316
+ onClick={() => removeMember(member.id)}
317
+ className="btn btn-glass btn-sm"
318
+ style={{ padding: '6px', color: 'var(--danger)' }}
319
+ >
320
+ <Trash2 size={16} />
321
+ </button>
322
+ )}
323
+ </div>
324
+ </div>
325
+ ))}
326
+ </div>
327
+ </section>
328
+
329
+ {/* Invite Section */}
330
+ {selectedTeam.role === 'admin' && (
331
+ <section className="glass-panel" style={{ padding: 'var(--space-lg)', height: 'fit-content' }}>
332
+ <h4 style={{ marginTop: 0 }}>Invite Member</h4>
333
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-dim)' }}>Invite someone to collaborate on team projects.</p>
334
+
335
+ <div style={{ marginTop: 'var(--space-md)' }}>
336
+ <label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '4px' }}>Email Address</label>
337
+ <input
338
+ type="email"
339
+ className="glass-input"
340
+ placeholder="colleague@example.com"
341
+ value={inviteEmail}
342
+ onChange={(e) => setInviteEmail(e.target.value)}
343
+ style={{ width: '100%', marginBottom: 'var(--space-md)' }}
344
+ />
345
+
346
+ <label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '4px' }}>Role</label>
347
+ <select
348
+ className="glass-input"
349
+ value={inviteRole}
350
+ onChange={(e) => setInviteRole(e.target.value as any)}
351
+ style={{ width: '100%', marginBottom: 'var(--space-lg)' }}
352
+ >
353
+ <option value="admin">Admin (Manage members & projects)</option>
354
+ <option value="editor">Editor (Create & edit projects)</option>
355
+ <option value="viewer">Viewer (Read only)</option>
356
+ </select>
357
+
358
+ <button
359
+ className="btn btn-primary"
360
+ style={{ width: '100%' }}
361
+ onClick={inviteMember}
362
+ disabled={actionPending || !inviteEmail.trim()}
363
+ >
364
+ {actionPending ? 'Inviting...' : 'Send Invitation'}
365
+ </button>
366
+ </div>
367
+ </section>
368
+ )}
369
+ </div>
370
+ </div>
371
+ ) : (
372
+ <div className="glass-panel" style={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: 'var(--text-dim)', padding: 'var(--space-xl)' }}>
373
+ <Users size={64} style={{ marginBottom: 'var(--space-lg)', opacity: 0.2 }} />
374
+ <h3>Select a team to manage</h3>
375
+ <p>Choose a team from the sidebar to view members and permissions.</p>
376
+ </div>
377
+ )}
378
+ </div>
379
+ </div>
380
+
381
+ {/* Create Team Modal */}
382
+ {showCreateModal && (
383
+ <div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
384
+ <div className="glass-panel modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
385
+ <h3>Create New Team</h3>
386
+ <p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}>Teams allow you to share projects and collaborate with other users.</p>
387
+
388
+ <div style={{ marginTop: 'var(--space-lg)' }}>
389
+ <label style={{ display: 'block', fontSize: '0.85rem', marginBottom: '8px' }}>Team Name</label>
390
+ <input
391
+ autoFocus
392
+ className="glass-input"
393
+ placeholder="Marketing Engine"
394
+ value={newTeamName}
395
+ onChange={(e) => setNewTeamName(e.target.value)}
396
+ style={{ width: '100%', marginBottom: 'var(--space-xl)' }}
397
+ />
398
+
399
+ <div className="button-row">
400
+ <button className="btn btn-glass" onClick={() => setShowCreateModal(false)}>Cancel</button>
401
+ <button
402
+ className="btn btn-primary"
403
+ onClick={createTeam}
404
+ disabled={actionPending || !newTeamName.trim()}
405
+ >
406
+ {actionPending ? 'Creating...' : 'Create Team'}
407
+ </button>
408
+ </div>
409
+ </div>
410
+ </div>
411
+ </div>
412
+ )}
413
+ </div>
414
+ );
415
+ };
416
+
417
+ export default TeamsView;